use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use rdkafka::client::ClientContext;
use rdkafka::consumer::{BaseConsumer, CommitMode, Consumer, ConsumerContext, Rebalance};
use rdkafka::error::KafkaResult;
use rdkafka::{Offset, TopicPartitionList};
pub(crate) struct RebalanceState {
inner: Mutex<RebalanceStateInner>,
assign_rounds: std::sync::atomic::AtomicU64,
}
#[derive(Default)]
struct RebalanceStateInner {
revoked: HashSet<(String, i32)>,
committable: HashMap<(String, i32), i64>,
}
impl RebalanceState {
pub(crate) fn new() -> Self {
Self {
inner: Mutex::new(RebalanceStateInner::default()),
assign_rounds: std::sync::atomic::AtomicU64::new(0),
}
}
pub(crate) fn assign_rounds(&self) -> u64 {
self.assign_rounds
.load(std::sync::atomic::Ordering::Relaxed)
}
fn record_assign_round(&self) {
self.assign_rounds
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn lock(&self) -> std::sync::MutexGuard<'_, RebalanceStateInner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn record_committable(&self, topic: &str, partition: i32, next_offset: i64) {
self.lock()
.committable
.insert((topic.to_string(), partition), next_offset);
}
fn confirm_committed(&self, topic: &str, partition: i32, confirmed_offset: i64) {
let mut inner = self.lock();
let key = (topic.to_string(), partition);
if inner
.committable
.get(&key)
.is_some_and(|stored| *stored <= confirmed_offset)
{
inner.committable.remove(&key);
}
}
fn take_committable(&self, partitions: &[(String, i32)]) -> Option<TopicPartitionList> {
let mut taken = Vec::new();
{
let mut inner = self.lock();
for key in partitions {
if let Some(offset) = inner.committable.remove(key) {
taken.push((key.clone(), offset));
}
}
}
if taken.is_empty() {
return None;
}
let mut tpl = TopicPartitionList::new();
for ((topic, partition), offset) in taken {
if let Err(e) = tpl.add_partition_offset(&topic, partition, Offset::Offset(offset)) {
tracing::error!(
topic = %topic,
partition,
offset,
error = %e,
"Failed to stage a revoked partition's offset for commit"
);
}
}
Some(tpl)
}
fn mark_revoked(&self, partitions: &[(String, i32)]) {
self.lock().revoked.extend(partitions.iter().cloned());
}
fn mark_assigned(&self, partitions: &[(String, i32)]) {
let mut inner = self.lock();
for key in partitions {
inner.revoked.remove(key);
inner.committable.remove(key);
}
}
pub(crate) fn is_revoked(&self, topic: &str, partition: i32) -> bool {
self.lock()
.revoked
.iter()
.any(|(t, p)| *p == partition && t.as_str() == topic)
}
}
pub(crate) struct KafkaConsumerContext {
rebalance: std::sync::Arc<RebalanceState>,
}
impl KafkaConsumerContext {
pub(crate) fn new(rebalance: std::sync::Arc<RebalanceState>) -> Self {
Self { rebalance }
}
}
impl ClientContext for KafkaConsumerContext {}
impl ConsumerContext for KafkaConsumerContext {
fn pre_rebalance(&self, base_consumer: &BaseConsumer<Self>, rebalance: &Rebalance<'_>) {
match rebalance {
Rebalance::Revoke(tpl) => {
let partitions = partition_keys(tpl);
if let Some(commit_tpl) = self.rebalance.take_committable(&partitions) {
match base_consumer.commit(&commit_tpl, CommitMode::Sync) {
Ok(()) => tracing::info!(
partitions = partitions.len(),
"Flushed in-flight offset commits before partition revocation"
),
Err(e) => {
crate::metrics::record_error("kafka_commit");
tracing::error!(
error = %e,
"Failed to flush offset commits before partition revocation"
);
}
}
}
self.rebalance.mark_revoked(&partitions);
tracing::info!(?partitions, "Kafka partitions revoked");
}
Rebalance::Assign(_) => {}
Rebalance::Error(e) => {
crate::metrics::record_error("kafka_rebalance");
tracing::error!(error = %e, "Kafka rebalance error");
}
}
}
fn post_rebalance(&self, _base_consumer: &BaseConsumer<Self>, rebalance: &Rebalance<'_>) {
if let Rebalance::Assign(tpl) = rebalance {
let partitions = partition_keys(tpl);
self.rebalance.mark_assigned(&partitions);
self.rebalance.record_assign_round();
tracing::info!(?partitions, "Kafka partitions assigned");
}
}
fn commit_callback(&self, result: KafkaResult<()>, offsets: &TopicPartitionList) {
match result {
Ok(()) => {
for elem in offsets.elements() {
if let Offset::Offset(offset) = elem.offset() {
self.rebalance
.confirm_committed(elem.topic(), elem.partition(), offset);
}
}
}
Err(e) => {
crate::metrics::record_error("kafka_commit");
tracing::warn!(error = %e, "Kafka offset commit failed; affected messages may be redelivered");
}
}
}
}
fn partition_keys(tpl: &TopicPartitionList) -> Vec<(String, i32)> {
tpl.elements()
.iter()
.map(|e| (e.topic().to_string(), e.partition()))
.collect()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use rdkafka::ClientConfig;
use super::*;
fn keys(pairs: &[(&str, i32)]) -> Vec<(String, i32)> {
pairs.iter().map(|(t, p)| (t.to_string(), *p)).collect()
}
#[test]
fn take_committable_drains_only_the_requested_partitions() {
let state = RebalanceState::new();
state.record_committable("orders", 0, 10);
state.record_committable("orders", 1, 20);
let tpl = state
.take_committable(&keys(&[("orders", 0)]))
.expect("partition 0 has an unconfirmed commit");
let elems = tpl.elements();
assert_eq!(elems.len(), 1);
assert_eq!(elems[0].topic(), "orders");
assert_eq!(elems[0].partition(), 0);
assert_eq!(elems[0].offset(), Offset::Offset(10));
assert!(state.take_committable(&keys(&[("orders", 0)])).is_none());
assert!(state.take_committable(&keys(&[("orders", 1)])).is_some());
}
#[test]
fn confirmed_commits_no_longer_need_a_revocation_flush() {
let state = RebalanceState::new();
state.record_committable("orders", 0, 10);
state.confirm_committed("orders", 0, 9);
assert!(state.take_committable(&keys(&[("orders", 0)])).is_some());
state.record_committable("orders", 0, 10);
state.confirm_committed("orders", 0, 10);
assert!(
state.take_committable(&keys(&[("orders", 0)])).is_none(),
"a confirmed commit must not be re-flushed on revocation"
);
}
#[test]
fn revocation_tracking_clears_on_reassignment() {
let state = RebalanceState::new();
assert!(!state.is_revoked("orders", 0));
state.mark_revoked(&keys(&[("orders", 0), ("orders", 1)]));
assert!(state.is_revoked("orders", 0));
assert!(state.is_revoked("orders", 1));
assert!(!state.is_revoked("orders", 2));
state.mark_assigned(&keys(&[("orders", 0)]));
assert!(!state.is_revoked("orders", 0), "re-assigned partition");
assert!(state.is_revoked("orders", 1), "still someone else's");
}
#[test]
fn rebalance_hooks_update_the_shared_state() {
let state = Arc::new(RebalanceState::new());
let consumer: BaseConsumer<KafkaConsumerContext> = ClientConfig::new()
.set("bootstrap.servers", "127.0.0.1:1")
.set("group.id", "context-hook-test")
.create_with_context(KafkaConsumerContext::new(state.clone()))
.expect("client creation is local; no broker contact");
let mut tpl = TopicPartitionList::new();
tpl.add_partition("orders", 0);
consumer
.context()
.pre_rebalance(&consumer, &Rebalance::Revoke(&tpl));
assert!(state.is_revoked("orders", 0));
assert_eq!(state.assign_rounds(), 0);
consumer
.context()
.post_rebalance(&consumer, &Rebalance::Assign(&tpl));
assert!(!state.is_revoked("orders", 0));
assert_eq!(state.assign_rounds(), 1);
}
}