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;