use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
pub type MemberInput = (String, Vec<String>, Vec<(String, i32)>, i32);
#[must_use]
#[tracing::instrument(
name = "consumer.assignor.cooperative_sticky",
level = "info",
skip_all,
fields(members = members.len(), topics = topic_partitions.len())
)]
pub fn assign(
members: &[MemberInput],
topic_partitions: &HashMap<String, i32>,
) -> HashMap<String, Vec<(String, i32)>> {
if members.is_empty() {
return HashMap::new();
}
let (member_ids, previous_owner, raw_assignment) = sticky_assignment(members, topic_partitions);
let mut adjusted: HashMap<String, Vec<(String, i32)>> = HashMap::new();
for id in &member_ids {
adjusted.insert(id.clone(), Vec::new());
}
for id in &member_ids {
let new_parts = raw_assignment.get(id).cloned().unwrap_or_default();
for tp in new_parts {
match previous_owner.get(&tp) {
Some(prev) if prev != id => {
}
_ => {
adjusted.get_mut(id).unwrap().push(tp);
}
}
}
}
for v in adjusted.values_mut() {
v.sort();
}
adjusted
}
#[must_use]
#[tracing::instrument(
name = "consumer.assignor.sticky",
level = "info",
skip_all,
fields(members = members.len(), topics = topic_partitions.len())
)]
pub fn assign_eager(
members: &[MemberInput],
topic_partitions: &HashMap<String, i32>,
) -> HashMap<String, Vec<(String, i32)>> {
if members.is_empty() {
return HashMap::new();
}
let (member_ids, _, raw_assignment) = sticky_assignment(members, topic_partitions);
member_ids
.into_iter()
.map(|id| {
let mut partitions = raw_assignment.get(&id).cloned().unwrap_or_default();
partitions.sort();
(id, partitions)
})
.collect()
}
type StickyAssignment = (
Vec<String>,
HashMap<(String, i32), String>,
HashMap<String, Vec<(String, i32)>>,
);
fn sticky_assignment(
members: &[MemberInput],
topic_partitions: &HashMap<String, i32>,
) -> StickyAssignment {
let mut member_ids: Vec<String> = members.iter().map(|(id, _, _, _)| id.clone()).collect();
member_ids.sort();
let subs: BTreeMap<String, BTreeSet<String>> = members
.iter()
.map(|(id, subs, _, _)| (id.clone(), subs.iter().cloned().collect()))
.collect();
let gens: BTreeMap<String, i32> = members
.iter()
.map(|(id, _, _, generation)| (id.clone(), *generation))
.collect();
let current_assignment =
prepopulate_current_assignments(members, &subs, &gens, topic_partitions);
let mut previous_owner: HashMap<(String, i32), String> = HashMap::new();
for (m, parts) in ¤t_assignment {
for tp in parts {
previous_owner.insert(tp.clone(), m.clone());
}
}
let all_equal = {
let mut iter = subs.values();
match iter.next() {
None => true,
Some(first) => iter.all(|s| s == first),
}
};
let raw_assignment = if all_equal {
constrained_assign(&member_ids, &subs, ¤t_assignment, topic_partitions)
} else {
general_assign(&member_ids, &subs, ¤t_assignment, topic_partitions)
};
(member_ids, previous_owner, raw_assignment)
}
fn prepopulate_current_assignments(
members: &[MemberInput],
subs: &BTreeMap<String, BTreeSet<String>>,
_gens: &BTreeMap<String, i32>,
topic_partitions: &HashMap<String, i32>,
) -> BTreeMap<String, Vec<(String, i32)>> {
let mut best: HashMap<(String, i32), (i32, String, bool)> = HashMap::new();
for (id, _id_subs, owned, generation) in members {
let id_subs = subs.get(id).cloned().unwrap_or_default();
for (t, p) in owned {
let Some(&pcount) = topic_partitions.get(t) else {
continue;
};
if *p < 0 || *p >= pcount {
continue;
}
if !id_subs.contains(t) {
continue;
}
let key = (t.clone(), *p);
match best.get(&key) {
None => {
best.insert(key, (*generation, id.clone(), false));
}
Some((existing_gen, _existing_id, _tied)) => {
if *generation > *existing_gen {
best.insert(key, (*generation, id.clone(), false));
} else if *generation == *existing_gen {
let prev = best.get(&key).cloned().unwrap();
best.insert(key, (prev.0, prev.1, true));
}
}
}
}
}
let mut out: BTreeMap<String, Vec<(String, i32)>> = BTreeMap::new();
for (id, _, _, _) in members {
out.insert(id.clone(), Vec::new());
}
for (tp, (_gen, owner, tied)) in best {
if tied {
continue;
}
out.entry(owner).or_default().push(tp);
}
for v in out.values_mut() {
v.sort();
}
out
}
fn constrained_assign(
member_ids: &[String],
subs: &BTreeMap<String, BTreeSet<String>>,
current_assignment: &BTreeMap<String, Vec<(String, i32)>>,
topic_partitions: &HashMap<String, i32>,
) -> HashMap<String, Vec<(String, i32)>> {
let num_members = member_ids.len();
if num_members == 0 {
return HashMap::new();
}
let shared: BTreeSet<String> = member_ids
.first()
.and_then(|m| subs.get(m).cloned())
.unwrap_or_default();
let all_partitions: Vec<(String, i32)> = shared
.iter()
.filter_map(|topic| topic_partitions.get(topic).map(|count| (topic, *count)))
.flat_map(|(topic, count)| (0..count.max(0)).map(move |p| (topic.clone(), p)))
.collect();
let total = all_partitions.len();
let min_quota = total / num_members;
let max_quota = total.div_ceil(num_members);
let expected_over_min = total % num_members;
let mut current_over_min = 0;
let mut assignment: HashMap<String, Vec<(String, i32)>> = member_ids
.iter()
.map(|id| (id.clone(), Vec::new()))
.collect();
let mut assigned: HashSet<(String, i32)> = HashSet::new();
let mut under_min: Vec<String> = Vec::new();
let mut exactly_min: std::collections::VecDeque<String> = std::collections::VecDeque::new();
for id in member_ids {
let mut owned: Vec<(String, i32)> = current_assignment.get(id).cloned().unwrap_or_default();
owned.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
let slot = assignment.get_mut(id).expect("member slot");
if owned.len() < min_quota {
assigned.extend(owned.iter().cloned());
slot.extend(owned);
under_min.push(id.clone());
} else if owned.len() >= max_quota && current_over_min < expected_over_min {
current_over_min += 1;
if current_over_min == expected_over_min {
exactly_min.clear();
}
owned.truncate(max_quota);
assigned.extend(owned.iter().cloned());
slot.extend(owned);
} else {
owned.truncate(min_quota);
assigned.extend(owned.iter().cloned());
slot.extend(owned);
if current_over_min < expected_over_min {
exactly_min.push_back(id.clone());
}
}
}
let unassigned: Vec<(String, i32)> = all_partitions
.into_iter()
.filter(|partition| !assigned.contains(partition))
.collect();
under_min.sort();
exactly_min.make_contiguous().sort();
let mut cursor = 0;
for partition in unassigned {
let member = if cursor < under_min.len() {
under_min[cursor].clone()
} else if under_min.is_empty() {
let Some(member) = exactly_min.pop_front() else {
break;
};
member
} else {
cursor = 0;
under_min[0].clone()
};
let slot = assignment.get_mut(&member).expect("member slot");
slot.push(partition);
let count = slot.len();
if cursor < under_min.len() && under_min[cursor] == member {
if count == min_quota {
under_min.remove(cursor);
exactly_min.push_back(member);
} else {
cursor += 1;
}
}
}
for partitions in assignment.values_mut() {
partitions.sort();
}
assignment
}
fn general_assign(
member_ids: &[String],
subs: &BTreeMap<String, BTreeSet<String>>,
current_assignment: &BTreeMap<String, Vec<(String, i32)>>,
topic_partitions: &HashMap<String, i32>,
) -> HashMap<String, Vec<(String, i32)>> {
let mut subscribers_per_topic: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (id, s) in subs {
for t in s {
if topic_partitions.contains_key(t) {
subscribers_per_topic
.entry(t.clone())
.or_default()
.insert(id.clone());
}
}
}
let mut sorted_partitions: Vec<(String, i32)> = Vec::new();
for t in subscribers_per_topic.keys() {
let Some(&n) = topic_partitions.get(t) else {
continue;
};
for p in 0..n {
sorted_partitions.push((t.clone(), p));
}
}
sorted_partitions.sort_by(|a, b| {
let sa = subscribers_per_topic.get(&a.0).map_or(0, BTreeSet::len);
let sb = subscribers_per_topic.get(&b.0).map_or(0, BTreeSet::len);
sa.cmp(&sb)
.then_with(|| a.0.cmp(&b.0))
.then_with(|| a.1.cmp(&b.1))
});
let mut new_assignment: BTreeMap<String, Vec<(String, i32)>> = BTreeMap::new();
for id in member_ids {
new_assignment.insert(id.clone(), Vec::new());
}
let mut prev_owner: HashMap<(String, i32), String> = HashMap::new();
for (m, parts) in current_assignment {
for tp in parts {
prev_owner.insert(tp.clone(), m.clone());
}
}
for tp in &sorted_partitions {
let subscribed: BTreeSet<String> = subscribers_per_topic
.get(&tp.0)
.cloned()
.unwrap_or_default();
if subscribed.is_empty() {
continue;
}
let chosen = choose_assignment_owner(tp, &subscribed, &prev_owner, &new_assignment);
new_assignment
.get_mut(&chosen)
.expect("chosen exists")
.push(tp.clone());
}
balance_assignment(
member_ids,
subs,
&prev_owner,
&mut new_assignment,
max_balance_iters(sorted_partitions.len(), member_ids.len()),
);
let mut out: HashMap<String, Vec<(String, i32)>> = HashMap::new();
for (k, mut v) in new_assignment {
v.sort();
out.insert(k, v);
}
out
}
fn pick_least_loaded(
subscribed: &BTreeSet<String>,
assignment: &BTreeMap<String, Vec<(String, i32)>>,
) -> String {
least_loaded(subscribed, assignment).1
}
fn least_loaded(
subscribed: &BTreeSet<String>,
assignment: &BTreeMap<String, Vec<(String, i32)>>,
) -> (usize, String) {
let mut best: Option<(usize, String)> = None;
for id in subscribed {
let load = assignment.get(id).map_or(0, Vec::len);
match &best {
None => best = Some((load, id.clone())),
Some((bl, bid)) => {
debug_assert!(id >= bid, "BTreeSet iteration must be lexicographic");
if load < *bl {
best = Some((load, id.clone()));
}
}
}
}
best.expect("non-empty subscription")
}
fn choose_assignment_owner(
tp: &(String, i32),
subscribed: &BTreeSet<String>,
prev_owner: &HashMap<(String, i32), String>,
assignment: &BTreeMap<String, Vec<(String, i32)>>,
) -> String {
let (min_load, _) = least_loaded(subscribed, assignment);
match prev_owner.get(tp) {
Some(prev) if subscribed.contains(prev) => {
let prev_load = assignment.get(prev).map_or(0, Vec::len);
if prev_load <= min_load {
prev.clone()
} else {
pick_least_loaded(subscribed, assignment)
}
}
_ => pick_least_loaded(subscribed, assignment),
}
}
fn max_balance_iters(partitions: usize, members: usize) -> usize {
partitions.saturating_mul(members) + 16
}
fn balance_assignment(
member_ids: &[String],
subs: &BTreeMap<String, BTreeSet<String>>,
prev_owner: &HashMap<(String, i32), String>,
new_assignment: &mut BTreeMap<String, Vec<(String, i32)>>,
max_iters: usize,
) {
let load = |assignment: &BTreeMap<String, Vec<(String, i32)>>, id: &str| {
assignment.get(id).map_or(0, Vec::len)
};
let mut moves = 0;
loop {
let mut modified = false;
let mut partitions: Vec<((String, i32), String)> = new_assignment
.iter()
.flat_map(|(id, parts)| parts.iter().map(move |tp| (tp.clone(), id.clone())))
.collect();
partitions.sort();
for (partition, owner) in partitions {
if moves >= max_iters {
return;
}
let owner_load = load(new_assignment, &owner);
let subscribers: Vec<&String> = member_ids
.iter()
.filter(|id| {
subs.get(*id)
.is_some_and(|topics| topics.contains(&partition.0))
})
.collect();
if subscribers.len() <= 1 {
continue;
}
let target = match prev_owner.get(&partition) {
Some(previous)
if previous != &owner
&& subscribers.contains(&previous)
&& owner_load > load(new_assignment, previous) + 1 =>
{
Some(previous.clone())
}
_ => subscribers
.iter()
.filter(|id| owner_load > load(new_assignment, id) + 1)
.min_by(|a, b| {
load(new_assignment, a)
.cmp(&load(new_assignment, b))
.then_with(|| a.cmp(b))
})
.map(|id| (*id).clone()),
};
let Some(target) = target else {
continue;
};
if let Some(parts) = new_assignment.get_mut(&owner)
&& let Some(position) = parts.iter().position(|tp| tp == &partition)
{
parts.remove(position);
}
new_assignment.entry(target).or_default().push(partition);
moves += 1;
modified = true;
}
if !modified {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tp(items: &[(&str, i32)]) -> Vec<(String, i32)> {
items.iter().map(|(t, p)| ((*t).to_string(), *p)).collect()
}
fn topics(ts: &[&str]) -> Vec<String> {
ts.iter().map(|s| (*s).to_string()).collect()
}
fn total_assigned(out: &HashMap<String, Vec<(String, i32)>>) -> usize {
out.values().map(Vec::len).sum()
}
fn member_ids(ids: &[&str]) -> Vec<String> {
ids.iter().map(|id| (*id).to_string()).collect()
}
fn subs(items: &[(&str, &[&str])]) -> BTreeMap<String, BTreeSet<String>> {
items
.iter()
.map(|(id, topics)| {
(
(*id).to_string(),
topics.iter().map(|t| (*t).to_string()).collect(),
)
})
.collect()
}
fn topic_parts(items: &[(&str, i32)]) -> HashMap<String, i32> {
items
.iter()
.map(|(topic, count)| ((*topic).to_string(), *count))
.collect()
}
fn current(items: &[(&str, &[(&str, i32)])]) -> BTreeMap<String, Vec<(String, i32)>> {
items
.iter()
.map(|(id, partitions)| ((*id).to_string(), tp(partitions)))
.collect()
}
fn assigned(items: &[(&str, &[(&str, i32)])]) -> HashMap<String, Vec<(String, i32)>> {
items
.iter()
.map(|(id, partitions)| ((*id).to_string(), tp(partitions)))
.collect()
}
#[test]
fn single_member_takes_all() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 4);
let a = assign(
&[("m1".to_string(), topics(&["t"]), vec![], 0)],
&topic_parts,
);
assert2::assert!(a["m1"] == tp(&[("t", 0), ("t", 1), ("t", 2), ("t", 3)]));
}
#[test]
fn constrained_assignment_keeps_owned_partitions_within_the_quotas() {
let one = topic_parts(&[("t", 1)]);
let three = topic_parts(&[("t", 3)]);
let mut actual = Vec::new();
let mut wanted = Vec::new();
for (name, members, counts, expected) in [
(
"a new member does not take the only partition from its owner",
vec![
("a".to_string(), topics(&["t"]), vec![], 1),
("z".to_string(), topics(&["t"]), tp(&[("t", 0)]), 1),
],
one.clone(),
assigned(&[("a", &[]), ("z", &[("t", 0)])]),
),
(
"the owner of two keeps both when one member may be over the min quota",
vec![
("a".to_string(), topics(&["t"]), vec![], 1),
(
"z".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 2)]),
1,
),
],
three.clone(),
assigned(&[("a", &[("t", 1)]), ("z", &[("t", 0), ("t", 2)])]),
),
] {
actual.push((name, assign_eager(&members, &counts)));
wanted.push((name, expected));
}
assert2::assert!(actual == wanted);
}
#[test]
fn general_assignment_moves_between_any_compatible_pair() {
let counts = topic_parts(&[("x", 2), ("y", 5), ("z", 1)]);
let members = vec![
("a".to_string(), topics(&["x"]), vec![], -1),
("b".to_string(), topics(&["y"]), vec![], -1),
("c".to_string(), topics(&["z"]), vec![], -1),
("d".to_string(), topics(&["x", "y"]), vec![], -1),
];
let loads: BTreeMap<String, usize> = assign_eager(&members, &counts)
.into_iter()
.map(|(id, partitions)| (id, partitions.len()))
.collect();
assert2::assert!(
loads
== maplit::btreemap! {"a".to_string() => 2, "b".to_string() => 3, "c".to_string() => 1, "d".to_string() => 2}
);
}
#[test]
fn fresh_join_balances() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 4);
let a = assign(
&[
("m1".to_string(), topics(&["t"]), vec![], 0),
("m2".to_string(), topics(&["t"]), vec![], 0),
],
&topic_parts,
);
assert2::assert!(
a == assigned(&[("m1", &[("t", 0), ("t", 2)]), ("m2", &[("t", 1), ("t", 3)])])
);
}
#[test]
fn prepopulate_prefers_higher_generation_and_drops_exact_ties() {
let topic_parts = topic_parts(&[("t", 3)]);
let subs = subs(&[("m1", &["t"]), ("m2", &["t"]), ("m3", &["t"])]);
let gens =
maplit::btreemap! {"m1".to_string() => 5, "m2".to_string() => 4, "m3".to_string() => 5};
let out = prepopulate_current_assignments(
&[
(
"m1".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 1)]),
5,
),
("m2".to_string(), topics(&["t"]), tp(&[("t", 0)]), 4),
("m3".to_string(), topics(&["t"]), tp(&[("t", 1)]), 5),
],
&subs,
&gens,
&topic_parts,
);
assert2::assert!(out == current(&[("m1", &[("t", 0)]), ("m2", &[]), ("m3", &[])]));
}
#[test]
fn general_assign_chooses_least_loaded_subscriber() {
let out = general_assign(
&member_ids(&["m1", "m2"]),
&subs(&[("m1", &["t"]), ("m2", &["t"])]),
&BTreeMap::new(),
&topic_parts(&[("t", 2)]),
);
assert2::assert!(
out == maplit::hashmap! {"m1".into() => tp(&[("t", 0)]), "m2".into() => tp(&[("t", 1)])}
);
}
#[test]
fn general_assign_keeps_previous_owner_only_when_subscribed_and_not_overloaded() {
for (_name, ids, member_subs, current_assignment, topic_partitions, expected) in [
(
"subscribed previous owner",
member_ids(&["m1", "m2"]),
subs(&[("m1", &["t"]), ("m2", &["t"])]),
current(&[("m1", &[]), ("m2", &[("t", 0)])]),
topic_parts(&[("t", 2)]),
assigned(&[("m1", &[("t", 1)]), ("m2", &[("t", 0)])]),
),
(
"unsubscribed previous owner",
member_ids(&["m1", "m2"]),
subs(&[("m1", &["t"]), ("m2", &["u"])]),
current(&[("m1", &[]), ("m2", &[("t", 0)])]),
topic_parts(&[("t", 1), ("u", 1)]),
assigned(&[("m1", &[("t", 0)]), ("m2", &[("u", 0)])]),
),
(
"overloaded previous owner",
member_ids(&["m1", "m2", "m3"]),
subs(&[("m1", &["t"]), ("m2", &["t"]), ("m3", &["t"])]),
current(&[("m1", &[("t", 0), ("t", 1)]), ("m2", &[]), ("m3", &[])]),
topic_parts(&[("t", 3)]),
assigned(&[
("m1", &[("t", 0)]),
("m2", &[("t", 1)]),
("m3", &[("t", 2)]),
]),
),
] {
let out = general_assign(&ids, &member_subs, ¤t_assignment, &topic_partitions);
assert2::assert!(out == expected);
}
}
#[test]
fn pick_least_loaded_prefers_lower_load_then_lexicographic_id() {
let subscribed: BTreeSet<String> = member_ids(&["m1", "m2", "m3"]).into_iter().collect();
for (_name, assignment, expected) in [
(
"unique minimum",
current(&[
("m1", &[("t", 0)]),
("m2", &[]),
("m3", &[("t", 1), ("t", 2)]),
]),
"m2",
),
(
"lexicographic tie break",
current(&[
("m1", &[("t", 0)]),
("m2", &[("t", 1)]),
("m3", &[("t", 2), ("t", 3)]),
]),
"m1",
),
] {
assert2::assert!(pick_least_loaded(&subscribed, &assignment) == expected);
}
}
#[test]
fn choose_assignment_owner_uses_least_loaded_and_sticky_previous_owner_bounds() {
let subscribed: BTreeSet<String> = member_ids(&["m1", "m2", "m3"]).into_iter().collect();
let tp = ("t".to_string(), 0);
for (_name, prev_owner, assignment, expected) in [
(
"no previous owner",
HashMap::new(),
current(&[
("m1", &[("a", 0)]),
("m2", &[]),
("m3", &[("b", 0), ("b", 1)]),
]),
"m2",
),
(
"overloaded sticky owner",
maplit::hashmap! {tp.clone() => "m1".to_string()},
current(&[
("m1", &[("a", 0)]),
("m2", &[]),
("m3", &[("b", 0), ("b", 1)]),
]),
"m2",
),
(
"balanced sticky owner",
maplit::hashmap! {tp.clone() => "m1".to_string()},
current(&[("m1", &[]), ("m2", &[]), ("m3", &[("b", 0), ("b", 1)])]),
"m1",
),
(
"unsubscribed previous owner",
maplit::hashmap! {tp.clone() => "mx".to_string()},
current(&[
("m1", &[("a", 0)]),
("m2", &[]),
("m3", &[("b", 0), ("b", 1)]),
]),
"m2",
),
] {
assert2::assert!(
choose_assignment_owner(&tp, &subscribed, &prev_owner, &assignment) == expected
);
}
}
#[test]
fn max_balance_iters_has_safety_slack_after_partition_member_product() {
for (_name, partitions, members, expected) in [
("empty partitions", 0, 5, 16),
("nonempty product", 3, 4, 28),
] {
assert2::assert!(max_balance_iters(partitions, members) == expected);
}
}
#[test]
fn balance_assignment_moves_from_heaviest_to_lightest_with_sticky_candidate_order() {
let ids = member_ids(&["m1", "m2", "m3"]);
let subs = subs(&[("m1", &["t"]), ("m2", &["t"]), ("m3", &["t"])]);
let prev_owner = maplit::hashmap! {("t".to_string(), 1) => "m2".to_string()};
let mut assignment = current(&[
("m1", &[("t", 0), ("t", 1), ("t", 2), ("t", 3)]),
("m2", &[]),
("m3", &[]),
]);
balance_assignment(&ids, &subs, &prev_owner, &mut assignment, 8);
assert2::assert!(
assignment
== current(&[
("m1", &[("t", 3)]),
("m2", &[("t", 0), ("t", 1)]),
("m3", &[("t", 2)]),
])
);
}
#[test]
fn balance_assignment_selects_non_first_heaviest_and_non_sticky_candidate() {
let ids = member_ids(&["m1", "m2", "m3"]);
let subs = subs(&[("m1", &["t"]), ("m2", &["t"]), ("m3", &["t"])]);
let prev_owner = maplit::hashmap! {("t".to_string(), 1) => "m1".to_string()};
let mut assignment = current(&[
("m1", &[]),
("m2", &[("t", 0), ("t", 1), ("t", 2)]),
("m3", &[]),
]);
balance_assignment(&ids, &subs, &prev_owner, &mut assignment, 1);
assert2::assert!(
assignment
== current(&[
("m1", &[("t", 0)]),
("m2", &[("t", 1), ("t", 2)]),
("m3", &[]),
])
);
}
#[test]
fn balance_assignment_keeps_lex_first_heaviest_on_equal_load() {
let ids = member_ids(&["m1", "m2", "m3"]);
let subs = subs(&[("m1", &["t"]), ("m2", &["t"]), ("m3", &["t"])]);
let mut assignment = current(&[
("m1", &[("t", 0), ("t", 1)]),
("m2", &[("t", 2), ("t", 3)]),
("m3", &[]),
]);
balance_assignment(&ids, &subs, &HashMap::new(), &mut assignment, 1);
assert2::assert!(
assignment
== current(&[
("m1", &[("t", 1)]),
("m2", &[("t", 2), ("t", 3)]),
("m3", &[("t", 0)]),
])
);
}
#[test]
fn balance_assignment_moves_a_partition_back_to_its_previous_owner() {
let ids = member_ids(&["m1", "m2"]);
let subs = subs(&[("m1", &["t"]), ("m2", &["t"])]);
let prev_owner = maplit::hashmap! {("t".to_string(), 0) => "m1".to_string()};
let mut assignment = current(&[("m1", &[]), ("m2", &[("t", 0), ("t", 1), ("t", 2)])]);
balance_assignment(&ids, &subs, &prev_owner, &mut assignment, 1);
assert2::assert!(
assignment == current(&[("m1", &[("t", 0)]), ("m2", &[("t", 1), ("t", 2)]),])
);
}
#[test]
fn balance_assignment_stops_when_spread_is_at_most_one() {
let ids = member_ids(&["m1", "m2", "m3"]);
let subs = subs(&[("m1", &["t"]), ("m2", &["t"]), ("m3", &["t"])]);
let mut assignment = current(&[
("m1", &[("t", 0), ("t", 1)]),
("m2", &[("t", 2)]),
("m3", &[("t", 3)]),
]);
balance_assignment(&ids, &subs, &HashMap::new(), &mut assignment, 8);
assert2::assert!(
assignment
== current(&[
("m1", &[("t", 0), ("t", 1)]),
("m2", &[("t", 2)]),
("m3", &[("t", 3)]),
])
);
}
#[test]
fn all_owned_stable_no_op() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 4);
let owned_m1 = tp(&[("t", 0), ("t", 1)]);
let owned_m2 = tp(&[("t", 2), ("t", 3)]);
let a = assign(
&[
("m1".to_string(), topics(&["t"]), owned_m1.clone(), 5),
("m2".to_string(), topics(&["t"]), owned_m2.clone(), 5),
],
&topic_parts,
);
assert2::assert!(
a == assigned(&[("m1", &[("t", 0), ("t", 1)]), ("m2", &[("t", 2), ("t", 3)])])
);
}
#[test]
fn partial_revocation_on_member_join() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 9);
let owned_m1 = tp(&[("t", 0), ("t", 1), ("t", 2), ("t", 3)]);
let owned_m2 = tp(&[("t", 4), ("t", 5), ("t", 6), ("t", 7)]);
let a = assign(
&[
("m1".to_string(), topics(&["t"]), owned_m1.clone(), 5),
("m2".to_string(), topics(&["t"]), owned_m2.clone(), 5),
("m3".to_string(), topics(&["t"]), vec![], 5),
],
&topic_parts,
);
assert2::assert!(
a == assigned(&[
("m1", &[("t", 0), ("t", 1), ("t", 2)]),
("m2", &[("t", 4), ("t", 5), ("t", 6)]),
("m3", &[("t", 8)]),
])
);
}
#[test]
fn phase2_picks_up_revoked() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 9);
let phase1 = assign(
&[
(
"m1".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 1), ("t", 2), ("t", 3)]),
5,
),
(
"m2".to_string(),
topics(&["t"]),
tp(&[("t", 4), ("t", 5), ("t", 6), ("t", 7)]),
5,
),
("m3".to_string(), topics(&["t"]), vec![], 5),
],
&topic_parts,
);
let phase2 = assign(
&[
("m1".to_string(), topics(&["t"]), phase1["m1"].clone(), 6),
("m2".to_string(), topics(&["t"]), phase1["m2"].clone(), 6),
("m3".to_string(), topics(&["t"]), phase1["m3"].clone(), 6),
],
&topic_parts,
);
assert2::assert!(
phase2
== assigned(&[
("m1", &[("t", 0), ("t", 1), ("t", 2)]),
("m2", &[("t", 4), ("t", 5), ("t", 6)]),
("m3", &[("t", 3), ("t", 7), ("t", 8)]),
])
);
}
#[test]
fn member_leaves_partitions_redistributed() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 4);
let a = assign(
&[(
"m2".to_string(),
topics(&["t"]),
tp(&[("t", 2), ("t", 3)]),
5,
)],
&topic_parts,
);
assert2::assert!(a == assigned(&[("m2", &[("t", 0), ("t", 1), ("t", 2), ("t", 3)])]));
}
#[test]
fn multi_topic_asymmetric_subscriptions() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t1".to_string(), 2);
topic_parts.insert("t2".to_string(), 2);
let a = assign(
&[
("m1".to_string(), topics(&["t1", "t2"]), vec![], 0),
("m2".to_string(), topics(&["t1"]), vec![], 0),
],
&topic_parts,
);
for (t, _) in &a["m2"] {
assert2::assert!(t == "t1");
}
assert2::assert!(
a == assigned(&[
("m1", &[("t2", 0), ("t2", 1)]),
("m2", &[("t1", 0), ("t1", 1)]),
])
);
}
#[test]
fn generation_zombie_lower_loses() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 4);
let a = assign(
&[
(
"m1".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 1)]),
5,
),
("m2".to_string(), topics(&["t"]), tp(&[("t", 0)]), 4),
],
&topic_parts,
);
assert2::assert!(
a == assigned(&[("m1", &[("t", 0), ("t", 1)]), ("m2", &[("t", 2), ("t", 3)])])
);
}
#[test]
fn generation_zombie_tie_both_lose() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 4);
let a = assign(
&[
(
"m1".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 1)]),
5,
),
(
"m2".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 2)]),
5,
),
],
&topic_parts,
);
assert2::assert!(total_assigned(&a) == 4);
}
#[test]
fn brand_new_topic() {
let mut topic_parts = HashMap::new();
topic_parts.insert("newt".to_string(), 3);
let a = assign(
&[
("m1".to_string(), topics(&["newt"]), vec![], 5),
("m2".to_string(), topics(&["newt"]), vec![], 5),
],
&topic_parts,
);
assert2::assert!(
a == assigned(&[("m1", &[("newt", 0), ("newt", 2)]), ("m2", &[("newt", 1)])])
);
}
#[test]
fn partition_count_decreased() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 3);
let a = assign(
&[(
"m1".to_string(),
topics(&["t"]),
tp(&[("t", 0), ("t", 5)]),
5,
)],
&topic_parts,
);
assert2::assert!(a == assigned(&[("m1", &[("t", 0), ("t", 1), ("t", 2)])]));
}
#[test]
fn empty_members_returns_empty() {
let topic_parts = HashMap::new();
let a = assign(&[], &topic_parts);
assert2::assert!(a.is_empty());
}
#[test]
fn member_with_no_subscriptions() {
let mut topic_parts = HashMap::new();
topic_parts.insert("t".to_string(), 2);
let a = assign(
&[
("m1".to_string(), topics(&["t"]), vec![], 0),
("m2".to_string(), vec![], vec![], 0),
],
&topic_parts,
);
assert2::assert!((a["m1"].len(), a["m2"].len()) == (2, 0));
}
}