1use crate::config::KafkaSourceConfig;
41use crate::context::{Intent, SourceContext};
42use crate::lane::KafkaLane;
43use etl_core::checkpoint::AckIssuer;
44use etl_core::error::{ErrorClass, SourceError};
45use etl_core::metrics::SourceMetrics;
46use etl_core::record::PartitionId;
47use etl_core::source::{DrainBarrier, LaneId, Source, SourceCtx, SourceEvent};
48use rdkafka::consumer::{BaseConsumer, Consumer};
49use rdkafka::message::Message;
50use rdkafka::{Offset, TopicPartitionList};
51use std::collections::HashMap;
52use std::sync::Arc;
53use std::time::{Duration, Instant};
54
55pub struct KafkaSource {
60 config: KafkaSourceConfig,
61 consumer: Option<Arc<BaseConsumer<SourceContext>>>,
62 issuer: Option<AckIssuer>,
63 metrics: Option<SourceMetrics>,
64 assignment: HashMap<LaneId, i32>,
66 revoking: HashMap<LaneId, i32>,
72 next_lane: u32,
73 opened_at: Option<Instant>,
74 saw_first_assignment: bool,
75 pending_unassign: bool,
78 main_queue_rewinds: u64,
80}
81
82impl std::fmt::Debug for KafkaSource {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("KafkaSource")
85 .field("topic", &self.config.topic)
86 .field("group_id", &self.config.group_id)
87 .field("lanes", &self.assignment.len())
88 .finish_non_exhaustive()
89 }
90}
91
92impl KafkaSource {
93 #[must_use]
95 pub fn new(config: KafkaSourceConfig) -> Self {
96 KafkaSource {
97 config,
98 consumer: None,
99 issuer: None,
100 metrics: None,
101 assignment: HashMap::new(),
102 revoking: HashMap::new(),
103 next_lane: 0,
104 opened_at: None,
105 saw_first_assignment: false,
106 pending_unassign: false,
107 main_queue_rewinds: 0,
108 }
109 }
110
111 pub fn from_component_config(
114 section: &etl_core::config::ComponentConfig,
115 ) -> Result<Self, etl_core::config::ConfigError> {
116 Ok(Self::new(KafkaSourceConfig::from_component_config(
117 section,
118 )?))
119 }
120
121 #[must_use]
124 pub fn with_metrics(mut self, metrics: SourceMetrics) -> Self {
125 self.metrics = Some(metrics);
126 self
127 }
128
129 fn consumer(&self) -> Result<&Arc<BaseConsumer<SourceContext>>, SourceError> {
130 self.consumer.as_ref().ok_or_else(|| SourceError::Client {
131 class: ErrorClass::Fatal,
132 reason: "source used before open()".into(),
133 })
134 }
135
136 fn tpl_for(&self, partitions: impl IntoIterator<Item = i32>) -> TopicPartitionList {
137 let mut tpl = TopicPartitionList::new();
138 for p in partitions {
139 tpl.add_partition(&self.config.topic, p);
140 }
141 tpl
142 }
143
144 fn lanes_tpl(&self, lanes: &[LaneId]) -> TopicPartitionList {
145 self.tpl_for(lanes.iter().filter_map(|l| self.assignment.get(l).copied()))
146 }
147
148 fn retained_partition_ids(&self) -> Vec<PartitionId> {
152 self.assignment
153 .values()
154 .map(|p| PartitionId(u32::try_from(*p).unwrap_or(0)))
155 .collect()
156 }
157
158 fn committable_partitions(&self) -> Vec<i32> {
162 self.assignment
163 .values()
164 .chain(self.revoking.values())
165 .copied()
166 .collect()
167 }
168
169 fn accept_assignment(
171 &mut self,
172 tpl: &TopicPartitionList,
173 ) -> Result<Vec<KafkaLane>, SourceError> {
174 let consumer = Arc::clone(self.consumer()?);
175 let issuer = self.issuer.as_ref().ok_or_else(|| SourceError::Client {
176 class: ErrorClass::Fatal,
177 reason: "assignment before open()".into(),
178 })?;
179
180 consumer.assign(tpl).map_err(fatal("assign"))?;
181 consumer.pause(tpl).map_err(fatal("pause new assignment"))?;
184
185 let mut lanes = Vec::new();
186 for elem in tpl.elements() {
187 let partition = elem.partition();
188 let queue = consumer
189 .split_partition_queue(&self.config.topic, partition)
190 .ok_or_else(|| SourceError::Client {
191 class: ErrorClass::Fatal,
192 reason: format!("no queue for assigned partition {partition}"),
193 })?;
194 let lane_id = LaneId(self.next_lane);
195 self.next_lane += 1;
196 self.assignment.insert(lane_id, partition);
197 lanes.push(KafkaLane::new(
198 lane_id,
199 PartitionId(u32::try_from(partition).unwrap_or(0)),
200 queue,
201 issuer.clone(),
202 ));
203 }
204 consumer
205 .resume(tpl)
206 .map_err(fatal("resume new assignment"))?;
207 self.saw_first_assignment = true;
208 tracing::info!(
209 partitions = lanes.len(),
210 topic = %self.config.topic,
211 "accepted assignment"
212 );
213 Ok(lanes)
214 }
215
216 fn publish_stats(&mut self) {
218 let Some(consumer) = self.consumer.as_ref() else {
219 return;
220 };
221 let Some(stats) = consumer.context().stats.lock().expect("stats lock").take() else {
222 return;
223 };
224 let Some(metrics) = self.metrics.as_ref() else {
225 return;
226 };
227 let mut max_lag: u64 = 0;
228 if let Some(topic) = stats.topics.get(&self.config.topic) {
229 for (pid, p) in &topic.partitions {
230 if p.consumer_lag >= 0
231 && let Ok(part) = u32::try_from(*pid)
232 {
233 let lag = u64::try_from(p.consumer_lag).unwrap_or(0);
234 max_lag = max_lag.max(lag);
235 metrics.set_partition_lag(PartitionId(part), lag);
236 }
237 }
238 }
239 metrics.set_lag_max(max_lag);
240 }
241}
242
243fn fatal(what: &'static str) -> impl Fn(rdkafka::error::KafkaError) -> SourceError {
244 move |e| SourceError::Client {
245 class: ErrorClass::Fatal,
246 reason: format!("{what}: {e}"),
247 }
248}
249
250impl Source for KafkaSource {
251 type Lane = KafkaLane;
252
253 fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
254 if self.consumer.is_some() {
255 return Err(SourceError::Client {
256 class: ErrorClass::Fatal,
257 reason: "open() called twice".into(),
258 });
259 }
260 let consumer: BaseConsumer<SourceContext> = self
261 .config
262 .client_config()
263 .create_with_context(SourceContext::default())
264 .map_err(fatal("create consumer"))?;
265 consumer
266 .subscribe(&[&self.config.topic])
267 .map_err(fatal("subscribe"))?;
268 self.consumer = Some(Arc::new(consumer));
269 self.issuer = Some(ctx.issuer);
270 self.opened_at = Some(Instant::now());
271 Ok(())
272 }
273
274 fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<KafkaLane>, SourceError> {
275 if !self.saw_first_assignment
280 && let Some(at) = self.opened_at
281 && at.elapsed() > self.config.startup_timeout
282 {
283 return Err(SourceError::Client {
284 class: ErrorClass::Fatal,
285 reason: format!(
286 "no partition assignment within {:?} (topic {:?}, brokers {:?})",
287 self.config.startup_timeout, self.config.topic, self.config.brokers
288 ),
289 });
290 }
291
292 if self.pending_unassign {
295 self.pending_unassign = false;
296 let consumer = Arc::clone(self.consumer()?);
297 if let Err(e) = consumer.unassign() {
298 tracing::warn!(error = %e, "unassign after drained revocation");
299 }
300 self.revoking.clear();
303 }
304
305 let consumer = Arc::clone(self.consumer()?);
306
307 if let Some(result) = consumer.poll(timeout) {
312 match result {
313 Ok(msg) => {
314 self.main_queue_rewinds += 1;
315 tracing::warn!(
316 partition = msg.partition(),
317 offset = msg.offset(),
318 total = self.main_queue_rewinds,
319 "message on the main queue; rewinding partition"
320 );
321 let tpl = self.tpl_for([msg.partition()]);
322 let _ = consumer.pause(&tpl);
323 if let Err(e) = consumer.seek(
324 &self.config.topic,
325 msg.partition(),
326 Offset::Offset(msg.offset()),
327 Duration::from_secs(5),
328 ) {
329 tracing::error!(error = %e, "seek for main-queue rewind failed");
330 }
331 let _ = consumer.resume(&tpl);
332 }
333 Err(e) => {
334 return Err(SourceError::Client {
338 class: crate::error::classify_poll_error(&e, self.saw_first_assignment),
339 reason: format!("consumer poll: {e}"),
340 });
341 }
342 }
343 }
344
345 self.publish_stats();
346
347 let intent = {
350 let ctx = self.consumer()?.context().clone();
351 let mut intents = ctx.intents.lock().expect("intent lock");
352 intents.pop_front()
353 };
354 if let Some(intent) = intent {
355 match intent {
356 Intent::Assign(tpl) => {
357 if let Some(m) = &self.metrics {
358 m.rebalance_assigned();
359 m.set_lanes_active(tpl.count());
360 }
361 if tpl.count() == 0 {
362 let consumer = Arc::clone(self.consumer()?);
369 consumer.assign(&tpl).map_err(fatal("assign empty"))?;
370 self.saw_first_assignment = true;
371 return Ok(SourceEvent::Idle);
372 }
373 let lanes = self.accept_assignment(&tpl)?;
374 return Ok(SourceEvent::LanesAssigned(lanes));
375 }
376 Intent::Revoke(tpl) => {
377 if let Some(m) = &self.metrics {
378 m.rebalance_revoked();
379 }
380 let revoked: Vec<i32> = tpl.elements().iter().map(|e| e.partition()).collect();
382 let lanes: Vec<LaneId> = self
383 .assignment
384 .iter()
385 .filter(|(_, p)| revoked.contains(p))
386 .map(|(l, _)| *l)
387 .collect();
388 for lane in &lanes {
395 if let Some(p) = self.assignment.remove(lane) {
396 self.revoking.insert(*lane, p);
397 }
398 }
399 if let Some(m) = &self.metrics {
403 m.retain_partitions(&self.retained_partition_ids());
404 }
405 self.pending_unassign = true;
408 if lanes.is_empty() {
409 return Ok(SourceEvent::Idle);
410 }
411 let barrier = DrainBarrier::new(lanes.len());
412 return Ok(SourceEvent::LanesRevoked { lanes, barrier });
413 }
414 Intent::Error(reason) => {
415 return Err(SourceError::Client {
416 class: ErrorClass::Retryable,
417 reason: format!("rebalance error: {reason}"),
418 });
419 }
420 }
421 }
422
423 Ok(SourceEvent::Idle)
424 }
425
426 fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
427 if watermarks.is_empty() {
428 return Ok(());
429 }
430 let consumer = Arc::clone(self.consumer()?);
431 let owned = self.committable_partitions();
437 let mut tpl = TopicPartitionList::new();
438 for (p, offset) in watermarks {
439 let partition = i32::try_from(p.0).unwrap_or(-1);
440 if owned.contains(&partition) {
441 tpl.add_partition_offset(&self.config.topic, partition, Offset::Offset(*offset))
442 .map_err(fatal("build offset list"))?;
443 } else {
444 tracing::debug!(
445 partition = p.0,
446 offset,
447 "skipping store for partition no longer owned"
448 );
449 }
450 }
451 if tpl.count() == 0 {
452 return Ok(());
453 }
454 consumer
455 .store_offsets(&tpl)
456 .map_err(|e| SourceError::Client {
457 class: ErrorClass::Retryable,
458 reason: format!("store offsets: {e}"),
459 })
460 }
461
462 fn flush_commits(&mut self) -> Result<(), SourceError> {
463 let consumer = Arc::clone(self.consumer()?);
464 match consumer.commit_consumer_state(rdkafka::consumer::CommitMode::Sync) {
465 Ok(()) => Ok(()),
466 Err(rdkafka::error::KafkaError::ConsumerCommit(
468 rdkafka::error::RDKafkaErrorCode::NoOffset,
469 )) => Ok(()),
470 Err(e) => Err(SourceError::Client {
471 class: ErrorClass::Retryable,
472 reason: format!("sync commit: {e}"),
473 }),
474 }
475 }
476
477 fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
478 let tpl = self.lanes_tpl(lanes);
479 if tpl.count() == 0 {
480 return Ok(());
481 }
482 self.consumer()?
483 .pause(&tpl)
484 .map_err(|e| SourceError::Client {
485 class: ErrorClass::Retryable,
486 reason: format!("pause: {e}"),
487 })
488 }
489
490 fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
491 let tpl = self.lanes_tpl(lanes);
492 if tpl.count() == 0 {
493 return Ok(());
494 }
495 self.consumer()?
496 .resume(&tpl)
497 .map_err(|e| SourceError::Client {
498 class: ErrorClass::Retryable,
499 reason: format!("resume: {e}"),
500 })
501 }
502}
503
504impl Drop for KafkaSource {
510 fn drop(&mut self) {
511 if let Some(consumer) = &self.consumer {
512 consumer
513 .context()
514 .closing
515 .store(true, std::sync::atomic::Ordering::Release);
516 let deferred_revoke = self.pending_unassign
517 || consumer
518 .context()
519 .intents
520 .lock()
521 .map(|q| q.iter().any(|i| matches!(i, Intent::Revoke(_))))
522 .unwrap_or(false);
523 if deferred_revoke && let Err(e) = consumer.unassign() {
524 tracing::warn!(error = %e, "unassign during source teardown failed");
525 }
526 }
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 fn test_config() -> KafkaSourceConfig {
535 KafkaSourceConfig {
536 brokers: "localhost:9092".into(),
537 topic: "orders".into(),
538 group_id: "test".into(),
539 commit_interval: Duration::from_secs(5),
540 startup_timeout: Duration::from_secs(30),
541 statistics_interval: Duration::ZERO,
542 rdkafka: std::collections::BTreeMap::new(),
543 }
544 }
545
546 fn revoke_lanes(source: &mut KafkaSource, revoked: &[i32]) {
549 let lanes: Vec<LaneId> = source
550 .assignment
551 .iter()
552 .filter(|(_, p)| revoked.contains(p))
553 .map(|(l, _)| *l)
554 .collect();
555 for lane in &lanes {
556 if let Some(p) = source.assignment.remove(lane) {
557 source.revoking.insert(*lane, p);
558 }
559 }
560 }
561
562 #[test]
566 fn committable_partitions_include_revoking_until_released() {
567 let mut source = KafkaSource::new(test_config());
568 for (lane, part) in [(0u32, 0i32), (1, 1), (2, 2), (3, 3)] {
569 source.assignment.insert(LaneId(lane), part);
570 }
571
572 revoke_lanes(&mut source, &[2, 3]);
573
574 let mut owned = source.committable_partitions();
575 owned.sort_unstable();
576 assert_eq!(
577 owned,
578 vec![0, 1, 2, 3],
579 "revoked partitions stay committable until unassign releases them"
580 );
581
582 source.revoking.clear();
585 let mut owned = source.committable_partitions();
586 owned.sort_unstable();
587 assert_eq!(owned, vec![0, 1]);
588 }
589
590 #[test]
593 fn retained_partition_ids_drop_revoked_partitions() {
594 let mut source = KafkaSource::new(test_config());
595 for (lane, part) in [(0u32, 0i32), (1, 1), (2, 2)] {
596 source.assignment.insert(LaneId(lane), part);
597 }
598
599 revoke_lanes(&mut source, &[2]);
600
601 let mut kept: Vec<u32> = source
602 .retained_partition_ids()
603 .iter()
604 .map(|p| p.0)
605 .collect();
606 kept.sort_unstable();
607 assert_eq!(kept, vec![0, 1], "revoked partition 2 is not retained");
608 }
609}