Skip to main content

laminar_connectors/kafka/rebalance/
mod.rs

1//! Kafka consumer group rebalance state tracking.
2//!
3//! [`RebalanceState`] tracks which topic-partitions are currently
4//! assigned to this consumer.
5//!
6//! [`LaminarConsumerContext`] is an rdkafka `ConsumerContext` that tracks
7//! assignment changes and broker offset-commit outcomes.
8
9use std::collections::HashSet;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12
13use prometheus::IntCounter;
14use rdkafka::consumer::{Consumer, ConsumerContext};
15use rdkafka::ClientContext;
16use tracing::{info, warn};
17
18/// Tracks partition assignments across consumer group rebalances.
19#[derive(Debug, Clone, Default)]
20pub struct RebalanceState {
21    /// Currently assigned (topic, partition) pairs.
22    assigned: Arc<HashSet<(String, i32)>>,
23}
24
25impl RebalanceState {
26    /// Starts with no partitions assigned.
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Handles a partition assignment event.
33    ///
34    /// Additive: inserts new partitions without clearing existing ones.
35    /// This is correct for both eager and cooperative rebalance protocols:
36    /// - Eager: the preceding `on_revoke(all)` already clears the set.
37    /// - Cooperative: `Assign` only contains newly assigned partitions,
38    ///   so clearing would lose existing assignments.
39    pub fn on_assign(&mut self, partitions: &[(String, i32)]) {
40        let assigned = Arc::make_mut(&mut self.assigned);
41        for (topic, partition) in partitions {
42            assigned.insert((topic.clone(), *partition));
43        }
44    }
45
46    /// Handles a partition revocation event.
47    ///
48    /// Removes the specified partitions from the assignment set.
49    pub fn on_revoke(&mut self, partitions: &[(String, i32)]) {
50        let assigned = Arc::make_mut(&mut self.assigned);
51        for (topic, partition) in partitions {
52            assigned.remove(&(topic.clone(), *partition));
53        }
54    }
55
56    /// Returns the set of currently assigned partitions.
57    #[must_use]
58    pub fn assigned_partitions(&self) -> &HashSet<(String, i32)> {
59        &self.assigned
60    }
61
62    /// Immutable assignment snapshot for checkpoint and revoke processing.
63    #[must_use]
64    pub fn assignment_snapshot(&self) -> Arc<HashSet<(String, i32)>> {
65        Arc::clone(&self.assigned)
66    }
67}
68
69/// rdkafka consumer context that tracks partition assignment changes.
70///
71/// The callback does not request an asynchronous engine checkpoint: Kafka may revoke ownership as
72/// soon as this callback returns, so a later checkpoint cannot certify the revoked cut. Guaranteed
73/// delivery uses engine-owned manual assignment instead; this state is for dynamic best-effort
74/// subscriptions and observability.
75///
76/// Rebalance callbacks run on rdkafka's background thread, so all shared
77/// state uses `Arc` + atomic types for thread safety.
78pub struct LaminarConsumerContext {
79    /// Shared rebalance state updated on Assign/Revoke events.
80    rebalance_state: Arc<Mutex<RebalanceState>>,
81    /// Shared rebalance event counter for source-level metrics.
82    rebalance_metric: Arc<AtomicU64>,
83    /// Monotonically increasing generation bumped on each Revoke event.
84    ///
85    /// Allows lock-free detection of revoke events from the hot path
86    /// (`poll_batch`) — the source compares its cached generation against
87    /// this value using `Relaxed` ordering, and only locks the mutex when
88    /// a change is detected.
89    revoke_generation: Arc<AtomicU64>,
90    /// Bumped on each Assign; the reader task seeks the newly-assigned partitions
91    /// from the poll loop (see the `KafkaSource` reader loop).
92    assign_generation: Arc<AtomicU64>,
93    /// Counter bumped on every broker-confirmed advisory progress commit.
94    /// Engine recovery uses checkpoint state, so asynchronous broker commit
95    /// failures affect monitoring lag rather than the recovery guarantee.
96    commits_counter: IntCounter,
97    /// Counter bumped when the broker rejects a commit.
98    commit_failures_counter: IntCounter,
99}
100
101impl LaminarConsumerContext {
102    /// Wires partition tracking, commit outcomes, and rebalance metrics.
103    #[must_use]
104    pub fn new(
105        rebalance_state: Arc<Mutex<RebalanceState>>,
106        rebalance_metric: Arc<AtomicU64>,
107        revoke_generation: Arc<AtomicU64>,
108        assign_generation: Arc<AtomicU64>,
109        commits_counter: IntCounter,
110        commit_failures_counter: IntCounter,
111    ) -> Self {
112        Self {
113            rebalance_state,
114            rebalance_metric,
115            revoke_generation,
116            assign_generation,
117            commits_counter,
118            commit_failures_counter,
119        }
120    }
121
122    /// Locks the rebalance state, recovering from poison.
123    fn lock_rebalance_state(&self) -> std::sync::MutexGuard<'_, RebalanceState> {
124        self.rebalance_state.lock().unwrap_or_else(|poisoned| {
125            warn!("rebalance_state mutex poisoned, recovering");
126            poisoned.into_inner()
127        })
128    }
129}
130
131impl ClientContext for LaminarConsumerContext {}
132
133impl ConsumerContext for LaminarConsumerContext {
134    fn pre_rebalance(
135        &self,
136        _base_consumer: &rdkafka::consumer::BaseConsumer<Self>,
137        rebalance: &rdkafka::consumer::Rebalance<'_>,
138    ) {
139        use rdkafka::consumer::Rebalance;
140
141        match rebalance {
142            Rebalance::Revoke(tpl) => {
143                let count = tpl.count();
144                info!(
145                    partitions_revoked = count,
146                    "kafka rebalance: partitions being revoked"
147                );
148                // Update shared rebalance state.
149                let partitions: Vec<(String, i32)> = tpl
150                    .elements()
151                    .iter()
152                    .map(|e| (e.topic().to_string(), e.partition()))
153                    .collect();
154                self.lock_rebalance_state().on_revoke(&partitions);
155                self.revoke_generation
156                    .fetch_add(1, std::sync::atomic::Ordering::Release);
157                self.rebalance_metric
158                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
159            }
160            Rebalance::Assign(tpl) => {
161                let count = tpl.count();
162                info!(
163                    partitions_assigned = count,
164                    "kafka rebalance: new partitions assigned"
165                );
166                // Update shared rebalance state.
167                let partitions: Vec<(String, i32)> = tpl
168                    .elements()
169                    .iter()
170                    .map(|e| (e.topic().to_string(), e.partition()))
171                    .collect();
172                self.lock_rebalance_state().on_assign(&partitions);
173                self.rebalance_metric
174                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
175            }
176            Rebalance::Error(msg) => {
177                warn!(error = %msg, "kafka rebalance error");
178            }
179        }
180    }
181
182    fn commit_callback(
183        &self,
184        result: rdkafka::error::KafkaResult<()>,
185        offsets: &rdkafka::TopicPartitionList,
186    ) {
187        match result {
188            Ok(()) => {
189                self.commits_counter.inc();
190                tracing::debug!(
191                    partition_count = offsets.count(),
192                    "broker offset commit confirmed"
193                );
194            }
195            Err(e) => {
196                self.commit_failures_counter.inc();
197                warn!(
198                    error = %e,
199                    partition_count = offsets.count(),
200                    "broker offset commit failed (callback)"
201                );
202            }
203        }
204    }
205
206    fn post_rebalance(
207        &self,
208        base_consumer: &rdkafka::consumer::BaseConsumer<Self>,
209        rebalance: &rdkafka::consumer::Rebalance<'_>,
210    ) {
211        use rdkafka::consumer::Rebalance;
212
213        if let Rebalance::Assign(tpl) = rebalance {
214            // Pause the newly-assigned partitions so librdkafka can't fetch from
215            // the reset position before the reader loop seeks them to their
216            // checkpointed offsets; the reader resumes them after the seek. (Seeking
217            // here fails — the partitions aren't fetch-ready yet in the callback.)
218            if let Err(e) = base_consumer.pause(tpl) {
219                warn!(error = %e, "failed to pause newly assigned partitions for seek");
220            }
221            // Signal the reader loop to seek + resume from the live poll loop.
222            self.assign_generation.fetch_add(1, Ordering::Release);
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests;