use std::time::Duration;
use kacrab_protocol::{
KafkaString, KafkaUuid,
generated::{
ApiKey, ConsumerGroupHeartbeatRequestData, ConsumerGroupHeartbeatResponseData, ErrorCode,
consumer_group_heartbeat_request::TopicPartitions as OwnedTopicPartitions,
},
version::client_api_info,
};
use super::error::{ConsumerError, Result};
use crate::wire::WireClient;
pub(super) const EPOCH_JOINING: i32 = 0;
pub(super) const EPOCH_LEAVING: i32 = -1;
#[derive(Debug, Clone)]
pub(super) struct ModernGroupState {
pub member_id: String,
pub member_epoch: i32,
pub heartbeat_interval: Duration,
}
impl ModernGroupState {
pub(super) fn new(default_interval: Duration) -> Result<Self> {
let member_id = KafkaUuid::random()
.map_err(|_error| {
ConsumerError::InvalidState("could not generate a consumer member id")
})?
.to_string();
Ok(Self {
member_id,
member_epoch: EPOCH_JOINING,
heartbeat_interval: default_interval,
})
}
}
#[derive(Debug, Clone)]
pub(super) struct AssignedTopic {
pub topic_id: KafkaUuid,
pub partitions: Vec<i32>,
}
pub(super) struct HeartbeatRequest<'a> {
pub group_id: &'a str,
pub member_id: &'a str,
pub member_epoch: i32,
pub instance_id: Option<&'a str>,
pub rack_id: Option<&'a str>,
pub rebalance_timeout_ms: i32,
pub subscribed_topics: &'a [String],
pub server_assignor: Option<&'a str>,
pub owned: &'a [AssignedTopic],
}
#[derive(Debug)]
pub(super) struct HeartbeatOutcome {
pub member_id: Option<String>,
pub member_epoch: i32,
pub heartbeat_interval: Duration,
pub assignment: Option<Vec<AssignedTopic>>,
pub error: ErrorCode,
}
pub(super) async fn heartbeat(
wire: &WireClient,
coordinator_id: i32,
request: &HeartbeatRequest<'_>,
) -> Result<HeartbeatOutcome> {
let owned = request
.owned
.iter()
.map(|topic| OwnedTopicPartitions {
topic_id: topic.topic_id,
partitions: topic.partitions.clone(),
_unknown_tagged_fields: Vec::new(),
})
.collect();
let wire_request = ConsumerGroupHeartbeatRequestData {
group_id: request.group_id.to_owned().into(),
member_id: request.member_id.to_owned().into(),
member_epoch: request.member_epoch,
instance_id: request
.instance_id
.filter(|id| !id.is_empty())
.map(|id| id.to_owned().into()),
rack_id: request
.rack_id
.filter(|rack| !rack.is_empty())
.map(|rack| rack.to_owned().into()),
rebalance_timeout_ms: request.rebalance_timeout_ms,
subscribed_topic_names: Some(
request
.subscribed_topics
.iter()
.map(|topic| KafkaString::from(topic.clone()))
.collect(),
),
subscribed_topic_regex: None,
server_assignor: request
.server_assignor
.map(|assignor| assignor.to_owned().into()),
topic_partitions: Some(owned),
_unknown_tagged_fields: Vec::new(),
};
let version = client_api_info(ApiKey::ConsumerGroupHeartbeat).max_version;
let response: ConsumerGroupHeartbeatResponseData = wire
.send_to_broker(
coordinator_id,
ApiKey::ConsumerGroupHeartbeat,
version,
&wire_request,
)
.await?;
let error = ErrorCode::from(response.error_code);
let assignment = response.assignment.map(|assignment| {
assignment
.topic_partitions
.into_iter()
.map(|topic| AssignedTopic {
topic_id: topic.topic_id,
partitions: topic.partitions,
})
.collect()
});
let interval = u64::try_from(response.heartbeat_interval_ms.max(0)).unwrap_or(0);
Ok(HeartbeatOutcome {
member_id: response.member_id.map(|id| id.to_string()),
member_epoch: response.member_epoch,
heartbeat_interval: Duration::from_millis(interval),
assignment,
error,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_state_generates_a_member_id_and_joins_at_epoch_zero() {
let state = ModernGroupState::new(Duration::from_secs(3)).expect("member id");
assert!(!state.member_id.is_empty());
assert_eq!(state.member_epoch, EPOCH_JOINING);
assert_eq!(state.heartbeat_interval, Duration::from_secs(3));
let other = ModernGroupState::new(Duration::from_secs(3)).expect("member id");
assert_ne!(state.member_id, other.member_id);
}
}