Skip to main content

risingwave_connector/source/kafka/
enumerator.rs

1// Copyright 2025 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16use std::sync::{Arc, LazyLock, Weak};
17use std::time::Duration;
18
19use anyhow::{Context, anyhow};
20use async_trait::async_trait;
21use moka::future::Cache as MokaCache;
22use moka::ops::compute::Op;
23use rdkafka::admin::{AdminClient, AdminOptions};
24use rdkafka::consumer::BaseConsumer;
25#[cfg(not(madsim))]
26use rdkafka::consumer::Consumer;
27use rdkafka::error::{KafkaError, KafkaResult};
28use rdkafka::types::RDKafkaErrorCode;
29use rdkafka::{ClientConfig, Offset, TopicPartitionList};
30use risingwave_common::bail;
31use risingwave_common::id::FragmentId;
32use risingwave_common::metrics::LabelGuardedIntGauge;
33use thiserror_ext::AsReport;
34
35use crate::connector_common::read_kafka_log_level;
36use crate::error::{ConnectorError, ConnectorResult};
37use crate::source::SourceEnumeratorContextRef;
38use crate::source::base::SplitEnumerator;
39use crate::source::kafka::split::KafkaSplit;
40use crate::source::kafka::{
41    KAFKA_ISOLATION_LEVEL, KafkaConnectionProps, KafkaContextCommon, KafkaProperties,
42    RwConsumerContext,
43};
44
45type KafkaConsumer = BaseConsumer<RwConsumerContext>;
46type KafkaAdmin = AdminClient<RwConsumerContext>;
47
48/// Consumer client is shared, and the cache doesn't manage the lifecycle, so we store `Weak` and no eviction.
49pub static SHARED_KAFKA_CONSUMER: LazyLock<MokaCache<KafkaConnectionProps, Weak<KafkaConsumer>>> =
50    LazyLock::new(|| moka::future::Cache::builder().build());
51/// Admin client is short-lived, so we store `Arc` and sets a time-to-idle eviction policy.
52pub static SHARED_KAFKA_ADMIN: LazyLock<MokaCache<KafkaConnectionProps, Arc<KafkaAdmin>>> =
53    LazyLock::new(|| {
54        moka::future::Cache::builder()
55            .time_to_idle(Duration::from_secs(5 * 60))
56            .build()
57    });
58
59#[derive(Debug, Copy, Clone, Eq, PartialEq)]
60pub enum KafkaEnumeratorOffset {
61    Earliest,
62    Latest,
63    Timestamp(i64),
64    None,
65}
66
67pub struct KafkaSplitEnumerator {
68    context: SourceEnumeratorContextRef,
69    broker_address: String,
70    topic: String,
71    client: Arc<KafkaConsumer>,
72    start_offset: KafkaEnumeratorOffset,
73
74    // maybe used in the future for batch processing
75    stop_offset: KafkaEnumeratorOffset,
76
77    sync_call_timeout: Duration,
78    high_watermark_metrics: HashMap<i32, LabelGuardedIntGauge>,
79
80    properties: KafkaProperties,
81    config: rdkafka::ClientConfig,
82}
83
84impl KafkaSplitEnumerator {
85    fn report_consumer_group_delete_failure(&self, group_id: &str) {
86        let source_id = self.context.info.source_id.to_string();
87        self.context
88            .metrics
89            .kafka_consumer_group_delete_failure_count
90            .with_label_values(&[&source_id, group_id])
91            .inc();
92    }
93
94    async fn drop_consumer_groups(&self, fragment_ids: Vec<FragmentId>) -> ConnectorResult<()> {
95        let admin = Box::pin(SHARED_KAFKA_ADMIN.try_get_with_by_ref(
96            &self.properties.connection,
97            async {
98                tracing::info!("build new kafka admin for {}", self.broker_address);
99                Ok(Arc::new(
100                    build_kafka_admin(&self.config, &self.properties).await?,
101                ))
102            },
103        ))
104        .await?;
105
106        let group_ids = fragment_ids
107            .iter()
108            .map(|fragment_id| self.properties.group_id(*fragment_id))
109            .collect::<Vec<_>>();
110        let group_id_refs = group_ids
111            .iter()
112            .map(|group_id| group_id.as_str())
113            .collect::<Vec<_>>();
114
115        let res = match admin
116            .delete_groups(&group_id_refs, &AdminOptions::default())
117            .await
118        {
119            Ok(res) => res,
120            Err(err) => {
121                for group_id in &group_ids {
122                    self.report_consumer_group_delete_failure(group_id);
123                }
124                tracing::warn!(
125                    error = %err.as_report(),
126                    topic = self.topic,
127                    ?group_ids,
128                    "failed to delete Kafka consumer groups"
129                );
130                return Err(err.into());
131            }
132        };
133
134        let mut failure_count = 0;
135        for result in &res {
136            if let Err((group_id, error_code)) = result {
137                failure_count += 1;
138                self.report_consumer_group_delete_failure(group_id);
139                tracing::warn!(
140                    topic = self.topic,
141                    group_id,
142                    error = %error_code.as_report(),
143                    "failed to delete Kafka consumer group"
144                );
145            }
146        }
147        tracing::debug!(
148            topic = self.topic,
149            ?fragment_ids,
150            ?res,
151            failure_count,
152            "delete groups result"
153        );
154        Ok(())
155    }
156}
157
158#[async_trait]
159impl SplitEnumerator for KafkaSplitEnumerator {
160    type Properties = KafkaProperties;
161    type Split = KafkaSplit;
162
163    async fn new(
164        properties: KafkaProperties,
165        context: SourceEnumeratorContextRef,
166    ) -> ConnectorResult<KafkaSplitEnumerator> {
167        let mut config = rdkafka::ClientConfig::new();
168        let common_props = &properties.common;
169
170        let broker_address = properties.connection.brokers.clone();
171        let topic = common_props.topic.clone();
172        config.set("bootstrap.servers", &broker_address);
173        config.set("isolation.level", KAFKA_ISOLATION_LEVEL);
174        if let Some(log_level) = read_kafka_log_level() {
175            config.set_log_level(log_level);
176        }
177        properties.connection.set_security_properties(&mut config);
178        properties.set_client(&mut config);
179        // The meta-side split enumerator does not export librdkafka native stats, so disable
180        // periodic statistics callbacks here even if the source properties enable them for
181        // compute-side readers.
182        config.set("statistics.interval.ms", "0");
183        let mut scan_start_offset = match properties
184            .scan_startup_mode
185            .as_ref()
186            .map(|s| s.to_lowercase())
187            .as_deref()
188        {
189            Some("earliest") => KafkaEnumeratorOffset::Earliest,
190            Some("latest") => KafkaEnumeratorOffset::Latest,
191            None => KafkaEnumeratorOffset::Earliest,
192            _ => bail!(
193                "properties `scan_startup_mode` only supports earliest and latest or leaving it empty"
194            ),
195        };
196
197        if let Some(s) = &properties.time_offset {
198            let time_offset = s.parse::<i64>().map_err(|e| anyhow!(e))?;
199            scan_start_offset = KafkaEnumeratorOffset::Timestamp(time_offset)
200        }
201
202        let mut client: Option<Arc<KafkaConsumer>> = None;
203        SHARED_KAFKA_CONSUMER
204            .entry_by_ref(&properties.connection)
205            .and_try_compute_with::<_, _, ConnectorError>(|maybe_entry| async {
206                if let Some(entry) = maybe_entry {
207                    let entry_value = entry.into_value();
208                    if let Some(client_) = entry_value.upgrade() {
209                        // return if the client is already built
210                        tracing::info!("reuse existing kafka client for {}", broker_address);
211                        client = Some(client_);
212                        return Ok(Op::Nop);
213                    }
214                }
215                tracing::info!("build new kafka client for {}", broker_address);
216                client = Some(build_kafka_client(&config, &properties).await?);
217                Ok(Op::Put(Arc::downgrade(client.as_ref().unwrap())))
218            })
219            .await?;
220
221        Ok(Self {
222            context,
223            broker_address,
224            topic,
225            client: client.unwrap(),
226            start_offset: scan_start_offset,
227            stop_offset: KafkaEnumeratorOffset::None,
228            sync_call_timeout: properties.common.sync_call_timeout,
229            high_watermark_metrics: HashMap::new(),
230            properties,
231            config,
232        })
233    }
234
235    async fn list_splits(&mut self) -> ConnectorResult<Vec<KafkaSplit>> {
236        // `KafkaSplitEnumerator` uses `BaseConsumer`, which does not have a background polling
237        // thread. Poll once per `list_splits` invocation so meta's periodic source-manager tick
238        // can serve queued callbacks like librdkafka statistics events.
239        //
240        // This meta-side enumerator does not have a fragment id, so it cannot derive the
241        // compute-side consumer group id. Polling this no-group client may therefore return
242        // `UnknownGroup`, which is expected and intentionally filtered below. Other poll errors
243        // are still logged as warnings.
244        if let Some(Err(poll_err)) = {
245            #[cfg(not(madsim))]
246            {
247                self.client.poll(Duration::ZERO)
248            }
249            #[cfg(madsim)]
250            {
251                self.client.poll(Duration::ZERO).await
252            }
253        } && !is_expected_no_group_poll_error(&poll_err)
254        {
255            tracing::warn!(
256                error = %poll_err.as_report(),
257                topic = self.topic,
258                broker_address = self.broker_address,
259                "failed to poll kafka client");
260        }
261
262        let topic_partitions = self.fetch_topic_partition().await.with_context(|| {
263            format!(
264                "failed to fetch metadata from kafka ({})",
265                self.broker_address
266            )
267        })?;
268
269        let watermarks = self.get_watermarks(topic_partitions.as_ref()).await?;
270        let mut start_offsets = self
271            .fetch_start_offset(topic_partitions.as_ref(), &watermarks)
272            .await?;
273
274        let mut stop_offsets = self
275            .fetch_stop_offset(topic_partitions.as_ref(), &watermarks)
276            .await?;
277
278        let ret: Vec<_> = topic_partitions
279            .into_iter()
280            .map(|partition| KafkaSplit {
281                topic: self.topic.clone(),
282                partition,
283                start_offset: start_offsets.remove(&partition).unwrap(),
284                stop_offset: stop_offsets.remove(&partition).unwrap(),
285            })
286            .collect();
287
288        Ok(ret)
289    }
290
291    async fn on_drop_fragments(&mut self, fragment_ids: Vec<FragmentId>) -> ConnectorResult<()> {
292        self.drop_consumer_groups(fragment_ids).await
293    }
294
295    async fn on_finish_backfill(&mut self, fragment_ids: Vec<FragmentId>) -> ConnectorResult<()> {
296        self.drop_consumer_groups(fragment_ids).await
297    }
298}
299
300fn is_expected_no_group_poll_error(error: &KafkaError) -> bool {
301    matches!(
302        error,
303        KafkaError::MessageConsumption(RDKafkaErrorCode::UnknownGroup)
304    )
305}
306
307async fn build_kafka_client(
308    config: &ClientConfig,
309    properties: &KafkaProperties,
310) -> ConnectorResult<Arc<KafkaConsumer>> {
311    let ctx_common = KafkaContextCommon::new(
312        properties.privatelink_common.broker_rewrite_map.clone(),
313        None,
314        None,
315        properties.aws_auth_props.clone(),
316        properties.connection.is_aws_msk_iam(),
317    )
318    .await?;
319    let client_ctx = RwConsumerContext::new(ctx_common);
320    let client: KafkaConsumer = config.create_with_context(client_ctx).await?;
321
322    // Note that before any SASL/OAUTHBEARER broker connection can succeed the application must call
323    // rd_kafka_oauthbearer_set_token() once – either directly or, more typically, by invoking either
324    // rd_kafka_poll(), rd_kafka_consumer_poll(), rd_kafka_queue_poll(), etc, in order to cause retrieval
325    // of an initial token to occur.
326    // https://docs.confluent.io/platform/current/clients/librdkafka/html/rdkafka_8h.html#a988395722598f63396d7a1bedb22adaf
327    if properties.connection.is_aws_msk_iam() {
328        #[cfg(not(madsim))]
329        client.poll(Duration::from_secs(10)); // note: this is a blocking call
330        #[cfg(madsim)]
331        client.poll(Duration::from_secs(10)).await;
332    }
333    Ok(Arc::new(client))
334}
335async fn build_kafka_admin(
336    config: &ClientConfig,
337    properties: &KafkaProperties,
338) -> ConnectorResult<KafkaAdmin> {
339    let ctx_common = KafkaContextCommon::new(
340        properties.privatelink_common.broker_rewrite_map.clone(),
341        None,
342        None,
343        properties.aws_auth_props.clone(),
344        properties.connection.is_aws_msk_iam(),
345    )
346    .await?;
347    let client_ctx = RwConsumerContext::new(ctx_common);
348    let client: KafkaAdmin = config.create_with_context(client_ctx).await?;
349    // AdminClient calls start_poll_thread on creation, so the additional poll seems not needed. (And currently no API for this.)
350    Ok(client)
351}
352
353impl KafkaSplitEnumerator {
354    async fn get_watermarks(
355        &mut self,
356        partitions: &[i32],
357    ) -> KafkaResult<HashMap<i32, (i64, i64)>> {
358        let mut map = HashMap::new();
359        for partition in partitions {
360            let (low, high) = self
361                .client
362                .fetch_watermarks(self.topic.as_str(), *partition, self.sync_call_timeout)
363                .await?;
364            self.report_high_watermark(*partition, high);
365            map.insert(*partition, (low, high));
366        }
367        tracing::debug!("fetch kafka watermarks: {map:?}");
368        Ok(map)
369    }
370
371    pub async fn list_splits_batch(
372        &mut self,
373        expect_start_timestamp_millis: Option<i64>,
374        expect_stop_timestamp_millis: Option<i64>,
375    ) -> ConnectorResult<Vec<KafkaSplit>> {
376        let topic_partitions = self.fetch_topic_partition().await.with_context(|| {
377            format!(
378                "failed to fetch metadata from kafka ({})",
379                self.broker_address
380            )
381        })?;
382
383        // Watermark here has nothing to do with watermark in streaming processing. Watermark
384        // here means smallest/largest offset available for reading.
385        let mut watermarks = self.get_watermarks(topic_partitions.as_ref()).await?;
386
387        // here we are getting the start offset and end offset for each partition with the given
388        // timestamp if the timestamp is None, we will use the low watermark and high
389        // watermark as the start and end offset if the timestamp is provided, we will use
390        // the watermark to narrow down the range
391        let mut expect_start_offset = if let Some(ts) = expect_start_timestamp_millis {
392            Some(
393                self.fetch_offset_for_time(topic_partitions.as_ref(), ts, &watermarks)
394                    .await?,
395            )
396        } else {
397            None
398        };
399
400        let mut expect_stop_offset = if let Some(ts) = expect_stop_timestamp_millis {
401            Some(
402                self.fetch_offset_for_time(topic_partitions.as_ref(), ts, &watermarks)
403                    .await?,
404            )
405        } else {
406            None
407        };
408
409        Ok(topic_partitions
410            .iter()
411            .map(|partition| {
412                let (low, high) = watermarks.remove(partition).unwrap();
413                let start_offset = {
414                    let earliest_offset = low - 1;
415                    let start = expect_start_offset
416                        .as_mut()
417                        .map(|m| m.remove(partition).flatten().unwrap_or(earliest_offset))
418                        .unwrap_or(earliest_offset);
419                    i64::max(start, earliest_offset)
420                };
421                let stop_offset = {
422                    let stop = expect_stop_offset
423                        .as_mut()
424                        .map(|m| m.remove(partition).unwrap_or(Some(high)))
425                        .unwrap_or(Some(high))
426                        .unwrap_or(high);
427                    i64::min(stop, high)
428                };
429
430                if start_offset > stop_offset {
431                    tracing::warn!(
432                        "Skipping topic {} partition {}: requested start offset {} is greater than stop offset {}",
433                        self.topic,
434                        partition,
435                        start_offset,
436                        stop_offset
437                    );
438                }
439                KafkaSplit {
440                    topic: self.topic.clone(),
441                    partition: *partition,
442                    start_offset: Some(start_offset),
443                    stop_offset: Some(stop_offset),
444                }
445            })
446            .collect::<Vec<KafkaSplit>>())
447    }
448
449    async fn fetch_stop_offset(
450        &self,
451        partitions: &[i32],
452        watermarks: &HashMap<i32, (i64, i64)>,
453    ) -> KafkaResult<HashMap<i32, Option<i64>>> {
454        match self.stop_offset {
455            KafkaEnumeratorOffset::Earliest => unreachable!(),
456            KafkaEnumeratorOffset::Latest => {
457                let mut map = HashMap::new();
458                for partition in partitions {
459                    let (_, high_watermark) = watermarks.get(partition).unwrap();
460                    map.insert(*partition, Some(*high_watermark));
461                }
462                Ok(map)
463            }
464            KafkaEnumeratorOffset::Timestamp(time) => {
465                self.fetch_offset_for_time(partitions, time, watermarks)
466                    .await
467            }
468            KafkaEnumeratorOffset::None => partitions
469                .iter()
470                .map(|partition| Ok((*partition, None)))
471                .collect(),
472        }
473    }
474
475    async fn fetch_start_offset(
476        &self,
477        partitions: &[i32],
478        watermarks: &HashMap<i32, (i64, i64)>,
479    ) -> KafkaResult<HashMap<i32, Option<i64>>> {
480        match self.start_offset {
481            KafkaEnumeratorOffset::Earliest | KafkaEnumeratorOffset::Latest => {
482                let mut map = HashMap::new();
483                for partition in partitions {
484                    let (low_watermark, high_watermark) = watermarks.get(partition).unwrap();
485                    let offset = match self.start_offset {
486                        KafkaEnumeratorOffset::Earliest => low_watermark - 1,
487                        KafkaEnumeratorOffset::Latest => high_watermark - 1,
488                        _ => unreachable!(),
489                    };
490                    map.insert(*partition, Some(offset));
491                }
492                Ok(map)
493            }
494            KafkaEnumeratorOffset::Timestamp(time) => {
495                self.fetch_offset_for_time(partitions, time, watermarks)
496                    .await
497            }
498            KafkaEnumeratorOffset::None => partitions
499                .iter()
500                .map(|partition| Ok((*partition, None)))
501                .collect(),
502        }
503    }
504
505    async fn fetch_offset_for_time(
506        &self,
507        partitions: &[i32],
508        time: i64,
509        watermarks: &HashMap<i32, (i64, i64)>,
510    ) -> KafkaResult<HashMap<i32, Option<i64>>> {
511        let mut tpl = TopicPartitionList::new();
512
513        for partition in partitions {
514            tpl.add_partition_offset(self.topic.as_str(), *partition, Offset::Offset(time))?;
515        }
516
517        let offsets = self
518            .client
519            .offsets_for_times(tpl, self.sync_call_timeout)
520            .await?;
521
522        let mut result = HashMap::with_capacity(partitions.len());
523
524        for elem in offsets.elements_for_topic(self.topic.as_str()) {
525            match elem.offset() {
526                Offset::Offset(offset) => {
527                    // XXX(rc): currently in RW source, `offset` means the last consumed offset, so we need to subtract 1
528                    result.insert(elem.partition(), Some(offset - 1));
529                }
530                Offset::End => {
531                    let (_, high_watermark) = watermarks.get(&elem.partition()).unwrap();
532                    tracing::info!(
533                        source_id = %self.context.info.source_id,
534                        "no message found before timestamp {} (ms) for partition {}, start from latest",
535                        time,
536                        elem.partition()
537                    );
538                    result.insert(elem.partition(), Some(high_watermark - 1)); // align to Latest
539                }
540                Offset::Invalid => {
541                    // special case for madsim test
542                    // For a read Kafka, it returns `Offset::Latest` when the timestamp is later than the latest message in the partition
543                    // But in madsim, it returns `Offset::Invalid`
544                    // So we align to Latest here
545                    tracing::info!(
546                        source_id = %self.context.info.source_id,
547                        "got invalid offset for partition  {} at timestamp {}, align to latest",
548                        elem.partition(),
549                        time
550                    );
551                    let (_, high_watermark) = watermarks.get(&elem.partition()).unwrap();
552                    result.insert(elem.partition(), Some(high_watermark - 1)); // align to Latest
553                }
554                Offset::Beginning => {
555                    let (low, _) = watermarks.get(&elem.partition()).unwrap();
556                    tracing::info!(
557                        source_id = %self.context.info.source_id,
558                        "all message in partition {} is after timestamp {} (ms), start from earliest",
559                        elem.partition(),
560                        time,
561                    );
562                    result.insert(elem.partition(), Some(low - 1)); // align to Earliest
563                }
564                err_offset @ Offset::Stored | err_offset @ Offset::OffsetTail(_) => {
565                    tracing::error!(
566                        source_id = %self.context.info.source_id,
567                        "got invalid offset for partition {}: {err_offset:?}",
568                        elem.partition(),
569                        err_offset = err_offset,
570                    );
571                    return Err(KafkaError::OffsetFetch(RDKafkaErrorCode::NoOffset));
572                }
573            }
574        }
575
576        Ok(result)
577    }
578
579    #[inline]
580    fn report_high_watermark(&mut self, partition: i32, offset: i64) {
581        let high_watermark_metrics =
582            self.high_watermark_metrics
583                .entry(partition)
584                .or_insert_with(|| {
585                    self.context
586                        .metrics
587                        .high_watermark
588                        .with_guarded_label_values(&[
589                            &self.context.info.source_id.to_string(),
590                            &partition.to_string(),
591                        ])
592                });
593        high_watermark_metrics.set(offset);
594    }
595
596    pub async fn check_reachability(&self) -> ConnectorResult<()> {
597        let _ = self
598            .client
599            .fetch_metadata(Some(self.topic.as_str()), self.sync_call_timeout)
600            .await?;
601        Ok(())
602    }
603
604    async fn fetch_topic_partition(&self) -> ConnectorResult<Vec<i32>> {
605        // for now, we only support one topic
606        let metadata = self
607            .client
608            .fetch_metadata(Some(self.topic.as_str()), self.sync_call_timeout)
609            .await?;
610
611        let topic_meta = match metadata.topics() {
612            [meta] => meta,
613            _ => bail!("topic {} not found", self.topic),
614        };
615
616        if topic_meta.partitions().is_empty() {
617            bail!("topic {} not found", self.topic);
618        }
619
620        Ok(topic_meta
621            .partitions()
622            .iter()
623            .map(|partition| partition.id())
624            .collect())
625    }
626}