use std::time::Duration;
use ahash::AHashMap as HashMap;
use crate::{Offset, PartitionId};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AutoOffsetReset {
Earliest,
#[default]
Latest,
ByDuration(std::time::Duration),
None,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IsolationLevel {
#[default]
ReadUncommitted,
ReadCommitted,
}
impl IsolationLevel {
#[inline]
pub fn to_i8(self) -> i8 {
match self {
IsolationLevel::ReadUncommitted => 0,
IsolationLevel::ReadCommitted => 1,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GroupProtocol {
#[default]
Classic,
Consumer,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PartitionAssignmentStrategy {
#[default]
Range,
RoundRobin,
CooperativeSticky,
}
impl PartitionAssignmentStrategy {
#[inline]
pub fn protocol_name(&self) -> &'static str {
match self {
Self::Range => "range",
Self::RoundRobin => "roundrobin",
Self::CooperativeSticky => "cooperative-sticky",
}
}
#[inline]
pub fn from_protocol_name(name: &str) -> Option<Self> {
match name {
"range" => Some(Self::Range),
"roundrobin" => Some(Self::RoundRobin),
"cooperative-sticky" => Some(Self::CooperativeSticky),
_ => None,
}
}
#[inline]
pub fn is_cooperative(&self) -> bool {
matches!(self, Self::CooperativeSticky)
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConsumerConfig {
pub(crate) group_id: Option<String>,
pub(crate) auto_offset_reset: AutoOffsetReset,
pub(crate) enable_auto_commit: bool,
pub(crate) auto_commit_interval: Duration,
pub(crate) fetch_min_bytes: i32,
pub(crate) fetch_max_wait: Duration,
pub(crate) fetch_max_bytes: i32,
pub(crate) max_partition_fetch_bytes: i32,
pub(crate) topic_fetch_max_bytes: HashMap<String, i32>,
pub(crate) max_poll_records: i32,
pub(crate) max_buffered_records: i32,
pub(crate) max_poll_interval: Duration,
pub(crate) session_timeout: Duration,
pub(crate) heartbeat_interval: Duration,
pub(crate) isolation_level: IsolationLevel,
pub(crate) partition_assignment_strategies: Vec<PartitionAssignmentStrategy>,
pub(crate) group_protocol: GroupProtocol,
pub(crate) group_remote_assignor: Option<String>,
pub(crate) group_instance_id: Option<String>,
pub(crate) client_rack: Option<String>,
pub(crate) max_decompressed_size: usize,
pub(crate) initial_offsets: HashMap<(String, PartitionId), Offset>,
pub(crate) lag_staleness_threshold: Duration,
pub(crate) idle_poll_backoff: Duration,
pub(crate) metrics_push: bool,
}
impl Default for ConsumerConfig {
fn default() -> Self {
Self {
group_id: None,
auto_offset_reset: AutoOffsetReset::Latest,
enable_auto_commit: true,
auto_commit_interval: Duration::from_secs(5),
fetch_min_bytes: 1,
fetch_max_wait: Duration::from_millis(500),
fetch_max_bytes: 52428800, max_partition_fetch_bytes: 1048576, topic_fetch_max_bytes: HashMap::new(),
max_poll_records: 500,
max_buffered_records: 500,
max_poll_interval: Duration::from_secs(300),
session_timeout: Duration::from_secs(45),
heartbeat_interval: Duration::from_secs(3),
isolation_level: IsolationLevel::ReadUncommitted,
partition_assignment_strategies: vec![
PartitionAssignmentStrategy::Range,
PartitionAssignmentStrategy::CooperativeSticky,
],
group_protocol: GroupProtocol::Classic,
group_remote_assignor: None,
group_instance_id: None,
client_rack: None,
max_decompressed_size: crate::protocol::RecordBatch::MAX_DECOMPRESSED_SIZE,
initial_offsets: HashMap::new(),
lag_staleness_threshold: Duration::from_secs(60),
idle_poll_backoff: Duration::from_millis(10),
metrics_push: true,
}
}
}
pub(crate) fn validate(config: &ConsumerConfig, request_timeout: Duration) -> crate::Result<()> {
if config.group_id.as_deref() == Some("") {
return Err(crate::error::KrafkaError::config(
"group_id must not be an empty string; use Kafka::consumer_without_group for a \
consumer without a group",
));
}
if config.heartbeat_interval >= config.session_timeout {
return Err(crate::error::KrafkaError::config(format!(
"heartbeat_interval ({:?}) must be less than session_timeout ({:?})",
config.heartbeat_interval, config.session_timeout,
)));
}
if request_timeout <= config.session_timeout {
tracing::debug!(
request_timeout = ?request_timeout,
session_timeout = ?config.session_timeout,
"request_timeout does not exceed session_timeout; long-parked coordinator \
requests may be aborted client-side"
);
}
if config.max_buffered_records < 0 {
return Err(crate::error::KrafkaError::config(format!(
"max_buffered_records ({}) must be >= 0",
config.max_buffered_records,
)));
}
if config.fetch_min_bytes > config.fetch_max_bytes {
return Err(crate::error::KrafkaError::config(format!(
"fetch_min_bytes ({}) must be <= fetch_max_bytes ({})",
config.fetch_min_bytes, config.fetch_max_bytes,
)));
}
if config.max_poll_records == 0 || config.max_poll_records < -1 {
return Err(crate::error::KrafkaError::config(format!(
"max_poll_records ({}) must be -1 (unlimited) or a positive integer",
config.max_poll_records,
)));
}
if config.partition_assignment_strategies.is_empty() {
return Err(crate::error::KrafkaError::config(
"partition_assignment_strategies must not be empty",
));
}
if let Some(assignor) = &config.group_remote_assignor {
if assignor.is_empty() {
return Err(crate::error::KrafkaError::config(
"group_remote_assignor must not be an empty string",
));
}
if config.group_protocol != GroupProtocol::Consumer {
return Err(crate::error::KrafkaError::config(format!(
"group_remote_assignor ({assignor:?}) applies only to \
GroupProtocol::Consumer (KIP-848); the classic protocol assigns \
partitions client-side with partition_assignment_strategies",
)));
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::Kafka;
#[test]
fn test_isolation_level_to_i8() {
assert_eq!(IsolationLevel::ReadUncommitted.to_i8(), 0);
assert_eq!(IsolationLevel::ReadCommitted.to_i8(), 1);
}
#[test]
fn test_config_default() {
let config = ConsumerConfig::default();
assert_eq!(config.auto_offset_reset, AutoOffsetReset::Latest);
assert!(config.enable_auto_commit);
assert_eq!(config.fetch_min_bytes, 1);
assert_eq!(
config.partition_assignment_strategies[0],
PartitionAssignmentStrategy::Range
);
assert_eq!(config.group_protocol, GroupProtocol::Classic);
assert!(config.group_instance_id.is_none());
assert!(config.client_rack.is_none());
}
#[tokio::test]
async fn test_config_builder() {
let config = Kafka::detached()
.consumer("test-group")
.auto_offset_reset(AutoOffsetReset::Earliest)
.enable_auto_commit(false)
.isolation_level(IsolationLevel::ReadCommitted)
.partition_assignment_strategy(PartitionAssignmentStrategy::CooperativeSticky)
.fetch_min_bytes(1024)
.fetch_max_bytes(10 * 1024 * 1024)
.group_instance_id("instance-1")
.client_rack("us-east-1a")
.group_protocol(GroupProtocol::Consumer)
.build_config()
.unwrap();
assert_eq!(config.group_id, Some("test-group".to_string()));
assert_eq!(config.auto_offset_reset, AutoOffsetReset::Earliest);
assert!(!config.enable_auto_commit);
assert_eq!(config.isolation_level, IsolationLevel::ReadCommitted);
assert_eq!(
config.partition_assignment_strategies,
vec![PartitionAssignmentStrategy::CooperativeSticky]
);
assert_eq!(config.fetch_min_bytes, 1024);
assert_eq!(config.fetch_max_bytes, 10 * 1024 * 1024);
assert_eq!(config.group_instance_id.as_deref(), Some("instance-1"));
assert_eq!(config.client_rack.as_deref(), Some("us-east-1a"));
assert_eq!(config.group_protocol, GroupProtocol::Consumer);
}
#[test]
fn test_partition_assignment_strategy_protocol_names() {
assert_eq!(PartitionAssignmentStrategy::Range.protocol_name(), "range");
assert_eq!(
PartitionAssignmentStrategy::RoundRobin.protocol_name(),
"roundrobin"
);
assert_eq!(
PartitionAssignmentStrategy::CooperativeSticky.protocol_name(),
"cooperative-sticky"
);
}
#[tokio::test]
async fn buffer_and_poll_limits_are_validated() {
let consumer = || Kafka::detached().consumer("g");
assert_eq!(
consumer()
.max_buffered_records(0)
.build_config()
.unwrap()
.max_buffered_records,
0
);
assert_eq!(
consumer()
.max_poll_records(-1)
.build_config()
.unwrap()
.max_poll_records,
-1,
"-1 means unlimited"
);
for bad in [0, -2] {
let err = consumer().max_poll_records(bad).build_config().unwrap_err();
assert!(err.to_string().contains("max_poll_records"), "{err}");
}
let err = consumer()
.max_buffered_records(-1)
.build_config()
.unwrap_err();
assert!(err.to_string().contains("max_buffered_records"), "{err}");
}
}