Skip to main content

laminar_connectors/kafka/
rebalance.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 {
229    use super::*;
230
231    fn is_assigned(state: &RebalanceState, topic: &str, partition: i32) -> bool {
232        state
233            .assigned_partitions()
234            .contains(&(topic.to_string(), partition))
235    }
236
237    #[test]
238    fn test_assign() {
239        let mut state = RebalanceState::new();
240        state.on_assign(&[
241            ("events".into(), 0),
242            ("events".into(), 1),
243            ("events".into(), 2),
244        ]);
245
246        assert_eq!(state.assigned_partitions().len(), 3);
247        assert!(is_assigned(&state, "events", 0));
248        assert!(is_assigned(&state, "events", 1));
249        assert!(is_assigned(&state, "events", 2));
250        assert!(!is_assigned(&state, "events", 3));
251    }
252
253    #[test]
254    fn test_revoke() {
255        let mut state = RebalanceState::new();
256        state.on_assign(&[("events".into(), 0), ("events".into(), 1)]);
257        state.on_revoke(&[("events".into(), 1)]);
258
259        assert_eq!(state.assigned_partitions().len(), 1);
260        assert!(is_assigned(&state, "events", 0));
261        assert!(!is_assigned(&state, "events", 1));
262    }
263
264    #[test]
265    fn test_eager_reassign() {
266        let mut state = RebalanceState::new();
267        state.on_assign(&[("events".into(), 0), ("events".into(), 1)]);
268        // Eager rebalance: revoke all first, then assign new set
269        state.on_revoke(&[("events".into(), 0), ("events".into(), 1)]);
270        state.on_assign(&[("events".into(), 2), ("events".into(), 3)]);
271
272        assert_eq!(state.assigned_partitions().len(), 2);
273        assert!(!is_assigned(&state, "events", 0));
274        assert!(is_assigned(&state, "events", 2));
275    }
276
277    #[test]
278    fn test_cooperative_assign() {
279        let mut state = RebalanceState::new();
280        state.on_assign(&[("events".into(), 0), ("events".into(), 1)]);
281        // Cooperative: only revoke subset, assign new subset
282        state.on_revoke(&[("events".into(), 1)]);
283        state.on_assign(&[("events".into(), 2)]);
284
285        assert_eq!(state.assigned_partitions().len(), 2);
286        assert!(is_assigned(&state, "events", 0)); // retained
287        assert!(!is_assigned(&state, "events", 1)); // revoked
288        assert!(is_assigned(&state, "events", 2)); // newly assigned
289    }
290
291    #[test]
292    fn assignment_snapshot_is_stable_across_rebalance() {
293        let mut state = RebalanceState::new();
294        state.on_assign(&[("events".into(), 0), ("events".into(), 1)]);
295        let pinned = state.assignment_snapshot();
296
297        state.on_revoke(&[("events".into(), 0)]);
298        assert!(pinned.contains(&("events".to_string(), 0)));
299        assert!(!is_assigned(&state, "events", 0));
300    }
301
302    #[test]
303    fn test_empty_state() {
304        let state = RebalanceState::new();
305        assert_eq!(state.assigned_partitions().len(), 0);
306        assert!(!is_assigned(&state, "events", 0));
307    }
308}