Skip to main content

risingwave_connector/source/cdc/external/
postgres.rs

1// Copyright 2023 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::cmp::Ordering;
16use std::sync::LazyLock;
17
18use anyhow::Context;
19use futures::stream::BoxStream;
20use futures::{StreamExt, pin_mut};
21use futures_async_stream::{for_await, try_stream};
22use itertools::Itertools;
23use risingwave_common::catalog::{Field, Schema};
24use risingwave_common::log::LogSuppressor;
25use risingwave_common::row::{OwnedRow, Row};
26use risingwave_common::types::{DataType, Datum, ScalarImpl, ToOwnedDatum};
27use risingwave_common::util::iter_util::ZipEqFast;
28use serde::{Deserialize, Serialize};
29use thiserror_ext::AsReport;
30use tokio_postgres::types::{PgLsn, Type as PgType};
31
32use crate::connector_common::create_pg_client;
33use crate::connector_common::postgres::postgres_point_type;
34use crate::error::{ConnectorError, ConnectorResult};
35use crate::parser::scalar_adapter::ScalarAdapter;
36use crate::parser::{
37    postgres_cell_to_scalar_impl_strict, postgres_row_to_owned_row_with_strict_pk,
38};
39use crate::source::CdcTableSnapshotSplit;
40use crate::source::cdc::external::{
41    CDC_TABLE_SPLIT_ID_START, CdcOffset, CdcOffsetParseFunc, CdcTableSnapshotSplitOption,
42    DebeziumOffset, ExternalTableConfig, ExternalTableReader, SchemaTableName,
43};
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct PostgresOffset {
47    pub txid: i64,
48    // In postgres, an LSN is a 64-bit integer, representing a byte position in the write-ahead log stream.
49    // It is printed as two hexadecimal numbers of up to 8 digits each, separated by a slash; for example, 16/B374D848
50    pub lsn: u64,
51    // Additional LSN fields for improved tracking
52    #[serde(default)]
53    pub lsn_commit: Option<u64>,
54    #[serde(default)]
55    pub lsn_proc: Option<u64>,
56}
57
58impl PartialOrd for PostgresOffset {
59    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
60        Some(self.cmp(other))
61    }
62}
63
64impl Eq for PostgresOffset {}
65impl PartialEq for PostgresOffset {
66    fn eq(&self, other: &Self) -> bool {
67        match (
68            self.lsn_commit,
69            self.lsn_proc,
70            other.lsn_commit,
71            other.lsn_proc,
72        ) {
73            (_, Some(_), _, Some(_)) => {
74                self.lsn_commit == other.lsn_commit && self.lsn_proc == other.lsn_proc
75            }
76            _ => self.lsn == other.lsn,
77        }
78    }
79}
80
81// only compare the lsn field, prefer lsn_commit and lsn_proc if both available
82impl Ord for PostgresOffset {
83    fn cmp(&self, other: &Self) -> Ordering {
84        match (
85            self.lsn_commit,
86            self.lsn_proc,
87            other.lsn_commit,
88            other.lsn_proc,
89        ) {
90            (_, Some(self_proc), _, Some(other_proc)) => {
91                // if both have `lsn_commit` and `lsn_proc`, compare `lsn_commit` first, then `lsn_proc`
92                // if `lsn_commit` is None, fall back to `lsn_proc`
93                match self.lsn_commit.cmp(&other.lsn_commit) {
94                    Ordering::Equal => self_proc.cmp(&other_proc),
95                    other_result => other_result,
96                }
97            }
98            _ => {
99                // Fall back to lsn comparison when either lsn_commit or lsn_proc is missing
100                static LOG_SUPPRESSOR: LazyLock<LogSuppressor> =
101                    LazyLock::new(LogSuppressor::default);
102                if let Ok(suppressed_count) = LOG_SUPPRESSOR.check() {
103                    tracing::warn!(
104                        suppressed_count,
105                        self_lsn = self.lsn,
106                        other_lsn = other.lsn,
107                        "lsn_commit and lsn_proc are missing, fall back to lsn comparison"
108                    );
109                }
110                self.lsn.cmp(&other.lsn)
111            }
112        }
113    }
114}
115
116impl PostgresOffset {
117    pub fn parse_debezium_offset(offset: &str) -> ConnectorResult<Self> {
118        let dbz_offset: DebeziumOffset = serde_json::from_str(offset)
119            .with_context(|| format!("invalid upstream offset: {}", offset))?;
120
121        let lsn = dbz_offset
122            .source_offset
123            .lsn
124            .context("invalid postgres lsn")?;
125
126        // `lsn_commit` may not be present in the offset for the first tx.
127        let lsn_commit = dbz_offset.source_offset.lsn_commit;
128
129        let lsn_proc = dbz_offset
130            .source_offset
131            .lsn_proc
132            .context("invalid postgres lsn_proc")?;
133
134        Ok(Self {
135            txid: dbz_offset
136                .source_offset
137                .txid
138                .context("invalid postgres txid")?,
139            lsn,
140            lsn_commit,
141            lsn_proc: Some(lsn_proc),
142        })
143    }
144}
145
146pub struct PostgresExternalTableReader {
147    rw_schema: Schema,
148    field_names: String,
149    pk_indices: Vec<usize>,
150    client: tokio::sync::Mutex<tokio_postgres::Client>,
151    schema_table_name: SchemaTableName,
152}
153
154impl ExternalTableReader for PostgresExternalTableReader {
155    async fn current_cdc_offset(&self) -> ConnectorResult<CdcOffset> {
156        let mut client = self.client.lock().await;
157        // start a transaction to read current lsn and txid
158        let trxn = client.transaction().await?;
159        let row = trxn.query_one("SELECT pg_current_wal_lsn()", &[]).await?;
160        let mut pg_offset = PostgresOffset::default();
161        let pg_lsn = row.get::<_, PgLsn>(0);
162        tracing::debug!("current lsn: {}", pg_lsn);
163        pg_offset.lsn = pg_lsn.into();
164
165        let txid_row = trxn.query_one("SELECT txid_current()", &[]).await?;
166        let txid: i64 = txid_row.get::<_, i64>(0);
167        pg_offset.txid = txid;
168
169        // commit the transaction
170        trxn.commit().await?;
171
172        Ok(CdcOffset::Postgres(pg_offset))
173    }
174
175    fn snapshot_read(
176        &self,
177        table_name: SchemaTableName,
178        start_pk: Option<OwnedRow>,
179        primary_keys: Vec<String>,
180        limit: u32,
181    ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
182        assert_eq!(table_name, self.schema_table_name);
183        self.snapshot_read_inner(table_name, start_pk, primary_keys, limit)
184    }
185
186    #[try_stream(boxed, ok = CdcTableSnapshotSplit, error = ConnectorError)]
187    async fn get_parallel_cdc_splits(&self, options: CdcTableSnapshotSplitOption) {
188        let backfill_num_rows_per_split = options.backfill_num_rows_per_split;
189        if backfill_num_rows_per_split == 0 {
190            return Err(anyhow::anyhow!(
191                "invalid backfill_num_rows_per_split, must be greater than 0"
192            )
193            .into());
194        }
195        if options.backfill_split_pk_column_index as usize >= self.pk_indices.len() {
196            return Err(anyhow::anyhow!(format!(
197                "invalid backfill_split_pk_column_index {}, out of bound",
198                options.backfill_split_pk_column_index
199            ))
200            .into());
201        }
202        let split_column = self.split_column(&options);
203        let row_stream = if options.backfill_as_even_splits
204            && is_supported_even_split_data_type(&split_column.data_type)
205        {
206            // For certain types, use evenly-sized partition to optimize performance.
207            tracing::info!(?self.schema_table_name, ?self.rw_schema, ?self.pk_indices, ?split_column, "Get parallel cdc table snapshot even splits.");
208            self.as_even_splits(options)
209        } else {
210            tracing::info!(?self.schema_table_name, ?self.rw_schema, ?self.pk_indices, ?split_column, "Get parallel cdc table snapshot uneven splits.");
211            self.as_uneven_splits(options)
212        };
213        pin_mut!(row_stream);
214        #[for_await]
215        for row in row_stream {
216            let row = row?;
217            yield row;
218        }
219    }
220
221    fn split_snapshot_read(
222        &self,
223        table_name: SchemaTableName,
224        left: OwnedRow,
225        right: OwnedRow,
226        split_columns: Vec<Field>,
227    ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
228        assert_eq!(table_name, self.schema_table_name);
229        self.split_snapshot_read_inner(table_name, left, right, split_columns)
230    }
231}
232
233impl PostgresExternalTableReader {
234    pub async fn new(
235        config: ExternalTableConfig,
236        rw_schema: Schema,
237        pk_indices: Vec<usize>,
238        schema_table_name: SchemaTableName,
239        table_id: u32,
240    ) -> ConnectorResult<Self> {
241        tracing::info!(
242            ?rw_schema,
243            ?pk_indices,
244            "create postgres external table reader"
245        );
246        // No TCP keepalive for CDC source
247        let application_name = format!(
248            "risingwave-postgres-source-reader-{}-{}.{}",
249            table_id, schema_table_name.schema_name, schema_table_name.table_name
250        );
251        let client = create_pg_client(
252            &config.pg_connection_config()?,
253            None,
254            Some(&application_name),
255        )
256        .await?;
257
258        // Discover user-defined composite columns and arrays of composites.
259        // tokio-postgres cannot decode composite values natively, so for these
260        // columns we cast to text in the snapshot SELECT. Scalar composites
261        // become `(a,b,c)`; arrays become `text[]` so tokio-postgres can
262        // decode them directly as `Vec<Option<String>>` matching the RW
263        // `List(Varchar)` column. Other varchar columns stay as-is to
264        // preserve RW's own text rendering (e.g. numeric -> "NaN").
265        let composite_columns = client
266            .query(
267                "SELECT a.attname, (t.typcategory = 'A') AS is_array \
268                 FROM pg_attribute a \
269                 JOIN pg_class c ON a.attrelid = c.oid \
270                 JOIN pg_namespace n ON c.relnamespace = n.oid \
271                 JOIN pg_type t ON a.atttypid = t.oid \
272                 LEFT JOIN pg_type t_elem ON t.typelem = t_elem.oid \
273                 WHERE n.nspname = $1 \
274                   AND c.relname = $2 \
275                   AND a.attnum > 0 \
276                   AND NOT a.attisdropped \
277                   AND (t.typtype = 'c' \
278                        OR (t.typcategory = 'A' AND t_elem.typtype = 'c'))",
279                &[
280                    &schema_table_name.schema_name,
281                    &schema_table_name.table_name,
282                ],
283            )
284            .await
285            .map(|rows| {
286                rows.into_iter()
287                    .map(|row| (row.get::<_, String>(0), row.get::<_, bool>(1)))
288                    .collect::<std::collections::HashMap<_, _>>()
289            })
290            .unwrap_or_else(|err| {
291                tracing::warn!(
292                    error = %err.as_report(),
293                    schema = %schema_table_name.schema_name,
294                    table = %schema_table_name.table_name,
295                    "failed to discover postgres composite columns; falling back to no text cast"
296                );
297                std::collections::HashMap::new()
298            });
299
300        let field_names = rw_schema
301            .fields
302            .iter()
303            .map(|f| {
304                let quoted = Self::quote_column(&f.name);
305                match composite_columns.get(&f.name) {
306                    Some(false) if matches!(f.data_type, DataType::Varchar) => {
307                        format!("{quoted}::text AS {quoted}")
308                    }
309                    Some(true) if matches!(f.data_type, DataType::List(_)) => {
310                        format!(
311                            "CASE WHEN {quoted} IS NULL THEN NULL \
312                             ELSE ARRAY(SELECT t::text FROM unnest({quoted}) t)::text[] \
313                             END AS {quoted}"
314                        )
315                    }
316                    _ => quoted,
317                }
318            })
319            .join(",");
320
321        Ok(Self {
322            rw_schema,
323            field_names,
324            pk_indices,
325            client: tokio::sync::Mutex::new(client),
326            schema_table_name,
327        })
328    }
329
330    pub fn get_normalized_table_name(table_name: &SchemaTableName) -> String {
331        format!(
332            "\"{}\".\"{}\"",
333            table_name.schema_name, table_name.table_name
334        )
335    }
336
337    pub fn get_cdc_offset_parser() -> CdcOffsetParseFunc {
338        Box::new(move |offset| {
339            Ok(CdcOffset::Postgres(PostgresOffset::parse_debezium_offset(
340                offset,
341            )?))
342        })
343    }
344
345    #[try_stream(boxed, ok = OwnedRow, error = ConnectorError)]
346    async fn snapshot_read_inner(
347        &self,
348        table_name: SchemaTableName,
349        start_pk_row: Option<OwnedRow>,
350        primary_keys: Vec<String>,
351        scan_limit: u32,
352    ) {
353        let order_key = Self::get_order_key(&primary_keys);
354        let client = self.client.lock().await;
355        client.execute("set time zone '+00:00'", &[]).await?;
356
357        let stream = match start_pk_row {
358            Some(ref pk_row) => {
359                // prepare the scan statement, since we may need to convert the RW data type to postgres data type
360                // e.g. varchar to uuid
361                let prepared_scan_stmt = {
362                    let primary_keys = self
363                        .pk_indices
364                        .iter()
365                        .map(|i| self.rw_schema.fields[*i].name.clone())
366                        .collect_vec();
367
368                    let order_key = Self::get_order_key(&primary_keys);
369                    let scan_sql = format!(
370                        "SELECT {} FROM {} WHERE {} ORDER BY {} LIMIT {scan_limit}",
371                        self.field_names,
372                        Self::get_normalized_table_name(&table_name),
373                        Self::filter_expression(&primary_keys),
374                        order_key,
375                    );
376                    client.prepare(&scan_sql).await?
377                };
378
379                let params: Vec<Option<ScalarAdapter>> = pk_row
380                    .iter()
381                    .zip_eq_fast(prepared_scan_stmt.params())
382                    .map(|(datum, ty)| {
383                        datum
384                            .map(|scalar| ScalarAdapter::from_scalar(scalar, ty))
385                            .transpose()
386                    })
387                    .try_collect()?;
388
389                client.query_raw(&prepared_scan_stmt, &params).await?
390            }
391            None => {
392                let sql = format!(
393                    "SELECT {} FROM {} ORDER BY {} LIMIT {scan_limit}",
394                    self.field_names,
395                    Self::get_normalized_table_name(&table_name),
396                    order_key,
397                );
398                let params: Vec<Option<ScalarAdapter>> = vec![];
399                client.query_raw(&sql, &params).await?
400            }
401        };
402
403        let row_stream = stream.map(|row| {
404            let row = row?;
405            postgres_row_to_owned_row_with_strict_pk(row, &self.rw_schema, &self.pk_indices)
406                .map_err(ConnectorError::from)
407        });
408
409        pin_mut!(row_stream);
410        #[for_await]
411        for row in row_stream {
412            let row = row?;
413            yield row;
414        }
415    }
416
417    // row filter expression: (v1, v2, v3) > ($1, $2, $3)
418    fn filter_expression(columns: &[String]) -> String {
419        let mut col_expr = String::new();
420        let mut arg_expr = String::new();
421        for (i, column) in columns.iter().enumerate() {
422            if i > 0 {
423                col_expr.push_str(", ");
424                arg_expr.push_str(", ");
425            }
426            col_expr.push_str(&Self::quote_column(column));
427            arg_expr.push_str(format!("${}", i + 1).as_str());
428        }
429        format!("({}) > ({})", col_expr, arg_expr)
430    }
431
432    // row filter expression: (v1, v2, v3) >= ($1, $2, $3) AND (v1, v2, v3) < ($1, $2, $3)
433    fn split_filter_expression(
434        columns: &[String],
435        is_first_split: bool,
436        is_last_split: bool,
437    ) -> String {
438        let mut left_col_expr = String::new();
439        let mut left_arg_expr = String::new();
440        let mut right_col_expr = String::new();
441        let mut right_arg_expr = String::new();
442        let mut c = 1;
443        if !is_first_split {
444            for (i, column) in columns.iter().enumerate() {
445                if i > 0 {
446                    left_col_expr.push_str(", ");
447                    left_arg_expr.push_str(", ");
448                }
449                left_col_expr.push_str(&Self::quote_column(column));
450                left_arg_expr.push_str(format!("${}", c).as_str());
451                c += 1;
452            }
453        }
454        if !is_last_split {
455            for (i, column) in columns.iter().enumerate() {
456                if i > 0 {
457                    right_col_expr.push_str(", ");
458                    right_arg_expr.push_str(", ");
459                }
460                right_col_expr.push_str(&Self::quote_column(column));
461                right_arg_expr.push_str(format!("${}", c).as_str());
462                c += 1;
463            }
464        }
465        if is_first_split && is_last_split {
466            "1 = 1".to_owned()
467        } else if is_first_split {
468            format!("({}) < ({})", right_col_expr, right_arg_expr,)
469        } else if is_last_split {
470            format!("({}) >= ({})", left_col_expr, left_arg_expr,)
471        } else {
472            format!(
473                "({}) >= ({}) AND ({}) < ({})",
474                left_col_expr, left_arg_expr, right_col_expr, right_arg_expr,
475            )
476        }
477    }
478
479    fn get_order_key(primary_keys: &Vec<String>) -> String {
480        primary_keys
481            .iter()
482            .map(|col| Self::quote_column(col))
483            .join(",")
484    }
485
486    fn quote_column(column: &str) -> String {
487        format!("\"{}\"", column)
488    }
489
490    async fn min_and_max(
491        &self,
492        split_column: &Field,
493    ) -> ConnectorResult<Option<(ScalarImpl, ScalarImpl)>> {
494        let sql = format!(
495            "SELECT MIN({}), MAX({}) FROM {}",
496            split_column.name,
497            split_column.name,
498            Self::get_normalized_table_name(&self.schema_table_name),
499        );
500        let client = self.client.lock().await;
501        let rows = client.query(&sql, &[]).await?;
502        if rows.is_empty() {
503            Ok(None)
504        } else {
505            let row = &rows[0];
506            let min = postgres_cell_to_scalar_impl_strict(
507                row,
508                &split_column.data_type,
509                0,
510                &split_column.name,
511            )?;
512            let max = postgres_cell_to_scalar_impl_strict(
513                row,
514                &split_column.data_type,
515                1,
516                &split_column.name,
517            )?;
518            match (min, max) {
519                (Some(min), Some(max)) => Ok(Some((min, max))),
520                _ => Ok(None),
521            }
522        }
523    }
524
525    async fn next_split_right_bound_exclusive(
526        &self,
527        left_value: &ScalarImpl,
528        max_value: &ScalarImpl,
529        max_split_size: u64,
530        split_column: &Field,
531    ) -> ConnectorResult<Option<Datum>> {
532        let sql = format!(
533            "WITH t as (SELECT {} FROM {} WHERE {} >= $1 ORDER BY {} ASC LIMIT {}) SELECT CASE WHEN MAX({}) < $2 THEN MAX({}) ELSE NULL END FROM t",
534            Self::quote_column(&split_column.name),
535            Self::get_normalized_table_name(&self.schema_table_name),
536            Self::quote_column(&split_column.name),
537            Self::quote_column(&split_column.name),
538            max_split_size,
539            Self::quote_column(&split_column.name),
540            Self::quote_column(&split_column.name),
541        );
542        let client = self.client.lock().await;
543        let prepared_stmt = client.prepare(&sql).await?;
544        let params: Vec<Option<ScalarAdapter>> = vec![
545            Some(ScalarAdapter::from_scalar(
546                left_value.as_scalar_ref_impl(),
547                &prepared_stmt.params()[0],
548            )?),
549            Some(ScalarAdapter::from_scalar(
550                max_value.as_scalar_ref_impl(),
551                &prepared_stmt.params()[1],
552            )?),
553        ];
554        let stream = client.query_raw(&prepared_stmt, &params).await?;
555        let datum_stream = stream.map(|row| {
556            let row = row?;
557            Ok::<_, ConnectorError>(postgres_cell_to_scalar_impl_strict(
558                &row,
559                &split_column.data_type,
560                0,
561                &split_column.name,
562            )?)
563        });
564        pin_mut!(datum_stream);
565        #[for_await]
566        for datum in datum_stream {
567            let right = datum?;
568            return Ok(Some(right.to_owned_datum()));
569        }
570        Ok(None)
571    }
572
573    async fn next_greater_bound(
574        &self,
575        start_offset: &ScalarImpl,
576        max_value: &ScalarImpl,
577        split_column: &Field,
578    ) -> ConnectorResult<Option<Datum>> {
579        let sql = format!(
580            "SELECT MIN({}) FROM {} WHERE {} > $1 AND {} <$2",
581            Self::quote_column(&split_column.name),
582            Self::get_normalized_table_name(&self.schema_table_name),
583            Self::quote_column(&split_column.name),
584            Self::quote_column(&split_column.name),
585        );
586        let client = self.client.lock().await;
587        let prepared_stmt = client.prepare(&sql).await?;
588        let params: Vec<Option<ScalarAdapter>> = vec![
589            Some(ScalarAdapter::from_scalar(
590                start_offset.as_scalar_ref_impl(),
591                &prepared_stmt.params()[0],
592            )?),
593            Some(ScalarAdapter::from_scalar(
594                max_value.as_scalar_ref_impl(),
595                &prepared_stmt.params()[1],
596            )?),
597        ];
598        let stream = client.query_raw(&prepared_stmt, &params).await?;
599        let datum_stream = stream.map(|row| {
600            let row = row?;
601            Ok::<_, ConnectorError>(postgres_cell_to_scalar_impl_strict(
602                &row,
603                &split_column.data_type,
604                0,
605                &split_column.name,
606            )?)
607        });
608        pin_mut!(datum_stream);
609        #[for_await]
610        for datum in datum_stream {
611            let right = datum?;
612            return Ok(Some(right));
613        }
614        Ok(None)
615    }
616
617    #[try_stream(boxed, ok = OwnedRow, error = ConnectorError)]
618    async fn split_snapshot_read_inner(
619        &self,
620        table_name: SchemaTableName,
621        left: OwnedRow,
622        right: OwnedRow,
623        split_columns: Vec<Field>,
624    ) {
625        assert_eq!(
626            split_columns.len(),
627            1,
628            "multiple split columns is not supported yet"
629        );
630        assert_eq!(left.len(), 1, "multiple split columns is not supported yet");
631        assert_eq!(
632            right.len(),
633            1,
634            "multiple split columns is not supported yet"
635        );
636        let is_first_split = left[0].is_none();
637        let is_last_split = right[0].is_none();
638        let split_column_names = split_columns.iter().map(|c| c.name.clone()).collect_vec();
639        let client = self.client.lock().await;
640        client.execute("set time zone '+00:00'", &[]).await?;
641        // prepare the scan statement, since we may need to convert the RW data type to postgres data type
642        // e.g. varchar to uuid
643        let prepared_scan_stmt = {
644            let scan_sql = format!(
645                "SELECT {} FROM {} WHERE {}",
646                self.field_names,
647                Self::get_normalized_table_name(&table_name),
648                Self::split_filter_expression(&split_column_names, is_first_split, is_last_split),
649            );
650            client.prepare(&scan_sql).await?
651        };
652
653        let mut params: Vec<Option<ScalarAdapter>> = vec![];
654        if !is_first_split {
655            let left_params: Vec<Option<ScalarAdapter>> = left
656                .iter()
657                .zip_eq_fast(prepared_scan_stmt.params().iter().take(left.len()))
658                .map(|(datum, ty)| {
659                    datum
660                        .map(|scalar| ScalarAdapter::from_scalar(scalar, ty))
661                        .transpose()
662                })
663                .try_collect()?;
664            params.extend(left_params);
665        }
666        if !is_last_split {
667            let right_params: Vec<Option<ScalarAdapter>> = right
668                .iter()
669                .zip_eq_fast(prepared_scan_stmt.params().iter().skip(params.len()))
670                .map(|(datum, ty)| {
671                    datum
672                        .map(|scalar| ScalarAdapter::from_scalar(scalar, ty))
673                        .transpose()
674                })
675                .try_collect()?;
676            params.extend(right_params);
677        }
678
679        let stream = client.query_raw(&prepared_scan_stmt, &params).await?;
680        let row_stream = stream.map(|row| {
681            let row = row?;
682            postgres_row_to_owned_row_with_strict_pk(row, &self.rw_schema, &self.pk_indices)
683                .map_err(ConnectorError::from)
684        });
685
686        pin_mut!(row_stream);
687        #[for_await]
688        for row in row_stream {
689            let row = row?;
690            yield row;
691        }
692    }
693
694    #[try_stream(boxed, ok = CdcTableSnapshotSplit, error = ConnectorError)]
695    async fn as_uneven_splits(&self, options: CdcTableSnapshotSplitOption) {
696        let split_column = self.split_column(&options);
697        let mut split_id = CDC_TABLE_SPLIT_ID_START;
698        let Some((min_value, max_value)) = self.min_and_max(&split_column).await? else {
699            let left_bound_row = OwnedRow::new(vec![None]);
700            let right_bound_row = OwnedRow::new(vec![None]);
701            let split = CdcTableSnapshotSplit {
702                split_id,
703                left_bound_inclusive: left_bound_row,
704                right_bound_exclusive: right_bound_row,
705            };
706            yield split;
707            return Ok(());
708        };
709        // left bound will never be NULL value.
710        let mut next_left_bound_inclusive = min_value.clone();
711        loop {
712            let left_bound_inclusive: Datum = if next_left_bound_inclusive == min_value {
713                None
714            } else {
715                Some(next_left_bound_inclusive.clone())
716            };
717            let right_bound_exclusive;
718            let mut next_right = self
719                .next_split_right_bound_exclusive(
720                    &next_left_bound_inclusive,
721                    &max_value,
722                    options.backfill_num_rows_per_split,
723                    &split_column,
724                )
725                .await?;
726            if let Some(Some(ref inner)) = next_right
727                && *inner == next_left_bound_inclusive
728            {
729                next_right = self
730                    .next_greater_bound(&next_left_bound_inclusive, &max_value, &split_column)
731                    .await?;
732            }
733            if let Some(next_right) = next_right {
734                match next_right {
735                    None => {
736                        // NULL found.
737                        right_bound_exclusive = None;
738                    }
739                    Some(next_right) => {
740                        next_left_bound_inclusive = next_right.clone();
741                        right_bound_exclusive = Some(next_right);
742                    }
743                }
744            } else {
745                // Not found.
746                right_bound_exclusive = None;
747            };
748            let is_completed = right_bound_exclusive.is_none();
749            if is_completed && left_bound_inclusive.is_none() {
750                assert_eq!(split_id, 1);
751            }
752            tracing::info!(
753                split_id,
754                ?left_bound_inclusive,
755                ?right_bound_exclusive,
756                "New CDC table snapshot split."
757            );
758            let left_bound_row = OwnedRow::new(vec![left_bound_inclusive]);
759            let right_bound_row = OwnedRow::new(vec![right_bound_exclusive]);
760            let split = CdcTableSnapshotSplit {
761                split_id,
762                left_bound_inclusive: left_bound_row,
763                right_bound_exclusive: right_bound_row,
764            };
765            try_increase_split_id(&mut split_id)?;
766            yield split;
767            if is_completed {
768                break;
769            }
770        }
771    }
772
773    #[try_stream(boxed, ok = CdcTableSnapshotSplit, error = ConnectorError)]
774    async fn as_even_splits(&self, options: CdcTableSnapshotSplitOption) {
775        let split_column = self.split_column(&options);
776        let mut split_id = 1;
777        let Some((min_value, max_value)) = self.min_and_max(&split_column).await? else {
778            let left_bound_row = OwnedRow::new(vec![None]);
779            let right_bound_row = OwnedRow::new(vec![None]);
780            let split = CdcTableSnapshotSplit {
781                split_id,
782                left_bound_inclusive: left_bound_row,
783                right_bound_exclusive: right_bound_row,
784            };
785            yield split;
786            return Ok(());
787        };
788        let min_value = min_value.as_integral();
789        let max_value = max_value.as_integral();
790        let saturated_split_max_size = options
791            .backfill_num_rows_per_split
792            .try_into()
793            .unwrap_or(i64::MAX);
794        let mut left = None;
795        let mut right = Some(min_value.saturating_add(saturated_split_max_size));
796        loop {
797            let mut is_completed = false;
798            if right.as_ref().map(|r| *r >= max_value).unwrap_or(true) {
799                right = None;
800                is_completed = true;
801            }
802            let split = CdcTableSnapshotSplit {
803                split_id,
804                left_bound_inclusive: OwnedRow::new(vec![
805                    left.map(|l| to_int_scalar(l, &split_column.data_type)),
806                ]),
807                right_bound_exclusive: OwnedRow::new(vec![
808                    right.map(|r| to_int_scalar(r, &split_column.data_type)),
809                ]),
810            };
811            try_increase_split_id(&mut split_id)?;
812            yield split;
813            if is_completed {
814                break;
815            }
816            left = right;
817            right = left.map(|l| l.saturating_add(saturated_split_max_size));
818        }
819    }
820
821    fn split_column(&self, options: &CdcTableSnapshotSplitOption) -> Field {
822        self.rw_schema.fields[self.pk_indices[options.backfill_split_pk_column_index as usize]]
823            .clone()
824    }
825}
826
827fn to_int_scalar(i: i64, data_type: &DataType) -> ScalarImpl {
828    match data_type {
829        DataType::Int16 => ScalarImpl::Int16(i.try_into().unwrap()),
830        DataType::Int32 => ScalarImpl::Int32(i.try_into().unwrap()),
831        DataType::Int64 => ScalarImpl::Int64(i),
832        _ => {
833            panic!("Can't convert int {} to ScalarImpl::{}", i, data_type)
834        }
835    }
836}
837
838fn try_increase_split_id(split_id: &mut i64) -> ConnectorResult<()> {
839    match split_id.checked_add(1) {
840        Some(s) => {
841            *split_id = s;
842            Ok(())
843        }
844        None => Err(anyhow::anyhow!("too many CDC snapshot splits").into()),
845    }
846}
847
848/// Use the first column of primary keys to split table.
849fn is_supported_even_split_data_type(data_type: &DataType) -> bool {
850    matches!(
851        data_type,
852        DataType::Int16 | DataType::Int32 | DataType::Int64
853    )
854}
855
856pub fn type_name_to_pg_type(ty_name: &str) -> Option<PgType> {
857    let ty_name_lower = ty_name.to_lowercase();
858    // Handle array types (prefixed with _)
859    if let Some(base_type) = ty_name_lower.strip_prefix('_') {
860        match base_type {
861            "int2" => Some(PgType::INT2_ARRAY),
862            "int4" => Some(PgType::INT4_ARRAY),
863            "int8" => Some(PgType::INT8_ARRAY),
864            "bit" => Some(PgType::BIT_ARRAY),
865            "float4" => Some(PgType::FLOAT4_ARRAY),
866            "float8" => Some(PgType::FLOAT8_ARRAY),
867            "numeric" => Some(PgType::NUMERIC_ARRAY),
868            "bool" => Some(PgType::BOOL_ARRAY),
869            "xml" | "macaddr" | "macaddr8" | "cidr" | "inet" | "int4range" | "int8range"
870            | "numrange" | "tsrange" | "tstzrange" | "daterange" | "citext" => {
871                Some(PgType::VARCHAR_ARRAY)
872            }
873            "varchar" => Some(PgType::VARCHAR_ARRAY),
874            "text" => Some(PgType::TEXT_ARRAY),
875            "bytea" => Some(PgType::BYTEA_ARRAY),
876            "geometry" | "geography" => Some(PgType::BYTEA_ARRAY), // PostGIS spatial arrays
877            "date" => Some(PgType::DATE_ARRAY),
878            "time" => Some(PgType::TIME_ARRAY),
879            "timetz" => Some(PgType::TIMETZ_ARRAY),
880            "timestamp" => Some(PgType::TIMESTAMP_ARRAY),
881            "timestamptz" => Some(PgType::TIMESTAMPTZ_ARRAY),
882            "interval" => Some(PgType::INTERVAL_ARRAY),
883            "json" => Some(PgType::JSON_ARRAY),
884            "jsonb" => Some(PgType::JSONB_ARRAY),
885            "uuid" => Some(PgType::UUID_ARRAY),
886            "point" => Some(PgType::POINT_ARRAY),
887            "oid" => Some(PgType::OID_ARRAY),
888            "money" => Some(PgType::MONEY_ARRAY),
889            _ => None,
890        }
891    } else {
892        // Handle non-array types
893        match ty_name_lower.as_str() {
894            "int2" => Some(PgType::INT2),
895            "bit" => Some(PgType::BIT),
896            "int" | "int4" => Some(PgType::INT4),
897            "int8" => Some(PgType::INT8),
898            "float4" => Some(PgType::FLOAT4),
899            "float8" => Some(PgType::FLOAT8),
900            "numeric" => Some(PgType::NUMERIC),
901            "money" => Some(PgType::MONEY),
902            "boolean" | "bool" => Some(PgType::BOOL),
903            "inet" | "xml" | "varchar" | "character varying" | "int4range" | "int8range"
904            | "numrange" | "tsrange" | "tstzrange" | "daterange" | "macaddr" | "macaddr8"
905            | "cidr" => Some(PgType::VARCHAR),
906            "char" | "character" | "bpchar" => Some(PgType::BPCHAR),
907            "citext" | "text" => Some(PgType::TEXT),
908            "bytea" => Some(PgType::BYTEA),
909            "geometry" | "geography" => Some(PgType::BYTEA), // PostGIS spatial types
910            "date" => Some(PgType::DATE),
911            "time" => Some(PgType::TIME),
912            "timetz" => Some(PgType::TIMETZ),
913            "timestamp" => Some(PgType::TIMESTAMP),
914            "timestamptz" => Some(PgType::TIMESTAMPTZ),
915            "interval" => Some(PgType::INTERVAL),
916            "json" => Some(PgType::JSON),
917            "jsonb" => Some(PgType::JSONB),
918            "uuid" => Some(PgType::UUID),
919            "point" => Some(PgType::POINT),
920            "oid" => Some(PgType::OID),
921            _ => None,
922        }
923    }
924}
925
926// Keep this canonical mapping aligned with `postgres_source_column_type_compatible` in
927// `src/common/src/catalog/cdc_type_compatibility.rs`, which validates user-declared RW column
928// types.
929pub fn pg_type_to_rw_type(pg_type: &PgType) -> ConnectorResult<DataType> {
930    let data_type = match *pg_type {
931        PgType::BOOL => DataType::Boolean,
932        PgType::BIT => DataType::Boolean,
933        PgType::INT2 => DataType::Int16,
934        PgType::INT4 => DataType::Int32,
935        PgType::INT8 => DataType::Int64,
936        PgType::FLOAT4 => DataType::Float32,
937        PgType::FLOAT8 => DataType::Float64,
938        PgType::NUMERIC | PgType::MONEY => DataType::Decimal,
939        PgType::DATE => DataType::Date,
940        PgType::TIME => DataType::Time,
941        PgType::TIMETZ => DataType::Time,
942        PgType::POINT => postgres_point_type(),
943        PgType::TIMESTAMP => DataType::Timestamp,
944        PgType::TIMESTAMPTZ => DataType::Timestamptz,
945        PgType::INTERVAL => DataType::Interval,
946        PgType::VARCHAR | PgType::TEXT | PgType::BPCHAR | PgType::UUID => DataType::Varchar,
947        PgType::BYTEA => DataType::Bytea,
948        PgType::JSON | PgType::JSONB => DataType::Jsonb,
949        // Array types
950        PgType::BOOL_ARRAY => DataType::Boolean.list(),
951        PgType::BIT_ARRAY => DataType::Boolean.list(),
952        PgType::INT2_ARRAY => DataType::Int16.list(),
953        PgType::INT4_ARRAY => DataType::Int32.list(),
954        PgType::INT8_ARRAY => DataType::Int64.list(),
955        PgType::FLOAT4_ARRAY => DataType::Float32.list(),
956        PgType::FLOAT8_ARRAY => DataType::Float64.list(),
957        PgType::NUMERIC_ARRAY => DataType::Decimal.list(),
958        PgType::VARCHAR_ARRAY => DataType::Varchar.list(),
959        PgType::TEXT_ARRAY => DataType::Varchar.list(),
960        PgType::BYTEA_ARRAY => DataType::Bytea.list(),
961        PgType::DATE_ARRAY => DataType::Date.list(),
962        PgType::TIME_ARRAY => DataType::Time.list(),
963        PgType::TIMESTAMP_ARRAY => DataType::Timestamp.list(),
964        PgType::TIMESTAMPTZ_ARRAY => DataType::Timestamptz.list(),
965        PgType::INTERVAL_ARRAY => DataType::Interval.list(),
966        PgType::JSON_ARRAY => DataType::Jsonb.list(),
967        PgType::JSONB_ARRAY => DataType::Jsonb.list(),
968        PgType::UUID_ARRAY => DataType::Varchar.list(),
969        PgType::OID => DataType::Int64,
970        PgType::OID_ARRAY => DataType::Int64.list(),
971        PgType::MONEY_ARRAY => DataType::Decimal.list(),
972        // Debezium does not implement POINT_ARRAY schema conversion.
973        // https://github.com/debezium/debezium/blob/main/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/PostgresValueConverter.java#L339-L348
974        PgType::POINT_ARRAY => {
975            return Err(anyhow::anyhow!("unsupported postgres type: {}", pg_type).into());
976        }
977        _ => {
978            return Err(anyhow::anyhow!("unsupported postgres type: {}", pg_type).into());
979        }
980    };
981    Ok(data_type)
982}
983
984#[cfg(test)]
985mod tests {
986    use std::cmp::Ordering;
987    use std::collections::HashMap;
988
989    use futures::pin_mut;
990    use futures_async_stream::for_await;
991    use maplit::{convert_args, hashmap};
992    use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema};
993    use risingwave_common::row::OwnedRow;
994    use risingwave_common::types::{DataType, ScalarImpl};
995
996    use crate::connector_common::PostgresExternalTable;
997    use crate::source::cdc::external::postgres::{PostgresExternalTableReader, PostgresOffset};
998    use crate::source::cdc::external::{ExternalTableConfig, ExternalTableReader, SchemaTableName};
999
1000    #[ignore]
1001    #[tokio::test]
1002    async fn test_postgres_schema() {
1003        let config = ExternalTableConfig {
1004            connector: "postgres-cdc".to_owned(),
1005            host: "localhost".to_owned(),
1006            port: "8432".to_owned(),
1007            username: "myuser".to_owned(),
1008            password: "123456".to_owned(),
1009            database: "mydb".to_owned(),
1010            schema: "public".to_owned(),
1011            table: "mytest".to_owned(),
1012            ssl_mode: Default::default(),
1013            ssl_root_cert: None,
1014            encrypt: "false".to_owned(),
1015        };
1016
1017        let table = PostgresExternalTable::connect(
1018            &config.pg_connection_config().unwrap(),
1019            &config.schema,
1020            &config.table,
1021            false,
1022            Some("SELECT"),
1023        )
1024        .await
1025        .unwrap();
1026
1027        println!("columns: {:?}", table.column_descs());
1028        println!("primary keys: {:?}", table.pk_names());
1029    }
1030
1031    #[test]
1032    fn test_postgres_offset() {
1033        let off1 = PostgresOffset {
1034            txid: 4,
1035            lsn: 2,
1036            ..Default::default()
1037        };
1038        let off2 = PostgresOffset {
1039            txid: 1,
1040            lsn: 3,
1041            ..Default::default()
1042        };
1043        let off3 = PostgresOffset {
1044            txid: 5,
1045            lsn: 1,
1046            ..Default::default()
1047        };
1048
1049        assert!(off1 < off2);
1050        assert!(off3 < off1);
1051        assert!(off2 > off3);
1052    }
1053
1054    #[test]
1055    fn test_postgres_offset_partial_ord_with_lsn_commit() {
1056        // Test comparison with both lsn_commit and lsn_proc fields
1057        let off1 = PostgresOffset {
1058            txid: 1,
1059            lsn: 100,
1060            lsn_commit: Some(200),
1061            lsn_proc: Some(150),
1062        };
1063        let off2 = PostgresOffset {
1064            txid: 2,
1065            lsn: 300,
1066            lsn_commit: Some(250),
1067            lsn_proc: Some(200),
1068        };
1069
1070        // Should compare using lsn_commit first when both have both fields
1071        assert!(off1 < off2);
1072
1073        // Test with same lsn_commit but different lsn_proc
1074        let off3 = PostgresOffset {
1075            txid: 3,
1076            lsn: 500,
1077            lsn_commit: Some(200), // same as off1
1078            lsn_proc: Some(160),   // higher than off1
1079        };
1080
1081        // Should compare lsn_proc when lsn_commit is equal
1082        assert!(off1 < off3);
1083
1084        // Test with missing lsn_proc - should fall back to lsn comparison
1085        let off4 = PostgresOffset {
1086            txid: 4,
1087            lsn: 400,
1088            lsn_commit: Some(100), // lower than off1's lsn_commit
1089            lsn_proc: None,        // missing lsn_proc
1090        };
1091
1092        // Should fall back to lsn comparison (off1.lsn=100 < off4.lsn=400)
1093        assert!(off1 < off4);
1094
1095        // Test with missing lsn_commit - should fall back to lsn comparison
1096        let off5 = PostgresOffset {
1097            txid: 5,
1098            lsn: 50,             // lower than off1.lsn
1099            lsn_commit: None,    // missing lsn_commit
1100            lsn_proc: Some(300), // higher than off1's lsn_proc
1101        };
1102
1103        // Should fall back to lsn comparison (off5.lsn=50 < off1.lsn=100)
1104        assert!(off5 < off1);
1105
1106        // Additional test cases: equal lsn_commit values with different lsn_proc
1107        let off6 = PostgresOffset {
1108            txid: 6,
1109            lsn: 600,
1110            lsn_commit: Some(500),
1111            lsn_proc: Some(300),
1112        };
1113        let off7 = PostgresOffset {
1114            txid: 7,
1115            lsn: 700,
1116            lsn_commit: Some(500), // same as off6
1117            lsn_proc: Some(400),   // higher than off6
1118        };
1119
1120        // Should compare lsn_proc since lsn_commit is equal
1121        assert!(off6 < off7);
1122
1123        // Test reverse order
1124        let off8 = PostgresOffset {
1125            txid: 8,
1126            lsn: 800,
1127            lsn_commit: Some(500), // same as others
1128            lsn_proc: Some(200),   // lower than off6
1129        };
1130
1131        assert!(off8 < off6);
1132        assert!(off8 < off7);
1133
1134        // Test equal lsn_commit and lsn_proc
1135        let off9 = PostgresOffset {
1136            txid: 9,
1137            lsn: 900,
1138            lsn_commit: Some(500), // same as off6
1139            lsn_proc: Some(300),   // same as off6
1140        };
1141
1142        // Should be equal
1143        assert_eq!(off6.partial_cmp(&off9), Some(Ordering::Equal));
1144    }
1145
1146    #[test]
1147    fn test_debezium_offset_parsing() {
1148        // Test parsing with all required fields present
1149        let debezium_offset_with_fields = r#"{
1150            "sourcePartition": {"server": "RW_CDC_1004"},
1151            "sourceOffset": {
1152                "last_snapshot_record": false,
1153                "lsn": 29973552,
1154                "txId": 1046,
1155                "ts_usec": 1670826189008456,
1156                "snapshot": true,
1157                "lsn_commit": 29973600,
1158                "lsn_proc": 29973580
1159            },
1160            "isHeartbeat": false
1161        }"#;
1162
1163        let offset = PostgresOffset::parse_debezium_offset(debezium_offset_with_fields).unwrap();
1164        assert_eq!(offset.txid, 1046);
1165        assert_eq!(offset.lsn, 29973552);
1166        assert_eq!(offset.lsn_commit, Some(29973600));
1167        assert_eq!(offset.lsn_proc, Some(29973580));
1168
1169        // Test parsing should fail when required fields are missing
1170        let debezium_offset_missing_fields = r#"{
1171            "sourcePartition": {"server": "RW_CDC_1004"},
1172            "sourceOffset": {
1173                "last_snapshot_record": false,
1174                "lsn": 29973552,
1175                "txId": 1046,
1176                "ts_usec": 1670826189008456,
1177                "snapshot": true
1178            },
1179            "isHeartbeat": false
1180        }"#;
1181
1182        let result = PostgresOffset::parse_debezium_offset(debezium_offset_missing_fields);
1183        assert!(result.is_err());
1184        let error_msg = result.unwrap_err().to_string();
1185        assert!(error_msg.contains("invalid postgres lsn_proc"));
1186    }
1187
1188    #[test]
1189    fn test_filter_expression() {
1190        let cols = vec!["v1".to_owned()];
1191        let expr = PostgresExternalTableReader::filter_expression(&cols);
1192        assert_eq!(expr, "(\"v1\") > ($1)");
1193
1194        let cols = vec!["v1".to_owned(), "v2".to_owned()];
1195        let expr = PostgresExternalTableReader::filter_expression(&cols);
1196        assert_eq!(expr, "(\"v1\", \"v2\") > ($1, $2)");
1197
1198        let cols = vec!["v1".to_owned(), "v2".to_owned(), "v3".to_owned()];
1199        let expr = PostgresExternalTableReader::filter_expression(&cols);
1200        assert_eq!(expr, "(\"v1\", \"v2\", \"v3\") > ($1, $2, $3)");
1201    }
1202
1203    #[test]
1204    fn test_split_filter_expression() {
1205        let cols = vec!["v1".to_owned()];
1206        let expr = PostgresExternalTableReader::split_filter_expression(&cols, true, true);
1207        assert_eq!(expr, "1 = 1");
1208
1209        let expr = PostgresExternalTableReader::split_filter_expression(&cols, true, false);
1210        assert_eq!(expr, "(\"v1\") < ($1)");
1211
1212        let expr = PostgresExternalTableReader::split_filter_expression(&cols, false, true);
1213        assert_eq!(expr, "(\"v1\") >= ($1)");
1214
1215        let expr = PostgresExternalTableReader::split_filter_expression(&cols, false, false);
1216        assert_eq!(expr, "(\"v1\") >= ($1) AND (\"v1\") < ($2)");
1217    }
1218
1219    // manual test
1220    #[ignore]
1221    #[tokio::test]
1222    async fn test_pg_table_reader() {
1223        let columns = [
1224            ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32),
1225            ColumnDesc::named("v2", ColumnId::new(2), DataType::Varchar),
1226            ColumnDesc::named("v3", ColumnId::new(3), DataType::Decimal),
1227            ColumnDesc::named("v4", ColumnId::new(4), DataType::Date),
1228        ];
1229        let rw_schema = Schema {
1230            fields: columns.iter().map(Field::from).collect(),
1231        };
1232
1233        let props: HashMap<String, String> = convert_args!(hashmap!(
1234                "hostname" => "localhost",
1235                "port" => "8432",
1236                "username" => "myuser",
1237                "password" => "123456",
1238                "database.name" => "mydb",
1239                "schema.name" => "public",
1240                "table.name" => "t1"));
1241
1242        let config =
1243            serde_json::from_value::<ExternalTableConfig>(serde_json::to_value(props).unwrap())
1244                .unwrap();
1245        let schema_table_name = SchemaTableName {
1246            schema_name: "public".to_owned(),
1247            table_name: "t1".to_owned(),
1248        };
1249        let reader = PostgresExternalTableReader::new(
1250            config,
1251            rw_schema,
1252            vec![0, 1],
1253            schema_table_name.clone(),
1254            233,
1255        )
1256        .await
1257        .unwrap();
1258
1259        let offset = reader.current_cdc_offset().await.unwrap();
1260        println!("CdcOffset: {:?}", offset);
1261
1262        let start_pk = OwnedRow::new(vec![Some(ScalarImpl::from(3)), Some(ScalarImpl::from("c"))]);
1263        let stream = reader.snapshot_read(
1264            schema_table_name,
1265            Some(start_pk),
1266            vec!["v1".to_owned(), "v2".to_owned()],
1267            1000,
1268        );
1269
1270        pin_mut!(stream);
1271        #[for_await]
1272        for row in stream {
1273            println!("OwnedRow: {:?}", row);
1274        }
1275    }
1276}