kafrust 0.2.28

A pure Rust Kafka client with no librdkafka or C client binding dependency.
Documentation
mod common;

use std::io::{self, Write};
use std::time::Duration;

use kafrust::{
    BrokerErrorKind, Client, ClientConfig, ConsumerGroupConfig, ConsumerGroupProtocol, Error,
};

const COORDINATOR_LOOKUP_MAX_RETRIES: u32 = 120;
const COORDINATOR_LOOKUP_BACKOFF: Duration = Duration::from_millis(250);

#[tokio::main]
async fn main() -> kafrust::Result<()> {
    let bootstrap_servers = common::bootstrap_servers_from_env();
    let group_id =
        std::env::var("KAFRUST_GROUP_ID").unwrap_or_else(|_| "kafrust-group-failover".to_owned());
    let topic = std::env::var("KAFRUST_TOPIC").unwrap_or_else(|_| "kafrust-smoke".to_owned());
    let protocol = group_protocol_from_env()?;
    let pause = pause_from_env()?;

    let mut bootstrap = common::apply_security(
        ClientConfig::new(bootstrap_servers.clone())
            .client_id("kafrust-group-failover-coordinator"),
    )?
    .connect()
    .await?;
    let coordinator = find_group_coordinator_with_retry(&mut bootstrap, &group_id).await?;
    let use_partition_queue = std::env::var("KAFRUST_PARTITION_QUEUE")
        .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));

    let config = common::apply_security(
        ConsumerGroupConfig::new(bootstrap_servers, group_id)
            .group_protocol(protocol)
            .client_id("kafrust-group-failover-consumer")
            .session_timeout_ms(6_000)
            .rebalance_timeout_ms(10_000)
            .max_wait_ms(100)
            .max_retries(100)
            .subscribe(topic),
    )?;
    let mut group = config.join().await?;
    let mut partition_queue = if use_partition_queue {
        let assignment = group
            .assignments()
            .first()
            .ok_or(Error::Unsupported("consumer group has no assignment"))?;
        let assigned_topic = assignment.topic().to_owned();
        let assigned_partition = assignment.partition();
        Some(group.split_partition_queue(assigned_topic, assigned_partition)?)
    } else {
        None
    };
    println!(
        "consumer group failover joined member {} generation {} coordinator node {}",
        group.member_id(),
        group.generation_id(),
        coordinator.node_id
    );

    let before = group.poll().await?;
    let before_queued = drain_partition_queue(&mut partition_queue);
    println!(
        "consumer group failover before polled count={} queued count={}",
        before.len(),
        before_queued
    );
    flush_stdout()?;

    if !pause.is_zero() {
        println!("consumer group failover pause {}ms", pause.as_millis());
        flush_stdout()?;
        tokio::time::sleep(pause).await;
    }

    let after = group.poll().await?;
    let after_queued = drain_partition_queue(&mut partition_queue);
    println!(
        "consumer group failover after polled count={} queued count={}",
        after.len(),
        after_queued
    );
    group.leave().await?;
    println!("consumer group failover left group");
    Ok(())
}

fn drain_partition_queue(queue: &mut Option<kafrust::ConsumerPartitionQueue>) -> usize {
    queue
        .as_mut()
        .map(|queue| {
            let mut count = 0;
            while queue.try_recv().is_some() {
                count += 1;
            }
            count
        })
        .unwrap_or(0)
}

fn group_protocol_from_env() -> kafrust::Result<ConsumerGroupProtocol> {
    let value = std::env::var("KAFRUST_GROUP_PROTOCOL").unwrap_or_else(|_| "classic".to_owned());
    group_protocol_from_value(&value)
}

fn group_protocol_from_value(value: &str) -> kafrust::Result<ConsumerGroupProtocol> {
    match value.trim().to_ascii_lowercase().as_str() {
        "classic" => Ok(ConsumerGroupProtocol::Classic),
        "consumer" | "kip-848" => Ok(ConsumerGroupProtocol::Consumer),
        _ => Err(Error::Unsupported(
            "KAFRUST_GROUP_PROTOCOL must be classic or consumer",
        )),
    }
}

fn pause_from_env() -> kafrust::Result<Duration> {
    std::env::var("KAFRUST_FAILOVER_PAUSE_MS")
        .map(|value| parse_pause(&value))
        .unwrap_or(Ok(Duration::ZERO))
}

fn parse_pause(value: &str) -> kafrust::Result<Duration> {
    value
        .trim()
        .parse::<u64>()
        .map(Duration::from_millis)
        .map_err(|_| Error::Unsupported("KAFRUST_FAILOVER_PAUSE_MS must be milliseconds"))
}

fn flush_stdout() -> kafrust::Result<()> {
    io::stdout().flush().map_err(Error::from)
}

async fn find_group_coordinator_with_retry(
    bootstrap: &mut Client,
    group_id: &str,
) -> kafrust::Result<kafrust::protocol::api::find_coordinator::FindCoordinatorResponseV1> {
    let mut retry_attempt = 0;
    loop {
        let response = bootstrap.find_group_coordinator(group_id).await?;
        if response.error_code == 0 {
            return Ok(response);
        }

        let retryable = matches!(
            BrokerErrorKind::from_code(response.error_code),
            BrokerErrorKind::CoordinatorLoadInProgress
                | BrokerErrorKind::CoordinatorNotAvailable
                | BrokerErrorKind::NotCoordinator
        );
        if !retryable || retry_attempt >= COORDINATOR_LOOKUP_MAX_RETRIES {
            return Err(Error::Broker {
                code: response.error_code,
                context: "find group failover coordinator".to_owned(),
            });
        }

        retry_attempt += 1;
        eprintln!(
            "waiting for group coordinator retry {} after broker error {}",
            retry_attempt, response.error_code
        );
        tokio::time::sleep(COORDINATOR_LOOKUP_BACKOFF).await;
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use kafrust::ConsumerGroupProtocol;

    use super::{group_protocol_from_value, parse_pause};

    #[test]
    fn parses_group_protocol() {
        assert_eq!(
            group_protocol_from_value(" KIP-848 ").expect("protocol should parse"),
            ConsumerGroupProtocol::Consumer
        );
        assert_eq!(
            group_protocol_from_value("classic").expect("protocol should parse"),
            ConsumerGroupProtocol::Classic
        );
    }

    #[test]
    fn rejects_unknown_group_protocol() {
        assert!(group_protocol_from_value("unknown").is_err());
    }

    #[test]
    fn parses_pause() {
        assert_eq!(
            parse_pause(" 1500 ").expect("pause should parse"),
            Duration::from_millis(1500)
        );
    }

    #[test]
    fn rejects_invalid_pause() {
        assert!(parse_pause("later").is_err());
    }
}