This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 610affc28e5 branch-4.1: [fix](streaming-job) Support delete sign in 
CDC stream TVF #65146 (#65222)
610affc28e5 is described below

commit 610affc28e5c62fedbb9a49ac82a8df78f0ccf85
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Jul 7 13:45:41 2026 +0800

    branch-4.1: [fix](streaming-job) Support delete sign in CDC stream TVF 
#65146 (#65222)
    
    Cherry-picked from #65146
    
    Co-authored-by: wudi <[email protected]>
---
 .../CdcStreamTableValuedFunction.java              |  12 +-
 .../CdcStreamTableValuedFunctionTest.java          | 101 +++++++++++++
 ..._streaming_job_cdc_stream_mysql_delete_sign.out |  21 +++
 ...reaming_job_cdc_stream_postgres_delete_sign.out |  21 +++
 ...reaming_job_cdc_stream_mysql_delete_sign.groovy | 157 ++++++++++++++++++++
 ...ming_job_cdc_stream_postgres_delete_sign.groovy | 159 +++++++++++++++++++++
 6 files changed, 470 insertions(+), 1 deletion(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
 
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
index ae3c815789f..aa2cd3a9331 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
@@ -20,6 +20,7 @@ package org.apache.doris.tablefunction;
 import org.apache.doris.analysis.BrokerDesc;
 import org.apache.doris.analysis.StorageBackend.StorageType;
 import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.datasource.jdbc.client.JdbcClient;
 import org.apache.doris.job.cdc.DataSourceConfigKeys;
@@ -53,14 +54,18 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
     public static final String JOB_ID_KEY = "job.id";
     public static final String TASK_ID_KEY = "task.id";
     public static final String META_KEY = "meta";
+    public static final String INCLUDE_DELETE_SIGN = "include_delete_sign";
+    private final boolean includeDeleteSign;
 
     public CdcStreamTableValuedFunction(Map<String, String> properties) throws 
AnalysisException {
         validate(properties);
+        includeDeleteSign = 
Boolean.parseBoolean(properties.getOrDefault(INCLUDE_DELETE_SIGN, "false"));
         processProps(properties);
     }
 
     private void processProps(Map<String, String> properties) throws 
AnalysisException {
         Map<String, String> copyProps = new HashMap<>(properties);
+        copyProps.remove(INCLUDE_DELETE_SIGN);
         copyProps.put("format", "json");
 
         // Standalone TVF: random jobId. TVF-in-job: job.id injected by 
rewriteTvfParams.
@@ -157,6 +162,7 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
         validatePositiveIntIfPresent(properties, 
DataSourceConfigKeys.SNAPSHOT_SPLIT_SIZE);
         validatePositiveIntIfPresent(properties, 
DataSourceConfigKeys.SNAPSHOT_PARALLELISM);
         validateBooleanIfPresent(properties, 
DataSourceConfigKeys.SKIP_SNAPSHOT_BACKFILL);
+        validateBooleanIfPresent(properties, INCLUDE_DELETE_SIGN);
         // TVF entrypoint shares server_id checks with the from-to path's 
validateSource.
         try {
             DataSourceConfigValidator.validateServerIdConfig(properties);
@@ -214,7 +220,11 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
             if (!jdbcClient.isTableExist(database, table)) {
                 throw new AnalysisException("Table does not exist: " + table);
             }
-            return jdbcClient.getColumnsFromJdbc(database, table);
+            List<Column> columns = new 
ArrayList<>(jdbcClient.getColumnsFromJdbc(database, table));
+            if (includeDeleteSign) {
+                columns.add(new Column(Column.DELETE_SIGN, 
PrimitiveType.TINYINT, false));
+            }
+            return columns;
         } finally {
             jdbcClient.closeClient();
         }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunctionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunctionTest.java
new file mode 100644
index 00000000000..1be26fa69da
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunctionTest.java
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.tablefunction;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.datasource.jdbc.client.JdbcClient;
+import org.apache.doris.job.cdc.DataSourceConfigKeys;
+import org.apache.doris.job.common.DataSourceType;
+import org.apache.doris.job.util.StreamingJobUtils;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class CdcStreamTableValuedFunctionTest {
+
+    @Test
+    public void testDeleteSignIsExcludedByDefault() throws Exception {
+        List<Column> columns = getTableColumns(baseProperties());
+
+        Assert.assertEquals(1, columns.size());
+        Assert.assertEquals("id", columns.get(0).getName());
+    }
+
+    @Test
+    public void testDeleteSignIsIncludedWhenEnabled() throws Exception {
+        Map<String, String> properties = baseProperties();
+        properties.put(CdcStreamTableValuedFunction.INCLUDE_DELETE_SIGN, 
"true");
+
+        List<Column> columns = getTableColumns(properties);
+
+        Assert.assertEquals(2, columns.size());
+        Column deleteSign = columns.get(1);
+        Assert.assertEquals(Column.DELETE_SIGN, deleteSign.getName());
+        Assert.assertEquals(PrimitiveType.TINYINT, 
deleteSign.getType().getPrimitiveType());
+        Assert.assertFalse(deleteSign.isAllowNull());
+    }
+
+    @Test
+    public void testInvalidIncludeDeleteSignIsRejected() {
+        Map<String, String> properties = baseProperties();
+        properties.put(CdcStreamTableValuedFunction.INCLUDE_DELETE_SIGN, 
"invalid");
+
+        AnalysisException exception = 
Assert.assertThrows(AnalysisException.class,
+                () -> new CdcStreamTableValuedFunction(properties));
+
+        
Assert.assertTrue(exception.getMessage().contains("include_delete_sign"));
+    }
+
+    private List<Column> getTableColumns(Map<String, String> properties) 
throws Exception {
+        JdbcClient jdbcClient = Mockito.mock(JdbcClient.class);
+        List<Column> sourceColumns = new ArrayList<>();
+        sourceColumns.add(new Column("id", PrimitiveType.INT));
+        Mockito.when(jdbcClient.isTableExist("test_db", 
"test_table")).thenReturn(true);
+        Mockito.when(jdbcClient.getColumnsFromJdbc("test_db", 
"test_table")).thenReturn(sourceColumns);
+
+        try (MockedStatic<StreamingJobUtils> utils = 
Mockito.mockStatic(StreamingJobUtils.class)) {
+            utils.when(() -> StreamingJobUtils.getJdbcClient(
+                            Mockito.eq(DataSourceType.MYSQL), 
Mockito.anyMap()))
+                    .thenReturn(jdbcClient);
+            utils.when(() -> StreamingJobUtils.getRemoteDbName(
+                            Mockito.eq(DataSourceType.MYSQL), 
Mockito.anyMap()))
+                    .thenReturn("test_db");
+            CdcStreamTableValuedFunction function = new 
CdcStreamTableValuedFunction(properties);
+            return function.getTableColumns();
+        }
+    }
+
+    private Map<String, String> baseProperties() {
+        Map<String, String> properties = new HashMap<>();
+        properties.put(DataSourceConfigKeys.TYPE, "mysql");
+        properties.put(DataSourceConfigKeys.JDBC_URL, 
"jdbc:mysql://localhost:3306/test_db");
+        properties.put(DataSourceConfigKeys.DATABASE, "test_db");
+        properties.put(DataSourceConfigKeys.TABLE, "test_table");
+        properties.put(DataSourceConfigKeys.OFFSET, "initial");
+        return properties;
+    }
+}
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_mysql_delete_sign.out
 
b/regression-test/data/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_mysql_delete_sign.out
new file mode 100644
index 00000000000..1328d4ef212
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_mysql_delete_sign.out
@@ -0,0 +1,21 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !snapshot_data --
+A1     1
+B1     2
+
+-- !incremental_data --
+A1     1       0
+B1     2       0
+C1     3       0
+D1     4       0
+
+-- !after_update --
+A1     1       0
+B1     2       0
+C1     30      0
+D1     4       0
+
+-- !after_delete --
+A1     1       0
+B1     2       0
+C1     30      0
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_postgres_delete_sign.out
 
b/regression-test/data/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_postgres_delete_sign.out
new file mode 100644
index 00000000000..1328d4ef212
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_postgres_delete_sign.out
@@ -0,0 +1,21 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !snapshot_data --
+A1     1
+B1     2
+
+-- !incremental_data --
+A1     1       0
+B1     2       0
+C1     3       0
+D1     4       0
+
+-- !after_update --
+A1     1       0
+B1     2       0
+C1     30      0
+D1     4       0
+
+-- !after_delete --
+A1     1       0
+B1     2       0
+C1     30      0
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_mysql_delete_sign.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_mysql_delete_sign.groovy
new file mode 100644
index 00000000000..9eef49daac6
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_mysql_delete_sign.groovy
@@ -0,0 +1,157 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * Test CDC stream TVF with delete sign enabled for MySQL primary-key table.
+ *
+ * Scenario:
+ *   1. Snapshot phase (offset=initial): pre-existing rows (A1, B1) are synced.
+ *   2. Incremental INSERT (C1, D1).
+ *   3. Incremental UPDATE (C1 age -> 30).
+ *   4. Incremental DELETE (D1 removed).
+ */
+suite("test_streaming_job_cdc_stream_mysql_delete_sign", 
"p0,external,mysql,external_docker,external_docker_mysql,nondatalake") {
+    def jobName = "test_streaming_job_cdc_stream_mysql_ds_name"
+    def currentDb = (sql "select database()")[0][0]
+    def dorisTable = "test_streaming_job_cdc_stream_mysql_ds_tbl"
+    def mysqlDb = "test_cdc_db"
+    def mysqlTable = "test_streaming_job_cdc_stream_mysql_ds_src"
+
+    sql """DROP JOB IF EXISTS where jobname = '${jobName}'"""
+    sql """drop table if exists ${currentDb}.${dorisTable} force"""
+
+    sql """
+        CREATE TABLE IF NOT EXISTS ${currentDb}.${dorisTable} (
+            `name` varchar(200) NULL,
+            `age`  int NULL
+        ) ENGINE=OLAP
+        UNIQUE KEY(`name`)
+        DISTRIBUTED BY HASH(`name`) BUCKETS AUTO
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "enable_unique_key_merge_on_write" = "true"
+        )
+    """
+
+    String enabled = context.config.otherConfigs.get("enableJdbcTest")
+    if (enabled != null && enabled.equalsIgnoreCase("true")) {
+        String mysql_port = context.config.otherConfigs.get("mysql_57_port")
+        String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+        String s3_endpoint = getS3Endpoint()
+        String bucket = getS3BucketName()
+        String driver_url = 
"https://${bucket}.${s3_endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+
+        // prepare source table with primary key and pre-existing snapshot data
+        connect("root", "123456", 
"jdbc:mysql://${externalEnvIp}:${mysql_port}") {
+            sql """CREATE DATABASE IF NOT EXISTS ${mysqlDb}"""
+            sql """DROP TABLE IF EXISTS ${mysqlDb}.${mysqlTable}"""
+            sql """CREATE TABLE ${mysqlDb}.${mysqlTable} (
+                      `name` varchar(200) NOT NULL,
+                      `age`  int DEFAULT NULL,
+                      PRIMARY KEY (`name`)
+                  ) ENGINE=InnoDB"""
+            sql """INSERT INTO ${mysqlDb}.${mysqlTable} (name, age) VALUES 
('A1', 1)"""
+            sql """INSERT INTO ${mysqlDb}.${mysqlTable} (name, age) VALUES 
('B1', 2)"""
+        }
+
+        // create streaming job via cdc_stream TVF with delete sign enabled
+        sql """
+            CREATE JOB ${jobName}
+            ON STREAMING DO INSERT INTO ${currentDb}.${dorisTable} (name, age, 
__DORIS_DELETE_SIGN__)
+            SELECT name, age, __DORIS_DELETE_SIGN__ FROM cdc_stream(
+                "type"         = "mysql",
+                "jdbc_url"     = "jdbc:mysql://${externalEnvIp}:${mysql_port}",
+                "driver_url"   = "${driver_url}",
+                "driver_class" = "com.mysql.cj.jdbc.Driver",
+                "user"         = "root",
+                "password"     = "123456",
+                "database"          = "${mysqlDb}",
+                "table"             = "${mysqlTable}",
+                "offset"             = "initial",
+                "snapshot_split_key" = "name",
+                "include_delete_sign" = "true"
+            )
+        """
+
+        // wait for at least one snapshot task to succeed
+        try {
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def cnt = sql """select SucceedTaskCount from 
jobs("type"="insert") where Name='${jobName}' and ExecuteType='STREAMING'"""
+                log.info("SucceedTaskCount: " + cnt)
+                cnt.size() == 1 && (cnt.get(0).get(0) as int) >= 1
+            })
+        } catch (Exception ex) {
+            log.info("job: " + (sql """select * from jobs("type"="insert") 
where Name='${jobName}'"""))
+            log.info("tasks: " + (sql """select * from tasks("type"="insert") 
where JobName='${jobName}'"""))
+            throw ex
+        }
+
+        // verify snapshot data
+        qt_snapshot_data """ SELECT name, age FROM ${currentDb}.${dorisTable} 
ORDER BY name """
+
+        // insert incremental rows
+        connect("root", "123456", 
"jdbc:mysql://${externalEnvIp}:${mysql_port}") {
+            sql """INSERT INTO ${mysqlDb}.${mysqlTable} (name, age) VALUES 
('C1', 3)"""
+            sql """INSERT INTO ${mysqlDb}.${mysqlTable} (name, age) VALUES 
('D1', 4)"""
+        }
+
+        // wait for binlog tasks to pick up the new rows
+        try {
+            Awaitility.await().atMost(120, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT count(1) FROM 
${currentDb}.${dorisTable} WHERE name IN ('C1', 'D1')"""
+                log.info("incremental rows: " + rows)
+                (rows.get(0).get(0) as int) == 2
+            })
+        } catch (Exception ex) {
+            log.info("job: " + (sql """select * from jobs("type"="insert") 
where Name='${jobName}'"""))
+            log.info("tasks: " + (sql """select * from tasks("type"="insert") 
where JobName='${jobName}'"""))
+            throw ex
+        }
+
+        qt_incremental_data """ SELECT name, age, __DORIS_DELETE_SIGN__ FROM 
${currentDb}.${dorisTable} ORDER BY name """
+
+        // verify incremental UPDATE
+        connect("root", "123456", 
"jdbc:mysql://${externalEnvIp}:${mysql_port}") {
+            sql """UPDATE ${mysqlDb}.${mysqlTable} SET age = 30 WHERE name = 
'C1'"""
+        }
+        Awaitility.await().atMost(120, SECONDS).pollInterval(2, 
SECONDS).until({
+            def rows = sql """SELECT age FROM ${currentDb}.${dorisTable} WHERE 
name = 'C1'"""
+            rows.size() == 1 && (rows.get(0).get(0) as int) == 30
+        })
+
+        qt_after_update """ SELECT name, age, __DORIS_DELETE_SIGN__ FROM 
${currentDb}.${dorisTable} ORDER BY name """
+
+        // verify incremental DELETE
+        connect("root", "123456", 
"jdbc:mysql://${externalEnvIp}:${mysql_port}") {
+            sql """DELETE FROM ${mysqlDb}.${mysqlTable} WHERE name = 'D1'"""
+        }
+        Awaitility.await().atMost(120, SECONDS).pollInterval(2, 
SECONDS).until({
+            def rows = sql """SELECT count(1) FROM ${currentDb}.${dorisTable} 
WHERE name = 'D1'"""
+            (rows.get(0).get(0) as int) == 0
+        })
+
+        qt_after_delete """ SELECT name, age, __DORIS_DELETE_SIGN__ FROM 
${currentDb}.${dorisTable} ORDER BY name """
+
+        sql """DROP JOB IF EXISTS where jobname = '${jobName}'"""
+        sql """drop table if exists ${currentDb}.${dorisTable} force"""
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_postgres_delete_sign.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_postgres_delete_sign.groovy
new file mode 100644
index 00000000000..97ad3c362da
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/tvf/test_streaming_job_cdc_stream_postgres_delete_sign.groovy
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * Test CDC stream TVF with delete sign enabled for PostgreSQL primary-key 
table.
+ *
+ * Scenario:
+ *   1. Snapshot phase (offset=initial): pre-existing rows (A1, B1) are synced.
+ *   2. Incremental INSERT (C1, D1).
+ *   3. Incremental UPDATE (C1 age -> 30).
+ *   4. Incremental DELETE (D1 removed).
+ */
+suite("test_streaming_job_cdc_stream_postgres_delete_sign", 
"p0,external,pg,external_docker,external_docker_pg,nondatalake") {
+    def jobName = "test_streaming_job_cdc_stream_postgres_ds_name"
+    def currentDb = (sql "select database()")[0][0]
+    def dorisTable = "test_streaming_job_cdc_stream_postgres_ds_tbl"
+    def pgDB = "postgres"
+    def pgSchema = "cdc_test"
+    def pgUser = "postgres"
+    def pgPassword = "123456"
+    def pgTable = "test_streaming_job_cdc_stream_postgres_ds_src"
+
+    sql """DROP JOB IF EXISTS where jobname = '${jobName}'"""
+    sql """drop table if exists ${currentDb}.${dorisTable} force"""
+
+    sql """
+        CREATE TABLE IF NOT EXISTS ${currentDb}.${dorisTable} (
+            `name` varchar(200) NULL,
+            `age`  int NULL
+        ) ENGINE=OLAP
+        UNIQUE KEY(`name`)
+        DISTRIBUTED BY HASH(`name`) BUCKETS AUTO
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "enable_unique_key_merge_on_write" = "true"
+        )
+    """
+
+    String enabled = context.config.otherConfigs.get("enableJdbcTest")
+    if (enabled != null && enabled.equalsIgnoreCase("true")) {
+        String pg_port = context.config.otherConfigs.get("pg_14_port")
+        String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+        String s3_endpoint = getS3Endpoint()
+        String bucket = getS3BucketName()
+        String driver_url = 
"https://${bucket}.${s3_endpoint}/regression/jdbc_driver/postgresql-42.5.0.jar";
+
+        // prepare source table with primary key and pre-existing snapshot data
+        connect("${pgUser}", "${pgPassword}", 
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+            sql """DROP TABLE IF EXISTS ${pgDB}.${pgSchema}.${pgTable}"""
+            sql """CREATE TABLE ${pgDB}.${pgSchema}.${pgTable} (
+                      "name" varchar(200) PRIMARY KEY,
+                      "age"  int2
+                  )"""
+            sql """INSERT INTO ${pgDB}.${pgSchema}.${pgTable} (name, age) 
VALUES ('A1', 1)"""
+            sql """INSERT INTO ${pgDB}.${pgSchema}.${pgTable} (name, age) 
VALUES ('B1', 2)"""
+        }
+
+        // create streaming job via cdc_stream TVF with delete sign enabled
+        sql """
+            CREATE JOB ${jobName}
+            ON STREAMING DO INSERT INTO ${currentDb}.${dorisTable} (name, age, 
__DORIS_DELETE_SIGN__)
+            SELECT name, age, __DORIS_DELETE_SIGN__ FROM cdc_stream(
+                "type"         = "postgres",
+                "jdbc_url"     = 
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}",
+                "driver_url"   = "${driver_url}",
+                "driver_class" = "org.postgresql.Driver",
+                "user"         = "${pgUser}",
+                "password"     = "${pgPassword}",
+                "database"     = "${pgDB}",
+                "schema"             = "${pgSchema}",
+                "table"              = "${pgTable}",
+                "offset"             = "initial",
+                "snapshot_split_size" = "1",
+                "include_delete_sign" = "true"
+            )
+        """
+
+        // wait for at least two snapshot tasks to succeed (split_size=1 → 2 
splits)
+        try {
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def cnt = sql """select SucceedTaskCount from 
jobs("type"="insert") where Name='${jobName}' and ExecuteType='STREAMING'"""
+                log.info("SucceedTaskCount: " + cnt)
+                cnt.size() == 1 && (cnt.get(0).get(0) as int) >= 2
+            })
+        } catch (Exception ex) {
+            log.info("job: " + (sql """select * from jobs("type"="insert") 
where Name='${jobName}'"""))
+            log.info("tasks: " + (sql """select * from tasks("type"="insert") 
where JobName='${jobName}'"""))
+            throw ex
+        }
+
+        // verify snapshot data
+        qt_snapshot_data """ SELECT name, age FROM ${currentDb}.${dorisTable} 
ORDER BY name """
+
+        // insert incremental rows
+        connect("${pgUser}", "${pgPassword}", 
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+            sql """INSERT INTO ${pgDB}.${pgSchema}.${pgTable} (name, age) 
VALUES ('C1', 3)"""
+            sql """INSERT INTO ${pgDB}.${pgSchema}.${pgTable} (name, age) 
VALUES ('D1', 4)"""
+        }
+
+        // wait for binlog tasks to pick up the new rows
+        try {
+            Awaitility.await().atMost(120, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT count(1) FROM 
${currentDb}.${dorisTable} WHERE name IN ('C1', 'D1')"""
+                log.info("incremental rows: " + rows)
+                (rows.get(0).get(0) as int) == 2
+            })
+        } catch (Exception ex) {
+            log.info("job: " + (sql """select * from jobs("type"="insert") 
where Name='${jobName}'"""))
+            log.info("tasks: " + (sql """select * from tasks("type"="insert") 
where JobName='${jobName}'"""))
+            throw ex
+        }
+
+        qt_incremental_data """ SELECT name, age, __DORIS_DELETE_SIGN__ FROM 
${currentDb}.${dorisTable} ORDER BY name """
+
+        // verify incremental UPDATE
+        connect("${pgUser}", "${pgPassword}", 
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+            sql """UPDATE ${pgDB}.${pgSchema}.${pgTable} SET age = 30 WHERE 
name = 'C1'"""
+        }
+        Awaitility.await().atMost(120, SECONDS).pollInterval(2, 
SECONDS).until({
+            def rows = sql """SELECT age FROM ${currentDb}.${dorisTable} WHERE 
name = 'C1'"""
+            rows.size() == 1 && (rows.get(0).get(0) as int) == 30
+        })
+
+        qt_after_update """ SELECT name, age, __DORIS_DELETE_SIGN__ FROM 
${currentDb}.${dorisTable} ORDER BY name """
+
+        // verify incremental DELETE
+        connect("${pgUser}", "${pgPassword}", 
"jdbc:postgresql://${externalEnvIp}:${pg_port}/${pgDB}") {
+            sql """DELETE FROM ${pgDB}.${pgSchema}.${pgTable} WHERE name = 
'D1'"""
+        }
+        Awaitility.await().atMost(120, SECONDS).pollInterval(2, 
SECONDS).until({
+            def rows = sql """SELECT count(1) FROM ${currentDb}.${dorisTable} 
WHERE name = 'D1'"""
+            (rows.get(0).get(0) as int) == 0
+        })
+
+        qt_after_delete """ SELECT name, age, __DORIS_DELETE_SIGN__ FROM 
${currentDb}.${dorisTable} ORDER BY name """
+
+        sql """DROP JOB IF EXISTS where jobname = '${jobName}'"""
+        sql """drop table if exists ${currentDb}.${dorisTable} force"""
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to