laminar_connectors/kafka/
rebalance.rs1use 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#[derive(Debug, Clone, Default)]
20pub struct RebalanceState {
21 assigned: Arc<HashSet<(String, i32)>>,
23}
24
25impl RebalanceState {
26 #[must_use]
28 pub fn new() -> Self {
29 Self::default()
30 }
31
32 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 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 #[must_use]
58 pub fn assigned_partitions(&self) -> &HashSet<(String, i32)> {
59 &self.assigned
60 }
61
62 #[must_use]
64 pub fn assignment_snapshot(&self) -> Arc<HashSet<(String, i32)>> {
65 Arc::clone(&self.assigned)
66 }
67}
68
69pub struct LaminarConsumerContext {
79 rebalance_state: Arc<Mutex<RebalanceState>>,
81 rebalance_metric: Arc<AtomicU64>,
83 revoke_generation: Arc<AtomicU64>,
90 assign_generation: Arc<AtomicU64>,
93 commits_counter: IntCounter,
97 commit_failures_counter: IntCounter,
99}
100
101impl LaminarConsumerContext {
102 #[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 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 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 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 if let Err(e) = base_consumer.pause(tpl) {
219 warn!(error = %e, "failed to pause newly assigned partitions for seek");
220 }
221 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 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 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)); assert!(!is_assigned(&state, "events", 1)); assert!(is_assigned(&state, "events", 2)); }
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}