Skip to main content

risingwave_connector/source/cdc/enumerator/
mod.rs

1// Copyright 2022 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::BTreeMap;
16use std::marker::PhantomData;
17use std::ops::Deref;
18use std::str::FromStr;
19use std::sync::Arc;
20
21use anyhow::{Context, anyhow};
22use async_trait::async_trait;
23use itertools::Itertools;
24use mysql_async::Row;
25use mysql_async::prelude::*;
26use prost::Message;
27use risingwave_common::global_jvm::Jvm;
28use risingwave_common::id::SourceId;
29use risingwave_common::util::addr::HostAddr;
30use risingwave_jni_core::call_static_method;
31use risingwave_jni_core::jvm_runtime::execute_with_jni_env;
32use risingwave_pb::connector_service::{SourceType, ValidateSourceRequest, ValidateSourceResponse};
33use thiserror_ext::AsReport;
34use tiberius::Config;
35use tokio_postgres::types::PgLsn;
36
37use crate::connector_common::{SslMode, create_pg_client, pg_connection_config_from_properties};
38use crate::error::ConnectorResult;
39use crate::sink::sqlserver::SqlServerClient;
40use crate::source::cdc::external::mysql::build_mysql_connection_pool;
41use crate::source::cdc::split::{extract_binlog_file_seq, parse_sql_server_lsn_str};
42use crate::source::cdc::{
43    CdcProperties, CdcSourceTypeTrait, Citus, DebeziumCdcSplit, Mongodb, Mysql, Postgres,
44    SqlServer, table_schema_exclude_additional_columns,
45};
46use crate::source::monitor::metrics::EnumeratorMetrics;
47use crate::source::{SourceEnumeratorContextRef, SplitEnumerator};
48
49pub const DATABASE_SERVERS_KEY: &str = "database.servers";
50
51#[derive(Debug)]
52pub struct DebeziumSplitEnumerator<T: CdcSourceTypeTrait> {
53    /// The `source_id` in the catalog
54    source_id: SourceId,
55    worker_node_addrs: Vec<HostAddr>,
56    metrics: Arc<EnumeratorMetrics>,
57    /// Properties specified in the WITH clause by user for database connection
58    properties: Arc<BTreeMap<String, String>>,
59    _phantom: PhantomData<T>,
60}
61
62#[async_trait]
63impl<T: CdcSourceTypeTrait> SplitEnumerator for DebeziumSplitEnumerator<T>
64where
65    Self: ListCdcSplits<CdcSourceType = T> + CdcMonitor,
66{
67    type Properties = CdcProperties<T>;
68    type Split = DebeziumCdcSplit<T>;
69
70    async fn new(
71        props: CdcProperties<T>,
72        context: SourceEnumeratorContextRef,
73    ) -> ConnectorResult<Self> {
74        let server_addrs = props
75            .properties
76            .get(DATABASE_SERVERS_KEY)
77            .map(|s| {
78                s.split(',')
79                    .map(HostAddr::from_str)
80                    .collect::<Result<Vec<_>, _>>()
81            })
82            .transpose()?
83            .unwrap_or_default();
84
85        assert_eq!(
86            props.get_source_type_pb(),
87            SourceType::from(T::source_type())
88        );
89
90        let jvm = Jvm::get_or_init()?;
91        let source_id = context.info.source_id;
92
93        // Extract fields before moving props
94        let source_type_pb = props.get_source_type_pb();
95
96        // Create Arc once and share it
97        let properties_arc = Arc::new(props.properties);
98        let properties_arc_for_validation = properties_arc.clone();
99        let table_schema_for_validation = props.table_schema;
100
101        tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
102            execute_with_jni_env(jvm, |env| {
103                let validate_source_request = ValidateSourceRequest {
104                    source_id: source_id.as_raw_id() as u64,
105                    source_type: source_type_pb as _,
106                    properties: (*properties_arc_for_validation).clone(),
107                    table_schema: Some(table_schema_exclude_additional_columns(
108                        &table_schema_for_validation,
109                    )),
110                    is_source_job: props.is_cdc_source_job,
111                    is_backfill_table: props.is_backfill_table,
112                };
113
114                let validate_source_request_bytes =
115                    env.byte_array_from_slice(&Message::encode_to_vec(&validate_source_request))?;
116
117                let validate_source_response_bytes = call_static_method!(
118                    env,
119                    {com.risingwave.connector.source.JniSourceValidateHandler},
120                    {byte[] validate(byte[] validateSourceRequestBytes)},
121                    &validate_source_request_bytes
122                )?;
123
124                let validate_source_response: ValidateSourceResponse = Message::decode(
125                    risingwave_jni_core::to_guarded_slice(&validate_source_response_bytes, env)?
126                        .deref(),
127                )?;
128
129                if let Some(error) = validate_source_response.error {
130                    return Err(
131                        anyhow!(error.error_message).context("source cannot pass validation")
132                    );
133                }
134
135                Ok(())
136            })
137        })
138        .await
139        .context("failed to validate source")??;
140
141        tracing::debug!("validate cdc source properties success");
142        Ok(Self {
143            source_id,
144            worker_node_addrs: server_addrs,
145            metrics: context.metrics.clone(),
146            properties: properties_arc,
147            _phantom: PhantomData,
148        })
149    }
150
151    async fn list_splits(&mut self) -> ConnectorResult<Vec<DebeziumCdcSplit<T>>> {
152        Ok(self.list_cdc_splits())
153    }
154
155    async fn on_tick(&mut self) -> ConnectorResult<()> {
156        self.monitor_cdc().await
157    }
158}
159
160impl<T: CdcSourceTypeTrait> DebeziumSplitEnumerator<T> {
161    fn sql_server_lsn_to_i64(lsn: &str) -> Option<i64> {
162        parse_sql_server_lsn_str(lsn).map(|v| v.min(i64::MAX as u128) as i64)
163    }
164
165    async fn monitor_postgres_confirmed_flush_lsn(&mut self) -> ConnectorResult<()> {
166        // Query upstream LSNs and update metrics.
167        match self.query_postgres_lsns().await {
168            Ok(Some((confirmed_flush_lsn, upstream_max_lsn, slot_name))) => {
169                let labels = [&self.source_id.to_string(), &slot_name.to_owned()];
170
171                self.metrics
172                    .pg_cdc_upstream_max_lsn
173                    .with_guarded_label_values(&labels)
174                    .set(upstream_max_lsn as i64);
175
176                if let Some(lsn) = confirmed_flush_lsn {
177                    self.metrics
178                        .pg_cdc_confirmed_flush_lsn
179                        .with_guarded_label_values(&labels)
180                        .set(lsn as i64);
181                    tracing::debug!(
182                        "Updated confirmed_flush_lsn for source {} slot {}: {}",
183                        self.source_id,
184                        slot_name,
185                        lsn
186                    );
187                } else {
188                    tracing::warn!(
189                        "confirmed_flush_lsn is NULL for source {} slot {}",
190                        self.source_id,
191                        slot_name
192                    );
193                }
194            }
195            Ok(None) => {
196                tracing::warn!(
197                    "No replication slot found when querying LSNs for source {}",
198                    self.source_id
199                );
200            }
201            Err(e) => {
202                tracing::error!(
203                    "Failed to query PostgreSQL LSNs for source {}: {}",
204                    self.source_id,
205                    e.as_report()
206                );
207            }
208        };
209        Ok(())
210    }
211
212    /// Query LSNs from PostgreSQL, return (`confirmed_flush_lsn`, `upstream_max_lsn`, `slot_name`).
213    async fn query_postgres_lsns(&self) -> ConnectorResult<Option<(Option<u64>, u64, &str)>> {
214        let pg_conn = pg_connection_config_from_properties(&self.properties)?;
215
216        let slot_name = self
217            .properties
218            .get("slot.name")
219            .ok_or_else(|| anyhow::anyhow!("slot.name not found in CDC properties"))?;
220
221        // No TCP keepalive for CDC enumerator
222        let client = create_pg_client(&pg_conn, None)
223            .await
224            .context("Failed to create PostgreSQL client")?;
225
226        let query = "SELECT confirmed_flush_lsn, pg_current_wal_lsn() \
227            FROM pg_replication_slots WHERE slot_name = $1";
228        let row = client
229            .query_opt(query, &[&slot_name])
230            .await
231            .context("PostgreSQL query LSNs error")?;
232        match row {
233            Some(row) => {
234                let confirmed_flush_lsn: Option<PgLsn> = row.get(0);
235                let upstream_max_lsn: PgLsn = row.get(1);
236                Ok(Some((
237                    confirmed_flush_lsn.map(Into::into),
238                    upstream_max_lsn.into(),
239                    slot_name.as_str(),
240                )))
241            }
242            None => {
243                tracing::warn!("No replication slot found with name: {}", slot_name);
244                Ok(None)
245            }
246        }
247    }
248
249    /// Query min/max LSNs from SQL Server CDC.
250    async fn query_sql_server_lsns(&self) -> ConnectorResult<Option<(String, String)>> {
251        let hostname = self
252            .properties
253            .get("hostname")
254            .ok_or_else(|| anyhow!("hostname not found in CDC properties"))?;
255        let port = self
256            .properties
257            .get("port")
258            .ok_or_else(|| anyhow!("port not found in CDC properties"))?
259            .parse::<u16>()
260            .context("failed to parse port as u16")?;
261        let username = self
262            .properties
263            .get("username")
264            .ok_or_else(|| anyhow!("username not found in CDC properties"))?;
265        let password = self
266            .properties
267            .get("password")
268            .ok_or_else(|| anyhow!("password not found in CDC properties"))?;
269        let database = self
270            .properties
271            .get("database.name")
272            .ok_or_else(|| anyhow!("database.name not found in CDC properties"))?;
273
274        let mut config = Config::new();
275        config.host(hostname);
276        config.port(port);
277        config.database(database);
278        config.authentication(tiberius::AuthMethod::sql_server(username, password));
279        config.trust_cert();
280
281        let mut client = SqlServerClient::new_with_config(config).await?;
282        let row = client
283            .inner_client
284            .simple_query(
285                "SELECT \
286                    sys.fn_cdc_get_max_lsn() AS max_lsn, \
287                    (SELECT MIN(sys.fn_cdc_get_min_lsn(capture_instance)) FROM cdc.change_tables) AS min_lsn"
288                    .to_owned(),
289            )
290            .await?
291            .into_row()
292            .await?
293            .ok_or_else(|| anyhow!("No result returned when querying SQL Server max/min LSN"))?;
294
295        let lsn_bytes_to_hex = |bytes: &[u8]| -> ConnectorResult<String> {
296            if bytes.len() != 10 {
297                return Err(anyhow!(
298                    "SQL Server LSN should be 10 bytes, got {} bytes",
299                    bytes.len()
300                )
301                .into());
302            }
303            let mut hex_string = String::with_capacity(22);
304            for byte in &bytes[0..4] {
305                hex_string.push_str(&format!("{:02x}", byte));
306            }
307            hex_string.push(':');
308            for byte in &bytes[4..8] {
309                hex_string.push_str(&format!("{:02x}", byte));
310            }
311            hex_string.push(':');
312            for byte in &bytes[8..10] {
313                hex_string.push_str(&format!("{:02x}", byte));
314            }
315            Ok(hex_string)
316        };
317
318        let max_lsn = row
319            .try_get::<&[u8], usize>(0)?
320            .map(lsn_bytes_to_hex)
321            .transpose()?
322            .ok_or_else(|| anyhow!("SQL Server max_lsn is NULL"))?;
323        let min_lsn = row
324            .try_get::<&[u8], usize>(1)?
325            .map(lsn_bytes_to_hex)
326            .transpose()?
327            .ok_or_else(|| anyhow!("SQL Server min_lsn is NULL"))?;
328
329        Ok(Some((min_lsn, max_lsn)))
330    }
331
332    async fn monitor_sql_server_lsns(&mut self) -> ConnectorResult<()> {
333        match self.query_sql_server_lsns().await {
334            Ok(Some((min_lsn, max_lsn))) => {
335                let source_id = self.source_id.to_string();
336
337                if let Some(value) = Self::sql_server_lsn_to_i64(&min_lsn) {
338                    self.metrics
339                        .sqlserver_cdc_upstream_min_lsn
340                        .with_guarded_label_values(&[&source_id])
341                        .set(value);
342                }
343
344                if let Some(value) = Self::sql_server_lsn_to_i64(&max_lsn) {
345                    self.metrics
346                        .sqlserver_cdc_upstream_max_lsn
347                        .with_guarded_label_values(&[&source_id])
348                        .set(value);
349                }
350            }
351            Ok(None) => {}
352            Err(e) => {
353                tracing::error!(
354                    "Failed to query SQL Server LSNs for source {}: {}",
355                    self.source_id,
356                    e.as_report()
357                );
358            }
359        }
360
361        Ok(())
362    }
363}
364
365pub trait ListCdcSplits {
366    type CdcSourceType: CdcSourceTypeTrait;
367    /// Generates a single split for shared source.
368    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>>;
369}
370
371/// Trait for CDC-specific monitoring behavior
372#[async_trait]
373pub trait CdcMonitor {
374    async fn monitor_cdc(&mut self) -> ConnectorResult<()>;
375}
376
377#[async_trait]
378impl<T: CdcSourceTypeTrait> CdcMonitor for DebeziumSplitEnumerator<T> {
379    default async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
380        Ok(())
381    }
382}
383
384impl DebeziumSplitEnumerator<Mysql> {
385    async fn monitor_mysql_binlog_files(&mut self) -> ConnectorResult<()> {
386        // Get hostname and port for metrics labels
387        let hostname = self
388            .properties
389            .get("hostname")
390            .map(|s| s.as_str())
391            .ok_or_else(|| {
392                anyhow::anyhow!("missing required property 'hostname' for MySQL CDC source")
393            })?;
394        let port = self
395            .properties
396            .get("port")
397            .map(|s| s.as_str())
398            .ok_or_else(|| {
399                anyhow::anyhow!("missing required property 'port' for MySQL CDC source")
400            })?;
401
402        // Query binlog files and update metrics
403        match self.query_binlog_files().await {
404            Ok(binlog_files) => {
405                if let Some((oldest_file, oldest_size)) = binlog_files.first()
406                    && let Some(seq) = extract_binlog_file_seq(oldest_file)
407                {
408                    self.metrics
409                        .mysql_cdc_binlog_file_seq_min
410                        .with_guarded_label_values(&[hostname, port])
411                        .set(seq as i64);
412                    tracing::debug!(
413                        "MySQL CDC source {} ({}:{}): oldest binlog = {}, seq = {}, size = {}",
414                        self.source_id,
415                        hostname,
416                        port,
417                        oldest_file,
418                        seq,
419                        oldest_size
420                    );
421                }
422                if let Some((newest_file, newest_size)) = binlog_files.last()
423                    && let Some(seq) = extract_binlog_file_seq(newest_file)
424                {
425                    self.metrics
426                        .mysql_cdc_binlog_file_seq_max
427                        .with_guarded_label_values(&[hostname, port])
428                        .set(seq as i64);
429                    tracing::debug!(
430                        "MySQL CDC source {} ({}:{}): newest binlog = {}, seq = {}, size = {}",
431                        self.source_id,
432                        hostname,
433                        port,
434                        newest_file,
435                        seq,
436                        newest_size
437                    );
438                }
439                tracing::debug!(
440                    "MySQL CDC source {} ({}:{}): total {} binlog files",
441                    self.source_id,
442                    hostname,
443                    port,
444                    binlog_files.len()
445                );
446            }
447            Err(e) => {
448                tracing::error!(
449                    "Failed to query binlog files for MySQL CDC source {} ({}:{}): {}",
450                    self.source_id,
451                    hostname,
452                    port,
453                    e.as_report()
454                );
455            }
456        }
457        Ok(())
458    }
459
460    /// Query binlog files from MySQL, returns Vec<(filename, size)>
461    async fn query_binlog_files(&self) -> ConnectorResult<Vec<(String, u64)>> {
462        // Extract connection parameters from CDC properties
463        let hostname = self
464            .properties
465            .get("hostname")
466            .ok_or_else(|| anyhow::anyhow!("hostname not found in CDC properties"))?;
467        let port = self
468            .properties
469            .get("port")
470            .ok_or_else(|| anyhow::anyhow!("port not found in CDC properties"))?
471            .parse::<u16>()
472            .context("failed to parse port as u16")?;
473        let username = self
474            .properties
475            .get("username")
476            .ok_or_else(|| anyhow::anyhow!("username not found in CDC properties"))?;
477        let password = self
478            .properties
479            .get("password")
480            .ok_or_else(|| anyhow::anyhow!("password not found in CDC properties"))?;
481        let database = self
482            .properties
483            .get("database.name")
484            .ok_or_else(|| anyhow::anyhow!("database.name not found in CDC properties"))?;
485
486        // Get SSL mode configuration (default to Disabled if not specified)
487        let ssl_mode = self
488            .properties
489            .get("ssl.mode")
490            .and_then(|s| s.parse().ok())
491            .unwrap_or(SslMode::Preferred);
492
493        // Build MySQL connection pool with proper SSL configuration
494        let pool =
495            build_mysql_connection_pool(hostname, port, username, password, database, ssl_mode);
496        let mut conn = pool
497            .get_conn()
498            .await
499            .context("Failed to connect to MySQL")?;
500
501        // Query binlog files using SHOW BINARY LOGS.
502        // MySQL 8.0+ may return 3 columns (Log_name, File_size, Encrypted), while some variants
503        // only return the first 2. Decode the row manually so we don't panic on column-count
504        // differences.
505        let rows: Vec<Row> = conn
506            .query("SHOW BINARY LOGS")
507            .await
508            .context("Failed to execute SHOW BINARY LOGS")?;
509        let query_result = rows
510            .into_iter()
511            .map(|mut row| -> ConnectorResult<(String, u64)> {
512                let log_name = row
513                    .take_opt::<String, _>(0)
514                    .transpose()
515                    .context("SHOW BINARY LOGS: failed to decode Log_name")?
516                    .ok_or_else(|| anyhow!("SHOW BINARY LOGS: missing Log_name column"))?;
517                let file_size = row
518                    .take_opt::<u64, _>(1)
519                    .transpose()
520                    .context("SHOW BINARY LOGS: failed to decode File_size")?
521                    .ok_or_else(|| anyhow!("SHOW BINARY LOGS: missing File_size column"))?;
522                Ok((log_name, file_size))
523            })
524            .collect::<ConnectorResult<Vec<_>>>()?;
525
526        drop(conn);
527        pool.disconnect().await.ok();
528
529        Ok(query_result)
530    }
531}
532
533impl ListCdcSplits for DebeziumSplitEnumerator<Mysql> {
534    type CdcSourceType = Mysql;
535
536    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
537        // CDC source only supports single split
538        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
539            self.source_id.as_raw_id(),
540            None,
541            None,
542        )]
543    }
544}
545
546#[async_trait]
547impl CdcMonitor for DebeziumSplitEnumerator<Mysql> {
548    async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
549        // For MySQL CDC, query the upstream MySQL binlog files and monitor them.
550        self.monitor_mysql_binlog_files().await?;
551        Ok(())
552    }
553}
554
555impl ListCdcSplits for DebeziumSplitEnumerator<Postgres> {
556    type CdcSourceType = Postgres;
557
558    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
559        // CDC source only supports single split
560        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
561            self.source_id.as_raw_id(),
562            None,
563            None,
564        )]
565    }
566}
567
568#[async_trait]
569impl CdcMonitor for DebeziumSplitEnumerator<Postgres> {
570    async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
571        // For PostgreSQL CDC, query the upstream Postgres confirmed flush lsn and monitor it.
572        self.monitor_postgres_confirmed_flush_lsn().await?;
573        Ok(())
574    }
575}
576
577impl ListCdcSplits for DebeziumSplitEnumerator<Citus> {
578    type CdcSourceType = Citus;
579
580    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
581        self.worker_node_addrs
582            .iter()
583            .enumerate()
584            .map(|(id, addr)| {
585                DebeziumCdcSplit::<Self::CdcSourceType>::new(
586                    id as u32,
587                    None,
588                    Some(addr.to_string()),
589                )
590            })
591            .collect_vec()
592    }
593}
594impl ListCdcSplits for DebeziumSplitEnumerator<Mongodb> {
595    type CdcSourceType = Mongodb;
596
597    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
598        // CDC source only supports single split
599        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
600            self.source_id.as_raw_id(),
601            None,
602            None,
603        )]
604    }
605}
606
607impl ListCdcSplits for DebeziumSplitEnumerator<SqlServer> {
608    type CdcSourceType = SqlServer;
609
610    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
611        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
612            self.source_id.as_raw_id(),
613            None,
614            None,
615        )]
616    }
617}
618
619#[async_trait]
620impl CdcMonitor for DebeziumSplitEnumerator<SqlServer> {
621    async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
622        self.monitor_sql_server_lsns().await
623    }
624}