krabka-client-consumer 0.7.1

Subscribe-style consumer client for Apache Kafka in Rust
Documentation
//! Range assignor, Kafka's classic default.
//!
//! From a set of members and a per-topic partition count, it hands each
//! member a contiguous range of partitions per topic. The trailing
//! members get one partition less when the partition count does not
//! divide evenly.

use std::collections::HashMap;

use super::MemberKey;

/// Returns `member_id → Vec<(topic, partition)>` assignments. Members sort as
/// Kafka's `AbstractPartitionAssignor.MemberInfo` does, so a static member
/// keeps its range across restarts.
#[must_use]
#[tracing::instrument(
    name = "consumer.assignor.range",
    level = "info",
    skip_all,
    fields(members = members.len(), topics = topic_partitions.len())
)]
pub fn assign_members(
    mut members: Vec<(MemberKey, Vec<String>)>,
    topic_partitions: &HashMap<String, i32>,
) -> HashMap<String, Vec<(String, i32)>> {
    members.sort_by(|(a, _), (b, _)| a.cmp(b));
    let mut out: HashMap<String, Vec<(String, i32)>> = members
        .iter()
        .map(|(m, _)| (m.member_id.clone(), Vec::new()))
        .collect();

    // For determinism, iterate topics in sorted order.
    let mut topics: Vec<(&String, &i32)> = topic_partitions.iter().collect();
    topics.sort_by_key(|(a, _)| *a);

    for (topic, &partition_count) in topics {
        let subscribed: Vec<&String> = members
            .iter()
            .filter(|(_, subs)| subs.iter().any(|t| t == topic))
            .map(|(m, _)| &m.member_id)
            .collect();
        if subscribed.is_empty() || partition_count <= 0 {
            continue;
        }
        let n = i32::try_from(subscribed.len()).unwrap_or(i32::MAX);
        let per = partition_count / n;
        let extras = partition_count % n;
        let mut next: i32 = 0;
        for (i, m) in subscribed.iter().enumerate() {
            let extra = i32::from(i32::try_from(i).unwrap_or(i32::MAX) < extras);
            let take = per + extra;
            for p in next..(next + take) {
                if let Some(slot) = out.get_mut(*m) {
                    slot.push((topic.clone(), p));
                }
            }
            next += take;
        }
    }
    out
}

#[cfg(test)]
mod tests {

    use super::*;

    fn assign(
        members: Vec<(String, Vec<String>)>,
        topic_partitions: &HashMap<String, i32>,
    ) -> HashMap<String, Vec<(String, i32)>> {
        assign_members(
            members
                .into_iter()
                .map(|(member_id, topics)| {
                    (
                        MemberKey {
                            member_id,
                            group_instance_id: None,
                        },
                        topics,
                    )
                })
                .collect(),
            topic_partitions,
        )
    }

    #[test]
    fn one_member_takes_everything() {
        let mut tp = HashMap::new();
        tp.insert("t".into(), 4);
        let a = assign(vec![("m1".into(), vec!["t".into()])], &tp);
        assert2::assert!(
            a["m1"]
                == vec![
                    ("t".into(), 0),
                    ("t".into(), 1),
                    ("t".into(), 2),
                    ("t".into(), 3),
                ]
        );
    }

    #[test]
    fn two_members_split_evenly() {
        let mut tp = HashMap::new();
        tp.insert("t".into(), 4);
        let a = assign(
            vec![
                ("m1".into(), vec!["t".into()]),
                ("m2".into(), vec!["t".into()]),
            ],
            &tp,
        );
        assert2::assert!(
            a == maplit::hashmap! {"m1".into() => vec![("t".into(), 0), ("t".into(), 1)], "m2".into() => vec![("t".into(), 2), ("t".into(), 3)]}
        );
    }

    #[test]
    fn extras_go_to_lower_member_ids() {
        let mut tp = HashMap::new();
        tp.insert("t".into(), 5);
        let a = assign(
            vec![
                ("m1".into(), vec!["t".into()]),
                ("m2".into(), vec!["t".into()]),
            ],
            &tp,
        );
        assert2::assert!((a["m1"].len(), a["m2"].len()) == (3, 2));
    }

    #[test]
    fn member_with_no_subscriptions_gets_empty() {
        let mut tp = HashMap::new();
        tp.insert("t".into(), 2);
        let a = assign(
            vec![("m1".into(), vec!["t".into()]), ("m2".into(), vec![])],
            &tp,
        );
        assert2::assert!((a["m1"].len(), a["m2"].len()) == (2, 0));
    }

    #[test]
    fn non_positive_partition_count_assigns_nothing() {
        let mut tp = HashMap::new();
        tp.insert("t".into(), 0);
        let a = assign(vec![("m1".into(), vec!["t".into()])], &tp);
        assert2::assert!(a["m1"].is_empty());
    }

    #[test]
    fn topic_with_no_subscribers_assigns_nothing() {
        let mut tp = HashMap::new();
        tp.insert("t".into(), 2);
        let a = assign(vec![("m1".into(), vec!["other".into()])], &tp);
        assert2::assert!(a["m1"].is_empty());
    }
}