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

JNSimba pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 8a76160b646 [feature](streaming-job) Support OceanBase CDC streaming 
jobs (#65588)
8a76160b646 is described below

commit 8a76160b6460fbf8c406c3e6b770142e43719200
Author: wudi <[email protected]>
AuthorDate: Thu Jul 23 18:50:43 2026 +0800

    [feature](streaming-job) Support OceanBase CDC streaming jobs (#65588)
    
    ### What problem does this PR solve?
    
    Related PR: #65325
    
    Streaming jobs currently support MySQL and PostgreSQL CDC sources but
    cannot use OceanBase as a source.
    
    This change adds OceanBase MySQL compatibility mode as a streaming job
    data source. It introduces OceanBase source type handling in FE and the
    CDC client, reuses the MySQL-compatible CDC processing path, validates
    source configuration and compatibility mode before creating target
    tables, and supports initial, snapshot, latest, earliest, and specific
    startup offsets.
    
    The OceanBase third-party environment exposes separate JDBC and CDC
    ports. Test coverage includes basic synchronization, data types, startup
    offsets, schema changes, and FE restart recovery.
---
 .../docker-compose/oceanbase/oceanbase.env         |   1 +
 .../docker-compose/oceanbase/oceanbase.yaml.tpl    |   6 +-
 .../apache/doris/job/common/DataSourceType.java    |   3 +-
 .../streaming/DataSourceConfigValidator.java       |  79 +++++++-
 .../insert/streaming/StreamingInsertJob.java       |  19 +-
 .../streaming/StreamingJdbcUrlNormalizer.java      |   1 +
 .../apache/doris/job/util/StreamingJobUtils.java   |  15 +-
 .../streaming/DataSourceConfigValidatorTest.java   | 188 +++++++++++++++++++
 .../streaming/StreamingJdbcUrlNormalizerTest.java  |  10 +
 .../doris/job/util/StreamingJobUtilsTest.java      |  13 ++
 .../mysql/MySqlStreamingChangeEventSource.java     |  92 ++++++----
 .../doris/cdcclient/source/factory/DataSource.java |   3 +-
 .../source/factory/SourceReaderFactory.java        |   2 +
 .../source/reader/mysql/MySqlSourceReader.java     |   9 +
 .../reader/oceanbase/OceanBaseSourceReader.java    |  15 +-
 .../mysql/MySqlStreamingChangeEventSourceTest.java |  48 +++++
 .../cdcclient/itcase/CdcClientWriteHarness.java    |  53 +++++-
 .../itcase/OceanBaseSchemaChangeITCase.java        | 121 ++++++++++++
 .../itcase/OceanBaseStartupOffsetITCase.java       | 148 +++++++++++++++
 .../doris/cdcclient/itcase/OceanBaseTestBase.java  | 150 +++++++++++++++
 .../cdcclient/itcase/OceanBaseWriteDmlITCase.java  | 112 ++++++++++++
 .../source/factory/SourceReaderFactoryTest.java    |   9 +
 .../source/reader/mysql/MySqlSourceReaderTest.java |  31 +++-
 regression-test/conf/regression-conf.groovy        |   3 +-
 .../cdc/test_streaming_oceanbase_job.out           |  17 ++
 .../cdc/test_streaming_oceanbase_job_all_type.out  |   8 +
 .../cdc/test_streaming_oceanbase_job_offset.out    |  11 ++
 .../test_streaming_oceanbase_job_restart_fe.out    |   5 +
 .../test_streaming_oceanbase_job_sc_restart_fe.out |  11 ++
 .../test_streaming_oceanbase_job_schema_change.out |   9 +
 .../pipeline/external/conf/regression-conf.groovy  |   1 +
 .../suites/doc/external/jdbc/oceanbase.md.groovy   |   2 +-
 .../jdbc/test_oceanbase_jdbc_catalog.groovy        |   4 +-
 .../cdc/test_streaming_oceanbase_job.groovy        | 143 +++++++++++++++
 .../test_streaming_oceanbase_job_all_type.groovy   | 152 +++++++++++++++
 .../cdc/test_streaming_oceanbase_job_offset.groovy | 203 +++++++++++++++++++++
 .../test_streaming_oceanbase_job_restart_fe.groovy | 150 +++++++++++++++
 ...st_streaming_oceanbase_job_sc_restart_fe.groovy | 156 ++++++++++++++++
 ...st_streaming_oceanbase_job_schema_change.groovy | 138 ++++++++++++++
 39 files changed, 2071 insertions(+), 70 deletions(-)

diff --git a/docker/thirdparties/docker-compose/oceanbase/oceanbase.env 
b/docker/thirdparties/docker-compose/oceanbase/oceanbase.env
index 5a8998e7cd5..108843f9686 100644
--- a/docker/thirdparties/docker-compose/oceanbase/oceanbase.env
+++ b/docker/thirdparties/docker-compose/oceanbase/oceanbase.env
@@ -17,3 +17,4 @@
 # under the License.
 
 DOCKER_OCEANBASE_EXTERNAL_PORT=2881
+DOCKER_OCEANBASE_PROXY_EXTERNAL_PORT=2883
diff --git a/docker/thirdparties/docker-compose/oceanbase/oceanbase.yaml.tpl 
b/docker/thirdparties/docker-compose/oceanbase/oceanbase.yaml.tpl
index aa64c1d2144..fee16e3c160 100644
--- a/docker/thirdparties/docker-compose/oceanbase/oceanbase.yaml.tpl
+++ b/docker/thirdparties/docker-compose/oceanbase/oceanbase.yaml.tpl
@@ -19,16 +19,18 @@ version: "2.1"
 
 services:
   doris--oceanbase:
-    image: oceanbase/oceanbase-ce:4.2.1-lts
+    image: quay.io/oceanbase/obbinlog-ce:4.2.5-test
     restart: always
     environment:
       MODE: slim
       OB_MEMORY_LIMIT: 5G
+      PASSWORD: 123456
       TZ: Asia/Shanghai
     ports:
       - ${DOCKER_OCEANBASE_EXTERNAL_PORT}:2881
+      - ${DOCKER_OCEANBASE_PROXY_EXTERNAL_PORT}:2883
     healthcheck:
-      test: ["CMD-SHELL", "obclient -h127.1 -P2881 -uroot@test -e 'SELECT * 
FROM doris_test.all_types limit 1'"]
+      test: ["CMD-SHELL", "obclient -h127.0.0.1 -P2881 -uroot@test -p123456 -e 
'SELECT * FROM doris_test.all_types LIMIT 1' >/dev/null && obclient -h127.0.0.1 
-P2883 -uroot@test -p123456 -Nse 'SHOW MASTER STATUS' | grep -q ."]
       interval: 5s
       timeout: 60s
       retries: 120
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java
index b188f265957..3eec77e757b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java
@@ -19,5 +19,6 @@ package org.apache.doris.job.common;
 
 public enum DataSourceType {
     MYSQL,
-    POSTGRES
+    POSTGRES,
+    OCEANBASE
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
index 1c1dc10c0d5..f75bf03c56e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
@@ -17,8 +17,12 @@
 
 package org.apache.doris.job.extensions.insert.streaming;
 
+import org.apache.doris.datasource.jdbc.client.JdbcClient;
+import org.apache.doris.datasource.jdbc.client.JdbcClientException;
 import org.apache.doris.job.cdc.DataSourceConfigKeys;
 import org.apache.doris.job.common.DataSourceType;
+import org.apache.doris.job.exception.JobException;
+import org.apache.doris.job.util.StreamingJobUtils;
 import org.apache.doris.nereids.trees.plans.commands.LoadCommand;
 
 import com.fasterxml.jackson.databind.JsonNode;
@@ -26,6 +30,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
 import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
@@ -36,6 +43,7 @@ public class DataSourceConfigValidator {
     // PostgreSQL unquoted identifier: lowercase letters, digits, underscores, 
not starting with a digit.
     private static final Pattern PG_IDENTIFIER_PATTERN = 
Pattern.compile("^[a-z_][a-z0-9_]*$");
     private static final int PG_MAX_IDENTIFIER_LENGTH = 63;
+    private static final String MYSQL_JDBC_URL_PREFIX = "jdbc:mysql://";
 
     private static final Set<String> ALLOW_SOURCE_KEYS = Sets.newHashSet(
             DataSourceConfigKeys.JDBC_URL,
@@ -58,6 +66,12 @@ public class DataSourceConfigValidator {
             DataSourceConfigKeys.SERVER_ID
     );
 
+    private static final Set<String> OCEANBASE_UNSUPPORTED_KEYS = 
Sets.newHashSet(
+            DataSourceConfigKeys.SCHEMA,
+            DataSourceConfigKeys.SLOT_NAME,
+            DataSourceConfigKeys.PUBLICATION_NAME
+    );
+
     private static final Set<String> ALLOW_SSL_MODES = Sets.newHashSet(
             DataSourceConfigKeys.SSL_MODE_DISABLE,
             DataSourceConfigKeys.SSL_MODE_REQUIRE,
@@ -106,14 +120,72 @@ public class DataSourceConfigValidator {
                 throw new IllegalArgumentException("Unexpected key: '" + key + 
"'");
             }
 
+            if 
(DataSourceType.OCEANBASE.name().equalsIgnoreCase(dataSourceType)
+                    && OCEANBASE_UNSUPPORTED_KEYS.contains(key)) {
+                throw new IllegalArgumentException(
+                        "Property '" + key + "' is not supported for 
OceanBase");
+            }
+
             if (!isValidValue(key, value, dataSourceType)) {
                 throw new IllegalArgumentException("Invalid value for key '" + 
key + "': " + value);
             }
         }
 
+        validateOceanBaseSource(input, dataSourceType);
         validateSslVerifyCaPair(input);
     }
 
+    private static void validateOceanBaseSource(Map<String, String> input, 
String dataSourceType) {
+        if (!DataSourceType.OCEANBASE.name().equalsIgnoreCase(dataSourceType)) 
{
+            return;
+        }
+        if (input.containsKey(DataSourceConfigKeys.JDBC_URL)
+                && 
!input.get(DataSourceConfigKeys.JDBC_URL).startsWith(MYSQL_JDBC_URL_PREFIX)) {
+            throw new IllegalArgumentException(
+                    "OceanBase jdbc_url must start with '" + 
MYSQL_JDBC_URL_PREFIX + "'");
+        }
+    }
+
+    public static void validateSourceBeforeTableCreation(
+            DataSourceType sourceType, Map<String, String> sourceProperties) 
throws JobException {
+        if (sourceType == DataSourceType.OCEANBASE) {
+            validateOceanBaseCompatibilityMode(sourceProperties);
+        }
+    }
+
+    private static void validateOceanBaseCompatibilityMode(Map<String, String> 
sourceProperties)
+            throws JobException {
+        // jdbc:mysql routes through JdbcMySQLClient so Connector/J is 
initialized consistently.
+        JdbcClient jdbcClient = StreamingJobUtils.getJdbcClient(
+                DataSourceType.OCEANBASE, sourceProperties);
+        try (Connection connection = jdbcClient.getConnection();
+                Statement statement = connection.createStatement();
+                ResultSet resultSet = statement.executeQuery(
+                        "SHOW VARIABLES LIKE 'ob_compatibility_mode'")) {
+            if (!resultSet.next()) {
+                throw new JobException("Failed to determine OceanBase 
compatibility mode");
+            }
+            String compatibilityMode = resultSet.getString(2);
+            if ("MYSQL".equalsIgnoreCase(compatibilityMode)) {
+                return;
+            }
+            if ("ORACLE".equalsIgnoreCase(compatibilityMode)) {
+                throw new JobException(
+                        "OceanBase Oracle compatibility mode is not supported 
for streaming jobs");
+            }
+            throw new JobException(
+                    "Unsupported OceanBase compatibility mode: " + 
compatibilityMode);
+        } catch (JobException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new JobException(
+                    "Failed to validate OceanBase compatibility mode: "
+                            + JdbcClientException.getAllExceptionMessages(e), 
e);
+        } finally {
+            jdbcClient.closeClient();
+        }
+    }
+
     // Cross-field: verify-ca must be paired with a CA cert; otherwise the 
reader will
     // silently fall back to the JVM default truststore and likely fail to 
connect.
     public static void validateSslVerifyCaPair(Map<String, String> input) 
throws IllegalArgumentException {
@@ -294,7 +366,7 @@ public class DataSourceConfigValidator {
     /**
      * Check if the offset value is valid for the given data source type.
      * Supported: initial, snapshot, latest, JSON binlog/lsn position.
-     * earliest is only supported for MySQL.
+     * earliest is only supported for MySQL-compatible sources.
      */
     public static boolean isValidOffset(String offset, String dataSourceType) {
         if (offset == null || offset.isEmpty()) {
@@ -305,9 +377,10 @@ public class DataSourceConfigValidator {
                 || 
DataSourceConfigKeys.OFFSET_SNAPSHOT.equalsIgnoreCase(offset)) {
             return true;
         }
-        // earliest only for MySQL
+        // earliest only for MySQL-compatible sources
         if (DataSourceConfigKeys.OFFSET_EARLIEST.equalsIgnoreCase(offset)) {
-            return 
DataSourceType.MYSQL.name().equalsIgnoreCase(dataSourceType);
+            return DataSourceType.MYSQL.name().equalsIgnoreCase(dataSourceType)
+                    || 
DataSourceType.OCEANBASE.name().equalsIgnoreCase(dataSourceType);
         }
         if (isJsonOffset(offset)) {
             return true;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
index 28b684a7e03..cc7e7e34a18 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
@@ -247,9 +247,9 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
     }
 
     /**
-     * Initialize job from source to database, like multi table mysql to doris.
-     * 1. get mysql connection info from sourceProperties
-     * 2. fetch table list from mysql
+     * Initialize a multi-table job from an external database source to Doris.
+     * 1. get source connection info from sourceProperties
+     * 2. fetch the source table list
      * 3. create doris table if not exists
      * 4. check whether need full data sync
      * 5. need => fetch split and write to system table
@@ -258,6 +258,8 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         try {
             init();
             checkRequiredSourceProperties();
+            DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                    dataSourceType, sourceProperties);
             List<String> createTbls = createTableIfNotExists();
             this.syncTables = createTbls;
             if (sourceProperties.get(DataSourceConfigKeys.INCLUDE_TABLES) == 
null) {
@@ -271,7 +273,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
             this.offsetProvider.initOnCreate(this.syncTables);
         } catch (Exception ex) {
             log.warn("init streaming job for {} failed", dataSourceType, ex);
-            throw new RuntimeException(ex.getMessage());
+            throw new RuntimeException(ex.getMessage(), ex);
         }
     }
 
@@ -298,7 +300,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
     private List<String> createTableIfNotExists() throws Exception {
         List<String> syncTbls = new ArrayList<>();
         Map<String, String> effectiveSourceProperties = 
buildConvertedSourceProperties(sourceProperties);
-        // Key: source table name (PG/MySQL); Value: CreateTableCommand for 
the Doris target table.
+        // Key: source table name; Value: CreateTableCommand for the Doris 
target table.
         // The two names differ when "table.<src>.target_table" is configured.
         LinkedHashMap<String, CreateTableCommand> createTblCmds =
                 StreamingJobUtils.generateCreateTableCmds(targetDb,
@@ -311,7 +313,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
             if 
(!db.isTableExist(createTblCmd.getCreateTableInfo().getTableName())) {
                 createTblCmd.run(ConnectContext.get(), null);
             }
-            // Use the source (upstream) table name so CDC monitors the 
correct PG/MySQL table
+            // Use the upstream table name so CDC monitors the correct source 
table.
             syncTbls.add(srcTable);
         }
         return syncTbls;
@@ -654,10 +656,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         return runningStreamTask;
     }
 
-    /**
-     * for From MySQL TO Database
-     * @return
-     */
+    /** Create a task for a FROM source TO DATABASE streaming job. */
     private AbstractStreamingTask createStreamingMultiTblTask() throws 
JobException {
         return new StreamingMultiTblTask(getJobId(), 
Env.getCurrentEnv().getNextId(), dataSourceType,
                 offsetProvider, getConvertedSourceProperties(), targetDb, 
targetProperties, jobProperties,
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizer.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizer.java
index f13d41687df..70f776ab5c1 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizer.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizer.java
@@ -35,6 +35,7 @@ public final class StreamingJdbcUrlNormalizer {
     public static String normalize(DataSourceType sourceType, String jdbcUrl) {
         switch (sourceType) {
             case MYSQL:
+            case OCEANBASE:
                 return normalizeMysql(jdbcUrl);
             case POSTGRES:
                 return jdbcUrl;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
index 033cc4404bb..1ef6bf18093 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
@@ -359,7 +359,7 @@ public class StreamingJobUtils {
      * <p>Returns a {@link LinkedHashMap} whose key is the <b>source</b> 
(upstream) table name and
      * whose value is the corresponding {@link CreateTableCommand} that 
creates the Doris target
      * table (which may have a different name when {@code 
table.<src>.target_table} is configured).
-     * Callers must use the map key as the PG/MySQL source table identifier 
for CDC monitoring and
+     * Callers must use the map key as the upstream source table identifier 
for CDC monitoring and
      * the {@link CreateTableCommand} value for the actual DDL execution.
      */
     public static LinkedHashMap<String, CreateTableCommand> 
generateCreateTableCmds(String targetDb,
@@ -520,14 +520,6 @@ public class StreamingJobUtils {
         return columns;
     }
 
-    /**
-     * The remoteDB implementation differs for each data source;
-     * refer to the hierarchical mapping in the JDBC catalog.
-     */
-    /**
-     * Populate default resource names into properties, then validate. No-op 
for sources that
-     * don't need it. Mutates properties: callers should expect default values 
to be inserted.
-     */
     public static void resolveAndValidateSource(DataSourceType sourceType,
                                                 Map<String, String> properties,
                                                 String jobId,
@@ -591,10 +583,15 @@ public class StreamingJobUtils {
         }
     }
 
+    /**
+     * The remoteDB implementation differs for each data source;
+     * refer to the hierarchical mapping in the JDBC catalog.
+     */
     public static String getRemoteDbName(DataSourceType sourceType, 
Map<String, String> properties) {
         String remoteDb = null;
         switch (sourceType) {
             case MYSQL:
+            case OCEANBASE:
                 remoteDb = properties.get(DataSourceConfigKeys.DATABASE);
                 Preconditions.checkArgument(StringUtils.isNotEmpty(remoteDb), 
"database is required");
                 break;
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidatorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidatorTest.java
index 84cc3bdf5e0..3765fd10189 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidatorTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidatorTest.java
@@ -17,12 +17,20 @@
 
 package org.apache.doris.job.extensions.insert.streaming;
 
+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.exception.JobException;
+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.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -398,4 +406,184 @@ public class DataSourceConfigValidatorTest {
         props.put(DataSourceConfigKeys.SNAPSHOT_PARALLELISM, "4");
         DataSourceConfigValidator.validateSource(props, 
DataSourceType.MYSQL.name());
     }
+
+    @Test
+    public void testOceanBaseAcceptsMysqlJdbcUrl() {
+        Map<String, String> props = new HashMap<>();
+        props.put(DataSourceConfigKeys.JDBC_URL, 
"jdbc:mysql://localhost:2883/test_db");
+
+        DataSourceConfigValidator.validateSource(props, 
DataSourceType.OCEANBASE.name());
+    }
+
+    @Test
+    public void testOceanBaseAllowsPartialPropertiesWithoutJdbcUrl() {
+        Map<String, String> props = new HashMap<>();
+        props.put(DataSourceConfigKeys.SNAPSHOT_PARALLELISM, "2");
+
+        DataSourceConfigValidator.validateSource(props, 
DataSourceType.OCEANBASE.name());
+    }
+
+    @Test
+    public void testOceanBaseRejectsNonMysqlJdbcUrl() {
+        Map<String, String> props = new HashMap<>();
+        props.put(DataSourceConfigKeys.JDBC_URL, 
"jdbc:oceanbase://localhost:2883/test_db");
+
+        IllegalArgumentException exception = 
Assert.assertThrows(IllegalArgumentException.class,
+                () -> DataSourceConfigValidator.validateSource(
+                        props, DataSourceType.OCEANBASE.name()));
+
+        Assert.assertTrue(exception.getMessage().contains("jdbc:mysql://"));
+    }
+
+    @Test
+    public void testOceanBaseRejectsPostgresProperties() {
+        for (String key : new String[] {
+                DataSourceConfigKeys.SCHEMA,
+                DataSourceConfigKeys.SLOT_NAME,
+                DataSourceConfigKeys.PUBLICATION_NAME}) {
+            Map<String, String> props = new HashMap<>();
+            props.put(key, "value");
+
+            IllegalArgumentException exception = Assert.assertThrows(
+                    IllegalArgumentException.class,
+                    () -> DataSourceConfigValidator.validateSource(
+                            props, DataSourceType.OCEANBASE.name()));
+            Assert.assertTrue(exception.getMessage().contains(key));
+        }
+    }
+
+    @Test
+    public void testOceanBaseDoesNotExposeSchemaChangeEnabled() {
+        Map<String, String> props = new HashMap<>();
+        props.put(DataSourceConfigKeys.SCHEMA_CHANGE_ENABLED, "false");
+
+        IllegalArgumentException exception = 
Assert.assertThrows(IllegalArgumentException.class,
+                () -> DataSourceConfigValidator.validateSource(
+                        props, DataSourceType.OCEANBASE.name()));
+
+        
Assert.assertTrue(exception.getMessage().contains(DataSourceConfigKeys.SCHEMA_CHANGE_ENABLED));
+    }
+
+    @Test
+    public void testOceanBaseSupportsEarliestOffset() {
+        Assert.assertTrue(DataSourceConfigValidator.isValidOffset(
+                DataSourceConfigKeys.OFFSET_EARLIEST, 
DataSourceType.OCEANBASE.name()));
+        Assert.assertFalse(DataSourceConfigValidator.isValidOffset(
+                DataSourceConfigKeys.OFFSET_EARLIEST, 
DataSourceType.POSTGRES.name()));
+    }
+
+    @Test
+    public void testCompatibilityModePreflightIsNoopForExistingSources() 
throws Exception {
+        DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                DataSourceType.MYSQL, new HashMap<>());
+        DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                DataSourceType.POSTGRES, new HashMap<>());
+    }
+
+    @Test
+    public void testOceanBaseMysqlCompatibilityModePasses() throws Exception {
+        JdbcClient jdbcClient = mockOceanBaseCompatibilityMode(true, "MYSQL");
+
+        try (MockedStatic<StreamingJobUtils> utils = 
Mockito.mockStatic(StreamingJobUtils.class)) {
+            utils.when(() -> StreamingJobUtils.getJdbcClient(
+                            Mockito.eq(DataSourceType.OCEANBASE), 
Mockito.anyMap()))
+                    .thenReturn(jdbcClient);
+
+            DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                    DataSourceType.OCEANBASE, new HashMap<>());
+        }
+
+        Mockito.verify(jdbcClient).closeClient();
+    }
+
+    @Test
+    public void testOceanBaseOracleCompatibilityModeIsRejected() throws 
Exception {
+        JdbcClient jdbcClient = mockOceanBaseCompatibilityMode(true, "ORACLE");
+
+        try (MockedStatic<StreamingJobUtils> utils = 
Mockito.mockStatic(StreamingJobUtils.class)) {
+            utils.when(() -> StreamingJobUtils.getJdbcClient(
+                            Mockito.eq(DataSourceType.OCEANBASE), 
Mockito.anyMap()))
+                    .thenReturn(jdbcClient);
+
+            JobException exception = Assert.assertThrows(JobException.class,
+                    () -> 
DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                            DataSourceType.OCEANBASE, new HashMap<>()));
+            Assert.assertTrue(exception.getMessage().contains("Oracle 
compatibility mode"));
+        }
+
+        Mockito.verify(jdbcClient).closeClient();
+    }
+
+    @Test
+    public void testOceanBaseUnknownCompatibilityModeIsRejected() throws 
Exception {
+        JdbcClient jdbcClient = mockOceanBaseCompatibilityMode(true, 
"UNKNOWN");
+
+        try (MockedStatic<StreamingJobUtils> utils = 
Mockito.mockStatic(StreamingJobUtils.class)) {
+            utils.when(() -> StreamingJobUtils.getJdbcClient(
+                            Mockito.eq(DataSourceType.OCEANBASE), 
Mockito.anyMap()))
+                    .thenReturn(jdbcClient);
+
+            JobException exception = Assert.assertThrows(JobException.class,
+                    () -> 
DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                            DataSourceType.OCEANBASE, new HashMap<>()));
+            Assert.assertTrue(exception.getMessage().contains("UNKNOWN"));
+        }
+
+        Mockito.verify(jdbcClient).closeClient();
+    }
+
+    @Test
+    public void testOceanBaseEmptyCompatibilityModeResultIsRejected() throws 
Exception {
+        JdbcClient jdbcClient = mockOceanBaseCompatibilityMode(false, null);
+
+        try (MockedStatic<StreamingJobUtils> utils = 
Mockito.mockStatic(StreamingJobUtils.class)) {
+            utils.when(() -> StreamingJobUtils.getJdbcClient(
+                            Mockito.eq(DataSourceType.OCEANBASE), 
Mockito.anyMap()))
+                    .thenReturn(jdbcClient);
+
+            JobException exception = Assert.assertThrows(JobException.class,
+                    () -> 
DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                            DataSourceType.OCEANBASE, new HashMap<>()));
+            Assert.assertTrue(exception.getMessage().contains("Failed to 
determine"));
+        }
+
+        Mockito.verify(jdbcClient).closeClient();
+    }
+
+    @Test
+    public void testOceanBaseCompatibilityModeQueryFailurePreservesCause() 
throws Exception {
+        JdbcClient jdbcClient = Mockito.mock(JdbcClient.class);
+        Connection connection = Mockito.mock(Connection.class);
+        Mockito.when(jdbcClient.getConnection()).thenReturn(connection);
+        Mockito.when(connection.createStatement()).thenThrow(new 
IllegalStateException("query failed"));
+
+        try (MockedStatic<StreamingJobUtils> utils = 
Mockito.mockStatic(StreamingJobUtils.class)) {
+            utils.when(() -> StreamingJobUtils.getJdbcClient(
+                            Mockito.eq(DataSourceType.OCEANBASE), 
Mockito.anyMap()))
+                    .thenReturn(jdbcClient);
+
+            JobException exception = Assert.assertThrows(JobException.class,
+                    () -> 
DataSourceConfigValidator.validateSourceBeforeTableCreation(
+                            DataSourceType.OCEANBASE, new HashMap<>()));
+            Assert.assertTrue(exception.getMessage().contains("query failed"));
+            Assert.assertNotNull(exception.getCause());
+        }
+
+        Mockito.verify(jdbcClient).closeClient();
+    }
+
+    private JdbcClient mockOceanBaseCompatibilityMode(boolean hasResult, 
String mode)
+            throws Exception {
+        JdbcClient jdbcClient = Mockito.mock(JdbcClient.class);
+        Connection connection = Mockito.mock(Connection.class);
+        Statement statement = Mockito.mock(Statement.class);
+        ResultSet resultSet = Mockito.mock(ResultSet.class);
+        Mockito.when(jdbcClient.getConnection()).thenReturn(connection);
+        Mockito.when(connection.createStatement()).thenReturn(statement);
+        Mockito.when(statement.executeQuery("SHOW VARIABLES LIKE 
'ob_compatibility_mode'"))
+                .thenReturn(resultSet);
+        Mockito.when(resultSet.next()).thenReturn(hasResult);
+        Mockito.when(resultSet.getString(2)).thenReturn(mode);
+        return jdbcClient;
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizerTest.java
index bfdc7ec695d..2dda159a11d 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJdbcUrlNormalizerTest.java
@@ -68,6 +68,16 @@ public class StreamingJdbcUrlNormalizerTest {
                 StreamingJdbcUrlNormalizer.normalize(DataSourceType.MYSQL, 
jdbcUrl));
     }
 
+    @Test
+    public void testNormalizeOceanBaseJdbcUrlUsesMysqlRules() {
+        String jdbcUrl = StreamingJdbcUrlNormalizer.normalize(
+                DataSourceType.OCEANBASE, "jdbc:mysql://127.0.0.1:2883/test");
+
+        
Assert.assertEquals("jdbc:mysql://127.0.0.1:2883/test?yearIsDateType=false"
+                        + 
"&tinyInt1isBit=false&useUnicode=true&characterEncoding=utf-8",
+                jdbcUrl);
+    }
+
     @Test
     public void testNormalizePostgresJdbcUrlDoesNotChange() {
         String jdbcUrl = "jdbc:postgresql://127.0.0.1:5432/test";
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
index 3e090d4d3fe..93c19074cd1 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/util/StreamingJobUtilsTest.java
@@ -21,6 +21,8 @@ import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.PrimitiveType;
 import org.apache.doris.catalog.ScalarType;
 import org.apache.doris.datasource.jdbc.client.JdbcClient;
+import org.apache.doris.job.cdc.DataSourceConfigKeys;
+import org.apache.doris.job.common.DataSourceType;
 
 import org.junit.Assert;
 import org.junit.Before;
@@ -32,7 +34,9 @@ import org.mockito.MockitoAnnotations;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class StreamingJobUtilsTest {
 
@@ -237,4 +241,13 @@ public class StreamingJobUtilsTest {
         Assert.assertNotNull(normalVarcharColumn);
         Assert.assertEquals(150, normalVarcharColumn.getType().getLength()); 
// 50 * 3
     }
+
+    @Test
+    public void testGetOceanBaseRemoteDbName() {
+        Map<String, String> properties = new HashMap<>();
+        properties.put(DataSourceConfigKeys.DATABASE, "test_db");
+
+        Assert.assertEquals("test_db",
+                StreamingJobUtils.getRemoteDbName(DataSourceType.OCEANBASE, 
properties));
+    }
 }
diff --git 
a/fs_brokers/cdc_client/src/main/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSource.java
 
b/fs_brokers/cdc_client/src/main/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSource.java
index bbf973641e4..275ec709211 100644
--- 
a/fs_brokers/cdc_client/src/main/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSource.java
+++ 
b/fs_brokers/cdc_client/src/main/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSource.java
@@ -84,13 +84,18 @@ import java.util.function.Predicate;
 import static io.debezium.util.Strings.isNullOrEmpty;
 
 /**
- * Copied from FlinkCDC project(3.5.0).
+ * Copied from FlinkCDC project(3.6.0).
  *
- * <p>Line 924 : change Log Level info to debug.
+ * <p>Line 940 : change Log Level info to debug.
+ *
+ * <p>Line 420 : exclude OceanBase heartbeat events from restart event 
counting.
  */
 public class MySqlStreamingChangeEventSource
         implements StreamingChangeEventSource<MySqlPartition, 
MySqlOffsetContext> {
 
+    public static final String EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT =
+            "doris.cdc.exclude.heartbeat.from.event.count";
+
     private static final Logger LOGGER =
             LoggerFactory.getLogger(MySqlStreamingChangeEventSource.class);
 
@@ -103,6 +108,7 @@ public class MySqlStreamingChangeEventSource
     private final Clock clock;
     private final EventProcessingFailureHandlingMode 
eventDeserializationFailureHandlingMode;
     private final EventProcessingFailureHandlingMode 
inconsistentSchemaHandlingMode;
+    private final boolean excludeHeartbeatFromEventCount;
 
     private int startingRowNumber = 0;
     private long initialEventsToSkip = 0L;
@@ -226,6 +232,8 @@ public class MySqlStreamingChangeEventSource
             }
         }
         Configuration configuration = connectorConfig.getConfig();
+        excludeHeartbeatFromEventCount =
+                configuration.getBoolean(EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT, 
false);
         
client.setKeepAlive(configuration.getBoolean(MySqlConnectorConfig.KEEP_ALIVE));
         final long keepAliveInterval =
                 
configuration.getLong(MySqlConnectorConfig.KEEP_ALIVE_INTERVAL_MS);
@@ -408,7 +416,10 @@ public class MySqlStreamingChangeEventSource
             eventDispatcher.dispatchHeartbeatEvent(partition, offsetContext);
 
             // Capture that we've completed another event ...
-            offsetContext.completeEvent();
+            // OceanBase heartbeat events must not participate in restart 
event counting.
+            if (shouldCompleteEvent(excludeHeartbeatFromEventCount, 
eventType)) {
+                offsetContext.completeEvent();
+            }
 
             // update last offset used for logging
             lastOffset = offsetContext.getOffset();
@@ -521,6 +532,11 @@ public class MySqlStreamingChangeEventSource
         }
     }
 
+    static boolean shouldCompleteEvent(
+            boolean excludeHeartbeatFromEventCount, EventType eventType) {
+        return !excludeHeartbeatFromEventCount || eventType != 
EventType.HEARTBEAT;
+    }
+
     /**
      * Handle the supplied event with a {@link RotateEventData} that signals 
the logs are being
      * rotated. This means that either the server was restarted, or the binlog 
has transitioned to a
@@ -937,11 +953,18 @@ public class MySqlStreamingChangeEventSource
             int count = 0;
             int numRows = rows.size();
             if (startingRowNumber < numRows) {
-                for (int row = startingRowNumber; row != numRows; ++row) {
-                    offsetContext.setRowNumber(row, numRows);
-                    offsetContext.event(tableId, eventTimestamp);
-                    changeEmitter.emit(tableId, rows.get(row));
-                    count++;
+                // Use iterator to avoid O(n²) complexity when rows is a 
LinkedList
+                // (mysql-binlog-connector-java uses LinkedList in 
WriteRowsEventDataDeserializer
+                // and DeleteRowsEventDataDeserializer)
+                int rowIndex = 0;
+                for (U rowData : rows) {
+                    if (rowIndex >= startingRowNumber) {
+                        offsetContext.setRowNumber(rowIndex, numRows);
+                        offsetContext.event(tableId, eventTimestamp);
+                        changeEmitter.emit(tableId, rowData);
+                        count++;
+                    }
+                    rowIndex++;
                 }
                 if (LOGGER.isDebugEnabled()) {
                     if (startingRowNumber != 0) {
@@ -1273,8 +1296,8 @@ public class MySqlStreamingChangeEventSource
 
                     keyManagers = kmf.getKeyManagers();
                 } catch (KeyStoreException
-                         | NoSuchAlgorithmException
-                         | UnrecoverableKeyException e) {
+                        | NoSuchAlgorithmException
+                        | UnrecoverableKeyException e) {
                     throw new DebeziumException("Could not load keystore", e);
                 }
             }
@@ -1288,23 +1311,23 @@ public class MySqlStreamingChangeEventSource
                 if (ks == null && (sslMode == SSLMode.PREFERRED || sslMode == 
SSLMode.REQUIRED)) {
                     trustManagers =
                             new TrustManager[] {
-                                    new X509TrustManager() {
-
-                                        @Override
-                                        public void checkClientTrusted(
-                                                X509Certificate[] 
x509Certificates, String s)
-                                                throws CertificateException {}
-
-                                        @Override
-                                        public void checkServerTrusted(
-                                                X509Certificate[] 
x509Certificates, String s)
-                                                throws CertificateException {}
-
-                                        @Override
-                                        public X509Certificate[] 
getAcceptedIssuers() {
-                                            return new X509Certificate[0];
-                                        }
+                                new X509TrustManager() {
+
+                                    @Override
+                                    public void checkClientTrusted(
+                                            X509Certificate[] 
x509Certificates, String s)
+                                            throws CertificateException {}
+
+                                    @Override
+                                    public void checkServerTrusted(
+                                            X509Certificate[] 
x509Certificates, String s)
+                                            throws CertificateException {}
+
+                                    @Override
+                                    public X509Certificate[] 
getAcceptedIssuers() {
+                                        return new X509Certificate[0];
                                     }
+                                }
                             };
                 } else {
                     TrustManagerFactory tmf =
@@ -1402,7 +1425,6 @@ public class MySqlStreamingChangeEventSource
         GtidSet mergedGtidSet;
 
         if (connectorConfig.gtidNewChannelPosition() == 
GtidNewChannelPosition.EARLIEST) {
-            final GtidSet knownGtidSet = filteredGtidSet;
             LOGGER.info("Using first available positions for new GTID 
channels");
             final GtidSet relevantAvailableServerGtidSet =
                     (gtidSourceFilter != null)
@@ -1422,14 +1444,16 @@ public class MySqlStreamingChangeEventSource
             // recorded offset in the checkpoint, and the available GTID for 
other MySQL instances
             // should be completed.
             mergedGtidSet =
-                    GtidUtils.fixRestoredGtidSet(
-                            GtidUtils.mergeGtidSetInto(
-                                    relevantAvailableServerGtidSet.retainAll(
-                                            uuid -> 
knownGtidSet.forServerWithId(uuid) != null),
-                                    purgedServerGtid),
-                            filteredGtidSet);
+                    GtidUtils.fixOldChannelsGtidSet(
+                            relevantAvailableServerGtidSet, purgedServerGtid, 
filteredGtidSet);
         } else {
-            mergedGtidSet = availableServerGtidSet.with(filteredGtidSet);
+            LOGGER.info("Using latest positions for new GTID channels");
+            mergedGtidSet =
+                    GtidUtils.computeLatestModeGtidSet(
+                            availableServerGtidSet,
+                            purgedServerGtid,
+                            filteredGtidSet,
+                            gtidSourceFilter);
         }
 
         LOGGER.info("Final merged GTID set to use when connecting to MySQL: 
{}", mergedGtidSet);
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/DataSource.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/DataSource.java
index 904cc32a537..fe819b8a5e7 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/DataSource.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/DataSource.java
@@ -19,5 +19,6 @@ package org.apache.doris.cdcclient.source.factory;
 
 public enum DataSource {
     MYSQL,
-    POSTGRES
+    POSTGRES,
+    OCEANBASE
 }
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactory.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactory.java
index 216c2514f42..c0eb9b35788 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactory.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactory.java
@@ -19,6 +19,7 @@ package org.apache.doris.cdcclient.source.factory;
 
 import org.apache.doris.cdcclient.source.reader.SourceReader;
 import org.apache.doris.cdcclient.source.reader.mysql.MySqlSourceReader;
+import 
org.apache.doris.cdcclient.source.reader.oceanbase.OceanBaseSourceReader;
 import org.apache.doris.cdcclient.source.reader.postgres.PostgresSourceReader;
 
 import java.util.Map;
@@ -38,6 +39,7 @@ public final class SourceReaderFactory {
     static {
         register(DataSource.MYSQL, MySqlSourceReader::new);
         register(DataSource.POSTGRES, PostgresSourceReader::new);
+        register(DataSource.OCEANBASE, OceanBaseSourceReader::new);
     }
 
     private SourceReaderFactory() {}
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
index b9277075513..11075ea2d81 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
@@ -92,6 +92,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
+import static 
io.debezium.connector.mysql.MySqlStreamingChangeEventSource.EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT;
 import static 
org.apache.doris.cdcclient.common.Constants.DEBEZIUM_HEARTBEAT_INTERVAL_MS;
 import static org.apache.doris.cdcclient.utils.ConfigUtil.is13Timestamp;
 import static org.apache.doris.cdcclient.utils.ConfigUtil.isJson;
@@ -141,6 +142,11 @@ public class MySqlSourceReader extends 
AbstractCdcSourceReader {
         this.snapshotReaderContexts = new CopyOnWriteArrayList<>();
     }
 
+    /** Whether server heartbeat events should be excluded from the restart 
event count. */
+    protected boolean excludeHeartbeatFromEventCount() {
+        return false;
+    }
+
     @Override
     public void initialize(String jobId, DataSource dataSource, Map<String, 
String> config) {
         this.serializer.init(config);
@@ -995,6 +1001,9 @@ public class MySqlSourceReader extends 
AbstractCdcSourceReader {
         dbzProps.setProperty(
                 MySqlConnectorConfig.KEEP_ALIVE_INTERVAL_MS.name(),
                 DEBEZIUM_HEARTBEAT_INTERVAL_MS + "");
+        dbzProps.setProperty(
+                EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT,
+                Boolean.toString(excludeHeartbeatFromEventCount()));
 
         if (cdcConfig.containsKey(DataSourceConfigKeys.SSL_MODE)) {
             String normalized =
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/oceanbase/OceanBaseSourceReader.java
similarity index 62%
copy from 
fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java
copy to 
fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/oceanbase/OceanBaseSourceReader.java
index b188f265957..c6509fc1405 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/common/DataSourceType.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/oceanbase/OceanBaseSourceReader.java
@@ -15,9 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-package org.apache.doris.job.common;
+package org.apache.doris.cdcclient.source.reader.oceanbase;
 
-public enum DataSourceType {
-    MYSQL,
-    POSTGRES
+import org.apache.doris.cdcclient.source.reader.mysql.MySqlSourceReader;
+
+public class OceanBaseSourceReader extends MySqlSourceReader {
+
+    @Override
+    protected boolean excludeHeartbeatFromEventCount() {
+        // OceanBase Binlog Service heartbeat events are not guaranteed to 
replay at the same
+        // position, so they must not participate in transaction restart event 
counting.
+        return true;
+    }
 }
diff --git 
a/fs_brokers/cdc_client/src/test/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSourceTest.java
 
b/fs_brokers/cdc_client/src/test/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSourceTest.java
new file mode 100644
index 00000000000..2f012526b31
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/io/debezium/connector/mysql/MySqlStreamingChangeEventSourceTest.java
@@ -0,0 +1,48 @@
+// 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 io.debezium.connector.mysql;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.github.shyiko.mysql.binlog.event.EventType;
+import org.junit.jupiter.api.Test;
+
+class MySqlStreamingChangeEventSourceTest {
+
+    @Test
+    void oceanBaseHeartbeatDoesNotCompleteEvent() {
+        assertFalse(
+                MySqlStreamingChangeEventSource.shouldCompleteEvent(
+                        true, EventType.HEARTBEAT));
+    }
+
+    @Test
+    void oceanBaseBinlogEventCompletesEvent() {
+        assertTrue(
+                MySqlStreamingChangeEventSource.shouldCompleteEvent(
+                        true, EventType.WRITE_ROWS));
+    }
+
+    @Test
+    void mysqlHeartbeatKeepsExistingCompletionBehavior() {
+        assertTrue(
+                MySqlStreamingChangeEventSource.shouldCompleteEvent(
+                        false, EventType.HEARTBEAT));
+    }
+}
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
index cdc8dc4a62e..07e5b40a811 100644
--- 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/CdcClientWriteHarness.java
@@ -94,6 +94,57 @@ final class CdcClientWriteHarness implements AutoCloseable {
             String offset,
             String targetDb,
             MockDorisServer mock) {
+        return mysqlCompatible(
+                jobId,
+                "MYSQL",
+                host,
+                port,
+                user,
+                password,
+                database,
+                includeTables,
+                offset,
+                targetDb,
+                mock);
+    }
+
+    static CdcClientWriteHarness oceanbase(
+            String jobId,
+            String host,
+            int port,
+            String user,
+            String password,
+            String database,
+            String includeTables,
+            String offset,
+            String targetDb,
+            MockDorisServer mock) {
+        return mysqlCompatible(
+                jobId,
+                "OCEANBASE",
+                host,
+                port,
+                user,
+                password,
+                database,
+                includeTables,
+                offset,
+                targetDb,
+                mock);
+    }
+
+    private static CdcClientWriteHarness mysqlCompatible(
+            String jobId,
+            String dataSource,
+            String host,
+            int port,
+            String user,
+            String password,
+            String database,
+            String includeTables,
+            String offset,
+            String targetDb,
+            MockDorisServer mock) {
         Map<String, String> config = new HashMap<>();
         config.put(
                 DataSourceConfigKeys.JDBC_URL,
@@ -108,7 +159,7 @@ final class CdcClientWriteHarness implements AutoCloseable {
         // Point cdc_client's stream-load at the mock BE.
         Env.getCurrentEnv().setBackendHttpPort(mock.port());
         Env.getCurrentEnv().setClusterToken("test");
-        return new CdcClientWriteHarness(jobId, "MYSQL", config, targetDb, 
mock);
+        return new CdcClientWriteHarness(jobId, dataSource, config, targetDb, 
mock);
     }
 
     static CdcClientWriteHarness postgres(
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseSchemaChangeITCase.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseSchemaChangeITCase.java
new file mode 100644
index 00000000000..1de7e36cfb0
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseSchemaChangeITCase.java
@@ -0,0 +1,121 @@
+// 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.cdcclient.itcase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.doris.cdcclient.common.Env;
+import org.apache.doris.job.cdc.split.SnapshotSplit;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+class OceanBaseSchemaChangeITCase extends OceanBaseTestBase {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final AtomicLong JOB_ID_SEQ = new AtomicLong(1_300_000);
+
+    private String jobId;
+    private String database;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        jobId = String.valueOf(JOB_ID_SEQ.incrementAndGet());
+        database = "oceanbase_schema_" + jobId;
+        createDatabase(
+                database,
+                "CREATE TABLE t_user (id INT NOT NULL, name VARCHAR(50), 
PRIMARY KEY (id))",
+                "INSERT INTO t_user VALUES (1,'alice'), (2,'bob')");
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        Env.getCurrentEnv().close(jobId);
+        dropDatabase(database);
+    }
+
+    @Test
+    void addAndDropColumnSurviveOffsetCommitFailureAndReaderRebuild() throws 
Exception {
+        try (MockDorisServer mock = new MockDorisServer();
+                CdcClientWriteHarness harness =
+                        oceanBaseHarness(jobId, database, "t_user", "initial", 
mock)) {
+            List<SnapshotSplit> splits = 
harness.fetchAllSnapshotSplits("t_user");
+            harness.writeSnapshot(splits);
+            harness.enterBinlog(splits);
+            String offsetBeforeAlter = harness.committedOffset();
+            String schemasBeforeAlter = harness.committedTableSchemas();
+            assertThat(schemasBeforeAlter).isNotNull().doesNotContain("city");
+
+            execute(
+                    "ALTER TABLE t_user ADD COLUMN city VARCHAR(50)",
+                    "INSERT INTO t_user (id, name, city) VALUES (3, 'carol', 
'beijing')");
+
+            mock.failCommitOffset();
+            assertThatThrownBy(() -> harness.continueBinlog(1, 
Duration.ofSeconds(90)))
+                    .isInstanceOf(Exception.class);
+            assertThat(mock.failedCommitOffsetCount()).isPositive();
+            assertThat(harness.committedOffset()).isEqualTo(offsetBeforeAlter);
+            
assertThat(harness.committedTableSchemas()).isEqualTo(schemasBeforeAlter);
+
+            mock.allowCommitOffset();
+            harness.rebuildReaderOnNextWrite();
+            List<String> retried = harness.continueBinlog(1, 
Duration.ofSeconds(90));
+
+            assertThat(harness.executedDdls()).hasSize(2);
+            assertThat(harness.executedDdls()).allMatch(ddl -> 
ddl.contains("ADD COLUMN"));
+            assertThat(mock.ddlResponses().get(1))
+                    .contains("Can not add column which already exists");
+            assertThat(retried).hasSize(1);
+            JsonNode added = MAPPER.readTree(retried.get(0));
+            assertThat(added.get("id").asInt()).isEqualTo(3);
+            assertThat(added.get("city").asText()).isEqualTo("beijing");
+            assertThat(harness.committedTableSchemas()).contains("city");
+
+            execute(
+                    "ALTER TABLE t_user DROP COLUMN city",
+                    "INSERT INTO t_user (id, name) VALUES (4, 'dave')");
+            List<String> afterDrop = harness.continueBinlog(1, 
Duration.ofSeconds(90));
+
+            assertThat(harness.executedDdls().get(2)).contains("DROP 
COLUMN").contains("city");
+            assertThat(afterDrop).hasSize(1);
+            JsonNode dropped = MAPPER.readTree(afterDrop.get(0));
+            assertThat(dropped.get("id").asInt()).isEqualTo(4);
+            assertThat(dropped.has("city")).isFalse();
+            assertThat(harness.committedTableSchemas()).doesNotContain("city");
+        }
+    }
+
+    private void execute(String... statements) throws Exception {
+        try (Connection connection = connection(database);
+                Statement statement = connection.createStatement()) {
+            for (String sql : statements) {
+                statement.execute(sql);
+            }
+        }
+    }
+}
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseStartupOffsetITCase.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseStartupOffsetITCase.java
new file mode 100644
index 00000000000..7b6980cd9ad
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseStartupOffsetITCase.java
@@ -0,0 +1,148 @@
+// 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.cdcclient.itcase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.apache.doris.cdcclient.common.Env;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+class OceanBaseStartupOffsetITCase extends OceanBaseTestBase {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final AtomicLong JOB_ID_SEQ = new AtomicLong(1_200_000);
+
+    private String jobId;
+    private String database;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        jobId = String.valueOf(JOB_ID_SEQ.incrementAndGet());
+        database = "oceanbase_offset_" + jobId;
+        createDatabase(
+                database,
+                "CREATE TABLE t_user (id INT NOT NULL, name VARCHAR(50), 
PRIMARY KEY (id))",
+                "INSERT INTO t_user VALUES (1,'alice'), (2,'bob')");
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        Env.getCurrentEnv().close(jobId);
+        dropDatabase(database);
+    }
+
+    @Test
+    void latestReadsOnlyChangesAfterReaderStarts() throws Exception {
+        awaitInitialRowsInBinlog();
+
+        try (MockDorisServer mock = new MockDorisServer();
+                CdcClientWriteHarness harness =
+                        oceanBaseHarness(jobId, database, "t_user", "latest", 
mock)) {
+            harness.enterBinlogFromStartupMode();
+            insert("INSERT INTO t_user VALUES (3,'carol')");
+
+            assertThat(ids(harness.continueBinlog(1, Duration.ofSeconds(90))))
+                    .containsExactly(3);
+            assertThat(ids(harness.loadedRecords())).doesNotContain(1, 2);
+        }
+    }
+
+    @Test
+    void earliestReplaysExistingBinlogChanges() throws Exception {
+        try (MockDorisServer mock = new MockDorisServer();
+                CdcClientWriteHarness harness =
+                        oceanBaseHarness(jobId, database, "t_user", 
"earliest", mock)) {
+            assertThat(ids(harness.readBinlogFromStartupMode(2, 
Duration.ofSeconds(90))))
+                    .containsExactlyInAnyOrder(1, 2);
+        }
+    }
+
+    @Test
+    void specificOffsetReplaysChangesAfterRecordedPosition() throws Exception {
+        awaitInitialRowsInBinlog();
+
+        String[] position = currentBinlogPosition();
+        String offset =
+                String.format(
+                        "{\"file\":\"%s\",\"pos\":\"%s\"}", position[0], 
position[1]);
+        insert("INSERT INTO t_user VALUES (3,'carol')");
+
+        try (MockDorisServer mock = new MockDorisServer();
+                CdcClientWriteHarness harness =
+                        oceanBaseHarness(jobId, database, "t_user", offset, 
mock)) {
+            assertThat(ids(harness.readBinlogFromStartupMode(1, 
Duration.ofSeconds(90))))
+                    .containsExactly(3);
+            assertThat(ids(harness.loadedRecords())).doesNotContain(1, 2);
+        }
+    }
+
+    private void awaitInitialRowsInBinlog() throws Exception {
+        String probeJobId = String.valueOf(JOB_ID_SEQ.incrementAndGet());
+
+        try (MockDorisServer mock = new MockDorisServer();
+                CdcClientWriteHarness harness =
+                        oceanBaseHarness(
+                                probeJobId, database, "t_user", "earliest", 
mock)) {
+            assertThat(
+                            ids(
+                                    harness.readBinlogFromStartupMode(
+                                            2, Duration.ofSeconds(90))))
+                    .containsExactlyInAnyOrder(1, 2);
+        }
+    }
+
+    private String[] currentBinlogPosition() throws Exception {
+        try (Connection connection = connection(database);
+                Statement statement = connection.createStatement();
+                ResultSet resultSet = statement.executeQuery("SHOW MASTER 
STATUS")) {
+            if (!resultSet.next()) {
+                throw new IllegalStateException("SHOW MASTER STATUS returned 
no row");
+            }
+            return new String[] {resultSet.getString("File"), 
resultSet.getString("Position")};
+        }
+    }
+
+    private void insert(String sql) throws Exception {
+        try (Connection connection = connection(database);
+                Statement statement = connection.createStatement()) {
+            statement.execute(sql);
+        }
+    }
+
+    private List<Integer> ids(List<String> records) throws Exception {
+        List<Integer> result = new ArrayList<>();
+        for (String record : records) {
+            JsonNode node = MAPPER.readTree(record);
+            result.add(node.get("id").asInt());
+        }
+        return result;
+    }
+}
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseTestBase.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseTestBase.java
new file mode 100644
index 00000000000..a6157335c3d
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseTestBase.java
@@ -0,0 +1,150 @@
+// 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.cdcclient.itcase;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
+import org.testcontainers.utility.DockerImageName;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.Duration;
+
+abstract class OceanBaseTestBase {
+
+    protected static final int OCEANBASE_CDC_PORT = 2883;
+    protected static final String USER = "root@test";
+    protected static final String PASSWORD = "123456";
+
+    private static final String EXTERNAL_HOST = 
System.getProperty("oceanbase.host");
+    private static final int EXTERNAL_CDC_PORT =
+            Integer.getInteger("oceanbase.cdc.port", OCEANBASE_CDC_PORT);
+
+    static final GenericContainer<?> OCEANBASE =
+            new GenericContainer<>(
+                            
DockerImageName.parse("quay.io/oceanbase/obbinlog-ce:4.2.5-test"))
+                    .withExposedPorts(2881, OCEANBASE_CDC_PORT)
+                    .withStartupTimeout(Duration.ofMinutes(6))
+                    .waitingFor(
+                            new LogMessageWaitStrategy()
+                                    .withRegEx(".*OBBinlog is ready!.*")
+                                    .withTimes(1)
+                                    
.withStartupTimeout(Duration.ofMinutes(6)));
+
+    @BeforeAll
+    static void initializeOceanBase() {
+        if (!useExternalOceanBase()) {
+            OCEANBASE.start();
+        }
+
+        Awaitility.await()
+                .atMost(60, SECONDS)
+                .pollInterval(1, SECONDS)
+                .ignoreExceptions()
+                .until(
+                        () -> {
+                            try (Connection connection = connection("");
+                                    Statement statement = 
connection.createStatement();
+                                    ResultSet resultSet =
+                                            statement.executeQuery("SHOW 
MASTER STATUS")) {
+                                return connection.isValid(1)
+                                        && resultSet.next()
+                                        && 
!resultSet.getString("File").isEmpty()
+                                        && resultSet.getLong("Position") >= 4;
+                            }
+                        });
+    }
+
+    @AfterAll
+    static void stopOceanBase() {
+        if (!useExternalOceanBase()) {
+            OCEANBASE.stop();
+        }
+    }
+
+    protected static Connection connection(String database) throws Exception {
+        String url =
+                "jdbc:mysql://"
+                        + oceanBaseHost()
+                        + ":"
+                        + oceanBaseCdcPort()
+                        + "/"
+                        + database
+                        + "?serverTimezone=UTC";
+        return DriverManager.getConnection(url, USER, PASSWORD);
+    }
+
+    protected void createDatabase(String database, String... statements) 
throws Exception {
+        try (Connection connection = connection("");
+                Statement statement = connection.createStatement()) {
+            statement.execute("DROP DATABASE IF EXISTS " + database);
+            statement.execute("CREATE DATABASE " + database);
+            statement.execute("USE " + database);
+            for (String sql : statements) {
+                statement.execute(sql);
+            }
+        }
+    }
+
+    protected void dropDatabase(String database) throws Exception {
+        try (Connection connection = connection("");
+                Statement statement = connection.createStatement()) {
+            statement.execute("DROP DATABASE IF EXISTS " + database);
+        }
+    }
+
+    protected CdcClientWriteHarness oceanBaseHarness(
+            String jobId,
+            String database,
+            String includeTables,
+            String offset,
+            MockDorisServer mock) {
+        return CdcClientWriteHarness.oceanbase(
+                jobId,
+                oceanBaseHost(),
+                oceanBaseCdcPort(),
+                USER,
+                PASSWORD,
+                database,
+                includeTables,
+                offset,
+                "doris_target_db",
+                mock);
+    }
+
+    private static boolean useExternalOceanBase() {
+        return EXTERNAL_HOST != null;
+    }
+
+    private static String oceanBaseHost() {
+        return useExternalOceanBase() ? EXTERNAL_HOST : OCEANBASE.getHost();
+    }
+
+    private static int oceanBaseCdcPort() {
+        return useExternalOceanBase()
+                ? EXTERNAL_CDC_PORT
+                : OCEANBASE.getMappedPort(OCEANBASE_CDC_PORT);
+    }
+}
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseWriteDmlITCase.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseWriteDmlITCase.java
new file mode 100644
index 00000000000..eb7159ab7e7
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/itcase/OceanBaseWriteDmlITCase.java
@@ -0,0 +1,112 @@
+// 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.cdcclient.itcase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.apache.doris.cdcclient.common.Constants;
+import org.apache.doris.cdcclient.common.Env;
+import org.apache.doris.job.cdc.split.SnapshotSplit;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+class OceanBaseWriteDmlITCase extends OceanBaseTestBase {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final AtomicLong JOB_ID_SEQ = new AtomicLong(1_100_000);
+
+    private String jobId;
+    private String database;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        jobId = String.valueOf(JOB_ID_SEQ.incrementAndGet());
+        database = "oceanbase_dml_" + jobId;
+        createDatabase(
+                database,
+                "CREATE TABLE t_user (id INT NOT NULL, name VARCHAR(50), 
PRIMARY KEY (id))",
+                "INSERT INTO t_user VALUES (1,'alice'), (2,'bob'), 
(3,'carol')");
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        Env.getCurrentEnv().close(jobId);
+        dropDatabase(database);
+    }
+
+    @Test
+    void snapshotAndBinlogDmlUseOceanBaseReader() throws Exception {
+        try (MockDorisServer mock = new MockDorisServer();
+                CdcClientWriteHarness harness =
+                        oceanBaseHarness(jobId, database, "t_user", "initial", 
mock)) {
+            List<SnapshotSplit> splits = 
harness.fetchAllSnapshotSplits("t_user");
+            harness.writeSnapshot(splits);
+
+            Map<Integer, JsonNode> snapshotById = 
indexById(harness.loadedRecords());
+            assertThat(snapshotById).containsOnlyKeys(1, 2, 3);
+            harness.enterBinlog(splits);
+
+            try (Connection connection = connection(database);
+                    Statement statement = connection.createStatement()) {
+                statement.execute("INSERT INTO t_user VALUES (4, 'dave')");
+                statement.execute("UPDATE t_user SET name = 'alice2' WHERE id 
= 1");
+                statement.execute("DELETE FROM t_user WHERE id = 2");
+            }
+
+            List<String> binlog = harness.continueBinlog(3, 
Duration.ofSeconds(90));
+            assertThat(binlog).hasSize(3);
+            Map<Integer, JsonNode> binlogById = indexById(binlog);
+            assertThat(binlogById).containsOnlyKeys(1, 2, 4);
+            
assertThat(binlogById.get(4).get("name").asText()).isEqualTo("dave");
+            
assertThat(binlogById.get(4).get(Constants.DORIS_DELETE_SIGN).asInt()).isZero();
+            
assertThat(binlogById.get(1).get("name").asText()).isEqualTo("alice2");
+            
assertThat(binlogById.get(1).get(Constants.DORIS_DELETE_SIGN).asInt()).isZero();
+            
assertThat(binlogById.get(2).get(Constants.DORIS_DELETE_SIGN).asInt()).isEqualTo(1);
+
+            harness.rebuildReaderOnNextWrite();
+            try (Connection connection = connection(database);
+                    Statement statement = connection.createStatement()) {
+                statement.execute("INSERT INTO t_user VALUES (5, 'eve')");
+            }
+
+            List<String> resumed = harness.continueBinlog(1, 
Duration.ofSeconds(90));
+            assertThat(indexById(resumed)).containsOnlyKeys(5);
+        }
+    }
+
+    private Map<Integer, JsonNode> indexById(List<String> records) throws 
Exception {
+        Map<Integer, JsonNode> result = new HashMap<>();
+        for (String record : records) {
+            JsonNode node = MAPPER.readTree(record);
+            result.put(node.get("id").asInt(), node);
+        }
+        return result;
+    }
+}
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactoryTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactoryTest.java
index bee4d048c62..8dd8651221a 100644
--- 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactoryTest.java
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/factory/SourceReaderFactoryTest.java
@@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame;
 
 import org.apache.doris.cdcclient.source.reader.SourceReader;
 import org.apache.doris.cdcclient.source.reader.mysql.MySqlSourceReader;
+import 
org.apache.doris.cdcclient.source.reader.oceanbase.OceanBaseSourceReader;
 import org.apache.doris.cdcclient.source.reader.postgres.PostgresSourceReader;
 
 import org.junit.jupiter.api.Test;
@@ -42,6 +43,14 @@ class SourceReaderFactoryTest {
                 SourceReaderFactory.createSourceReader(DataSource.POSTGRES));
     }
 
+    @Test
+    void createsOceanBaseReader() {
+        SourceReader reader = 
SourceReaderFactory.createSourceReader(DataSource.OCEANBASE);
+
+        assertInstanceOf(OceanBaseSourceReader.class, reader);
+        assertInstanceOf(MySqlSourceReader.class, reader);
+    }
+
     @Test
     void eachCallReturnsFreshInstance() {
         SourceReader first = 
SourceReaderFactory.createSourceReader(DataSource.MYSQL);
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
index 4136e4e617a..8905e0379e2 100644
--- 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import 
org.apache.doris.cdcclient.source.reader.oceanbase.OceanBaseSourceReader;
 import org.apache.doris.job.cdc.DataSourceConfigKeys;
 import org.apache.doris.job.cdc.request.JobBaseConfig;
 
@@ -48,6 +49,8 @@ import io.debezium.relational.history.TableChanges;
 public class MySqlSourceReaderTest {
 
     private static final String SERVER_UUID = 
"24bc7850-2c16-11e6-a073-0242ac110002";
+    private static final String EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT =
+            "doris.cdc.exclude.heartbeat.from.event.count";
 
     @Test
     void testNormalizeSslModeMapsAllLegalValues() {
@@ -126,6 +129,26 @@ public class MySqlSourceReaderTest {
         assertFalse(config.isIncludeSchemaChanges());
     }
 
+    @Test
+    void mysqlHeartbeatContributesToRestartEventCount() throws Exception {
+        MySqlSourceConfig config =
+                sourceConfig(new MySqlSourceReader(), "initial", Map.of());
+
+        assertFalse(
+                config.getDbzConfiguration()
+                        .getBoolean(EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT));
+    }
+
+    @Test
+    void oceanBaseHeartbeatDoesNotContributeToRestartEventCount() throws 
Exception {
+        MySqlSourceConfig config =
+                sourceConfig(new OceanBaseSourceReader(), "initial", Map.of());
+
+        assertTrue(
+                config.getDbzConfiguration()
+                        .getBoolean(EXCLUDE_HEARTBEAT_FROM_EVENT_COUNT));
+    }
+
     @Test
     void snapshotSplitContainsOnlyCurrentTableSchema() throws Exception {
         TableId tableId = TableId.parse("testdb.orders");
@@ -205,6 +228,12 @@ public class MySqlSourceReaderTest {
 
     private MySqlSourceConfig sourceConfig(String offset, Map<String, String> 
overrides)
             throws Exception {
+        return sourceConfig(new MySqlSourceReader(), offset, overrides);
+    }
+
+    private MySqlSourceConfig sourceConfig(
+            MySqlSourceReader reader, String offset, Map<String, String> 
overrides)
+            throws Exception {
         Map<String, String> cfg = new HashMap<>();
         cfg.put(DataSourceConfigKeys.JDBC_URL, 
"jdbc:mysql://localhost:3306/testdb");
         cfg.put(DataSourceConfigKeys.USER, "u");
@@ -217,7 +246,7 @@ public class MySqlSourceReaderTest {
                 MySqlSourceReader.class.getDeclaredMethod(
                         "generateMySqlConfig", Map.class, String.class, 
int.class);
         m.setAccessible(true);
-        return (MySqlSourceConfig) m.invoke(new MySqlSourceReader(), cfg, 
"job-1", 0);
+        return (MySqlSourceConfig) m.invoke(reader, cfg, "job-1", 0);
     }
 
     private static Map<String, Object> snapshotOffset(String tableId, String 
splitKey) {
diff --git a/regression-test/conf/regression-conf.groovy 
b/regression-test/conf/regression-conf.groovy
index ec01b743813..afbe0b1a5c3 100644
--- a/regression-test/conf/regression-conf.groovy
+++ b/regression-test/conf/regression-conf.groovy
@@ -135,6 +135,7 @@ doris_port=9030
 mariadb_10_port=3326
 db2_11_port=50000
 oceanbase_port=2881
+oceanbase_cdc_port=2883
 
 // hive catalog test config
 // To enable hive/paimon test, you need first start hive container.
@@ -335,4 +336,4 @@ hudiMinioSecretKey="minio123"
 icebergDlfRestCatalog="'type' = 'iceberg', 'warehouse' = 
'new_dlf_iceberg_catalog', 'iceberg.catalog.type' = 'rest', 'iceberg.rest.uri' 
= 'http://cn-beijing-vpc.dlf.aliyuncs.com/iceberg', 
'iceberg.rest.sigv4-enabled' = 'true', 'iceberg.rest.signing-name' = 'DlfNext', 
'iceberg.rest.access-key-id' = 'ak', 'iceberg.rest.secret-access-key' = 'sk', 
'iceberg.rest.signing-region' = 'cn-beijing', 
'iceberg.rest.vended-credentials-enabled' = 'true', 'io-impl' = 
'org.apache.iceberg.rest.DlfFileIO', [...]
 
 // For python UDF test, set the runtime version of python, default: 3.8.10
-// pythonUdfRuntimeVersion = ""
\ No newline at end of file
+// pythonUdfRuntimeVersion = ""
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job.out
 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job.out
new file mode 100644
index 00000000000..26ee9fd67b5
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job.out
@@ -0,0 +1,17 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !oceanbase_snapshot_users --
+1      Alice   18
+2      Bob     20
+
+-- !oceanbase_snapshot_orders --
+10     snapshot_order
+
+-- !oceanbase_snapshot_empty --
+
+-- !oceanbase_incremental_users --
+2      Bob     21
+3      Carol   30
+
+-- !oceanbase_incremental_orders --
+10     snapshot_order
+11     incremental_order
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_all_type.out
 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_all_type.out
new file mode 100644
index 00000000000..2ee538b7866
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_all_type.out
@@ -0,0 +1,8 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !oceanbase_all_type_snapshot --
+1      200     18446744073709551610    123456789.12345 1       2026-07-10      
2026-07-10T10:11:12.123456      2026-07-10T10:11:12.123456      abc     
snapshot        snapshot text   41514944        {"id": 1, "name": "snapshot"}   
A,C     NEW     4367734D
+2      \N      \N      \N      \N      \N      \N      \N      \N      \N      
\N      \N      \N      \N      \N      \N
+
+-- !oceanbase_all_type_incremental --
+1      200     18446744073709551610    1.25000 1       2026-07-10      
2026-07-10T10:11:12.123456      2026-07-10T02:11:12.123456      abc     updated 
snapshot text   41514944        {"id":1,"name":"snapshot"}      A,C     NEW     
4367734D
+3      100     9000000000      -98765.43210    0       2026-07-11      
2026-07-11T11:12:13.654321      2026-07-11T03:12:13.654321      xyz     
incremental     incremental text        2F2B3764        
{"id":3,"name":"incremental"}   B       DONE    71383376
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_offset.out
 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_offset.out
new file mode 100644
index 00000000000..8786bd3dd06
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_offset.out
@@ -0,0 +1,11 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !oceanbase_offset_latest --
+2      after_latest
+
+-- !oceanbase_offset_earliest --
+1      earliest_one
+2      earliest_two
+
+-- !oceanbase_offset_specific --
+10     specific_one
+11     specific_two
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_restart_fe.out
 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_restart_fe.out
new file mode 100644
index 00000000000..4f8f344724e
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_restart_fe.out
@@ -0,0 +1,5 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !oceanbase_restart_fe --
+1      snapshot
+2      updated_after_restart
+3      after_restart
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_sc_restart_fe.out
 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_sc_restart_fe.out
new file mode 100644
index 00000000000..bbda3141447
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_sc_restart_fe.out
@@ -0,0 +1,11 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !oceanbase_sc_after_restart --
+1      snapshot        \N
+2      before_restart  updated_after_restart
+3      after_restart   schema_after_restart
+
+-- !oceanbase_sc_restart_final --
+1      snapshot
+2      before_restart
+3      after_restart
+4      after_drop
diff --git 
a/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_schema_change.out
 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_schema_change.out
new file mode 100644
index 00000000000..a4bebceb4c7
--- /dev/null
+++ 
b/regression-test/data/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_schema_change.out
@@ -0,0 +1,9 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !oceanbase_after_add --
+1      snapshot        \N
+2      after_add       Hangzhou
+
+-- !oceanbase_after_drop --
+1      snapshot
+2      after_add
+3      after_drop
diff --git a/regression-test/pipeline/external/conf/regression-conf.groovy 
b/regression-test/pipeline/external/conf/regression-conf.groovy
index 5cc507a9a14..d6679434ecc 100644
--- a/regression-test/pipeline/external/conf/regression-conf.groovy
+++ b/regression-test/pipeline/external/conf/regression-conf.groovy
@@ -190,6 +190,7 @@ oracle_11_port=1521
 sqlserver_2022_port=1433
 clickhouse_22_port=8123
 oceanbase_port=2881
+oceanbase_cdc_port=2883
 db2_11_port=50000
 
 // trino-connector catalog test config
diff --git a/regression-test/suites/doc/external/jdbc/oceanbase.md.groovy 
b/regression-test/suites/doc/external/jdbc/oceanbase.md.groovy
index c17820fbb39..64d3a1efb8d 100644
--- a/regression-test/suites/doc/external/jdbc/oceanbase.md.groovy
+++ b/regression-test/suites/doc/external/jdbc/oceanbase.md.groovy
@@ -30,7 +30,7 @@ suite("oceanbase.md", 
"p2,external,oceanbase,external_docker,external_docker_oce
         sql """create catalog if not exists ${catalog_name} properties(
             "type"="jdbc",
             "user"="root@test",
-            "password"="",
+            "password"="123456",
             "jdbc_url" = 
"jdbc:oceanbase://${externalEnvIp}:${oceanbase_port}/doris_test",
             "driver_url" = "${driver_url}",
             "driver_class" = "com.oceanbase.jdbc.Driver"
diff --git 
a/regression-test/suites/external_table_p2/jdbc/test_oceanbase_jdbc_catalog.groovy
 
b/regression-test/suites/external_table_p2/jdbc/test_oceanbase_jdbc_catalog.groovy
index 7ebc54cd60b..13aadd908ab 100644
--- 
a/regression-test/suites/external_table_p2/jdbc/test_oceanbase_jdbc_catalog.groovy
+++ 
b/regression-test/suites/external_table_p2/jdbc/test_oceanbase_jdbc_catalog.groovy
@@ -32,8 +32,8 @@ suite("test_oceanbase_jdbc_catalog", "p2,external") {
         sql """ create catalog if not exists ${catalog_name} properties(
                     "type"="jdbc",
                     "user"="root@test",
-                    "password"="",
-                    "jdbc_url" = 
"jdbc:oceanbase://${externalEnvIp}:${oceanbase_port}/doris_test",
+                    "password"="123456",
+                    "jdbc_url" = 
"jdbc:oceanbase://${externalEnvIp}:${oceanbase_port}/doris_test?serverTimezone=UTC",
                     "driver_url" = "${driver_url}",
                     "driver_class" = "com.oceanbase.jdbc.Driver"
         );"""
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job.groovy
new file mode 100644
index 00000000000..416c8ddc016
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job.groovy
@@ -0,0 +1,143 @@
+// 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
+
+suite("test_streaming_oceanbase_job",
+        
"p2,external,oceanbase,external_docker,external_docker_oceanbase,nondatalake") {
+    def jobName = "test_streaming_oceanbase_job"
+    def currentDb = (sql "SELECT DATABASE()")[0][0]
+    def sourceDb = "test_oceanbase_streaming_db"
+    def table1 = "oceanbase_streaming_users"
+    def table2 = "oceanbase_streaming_orders"
+    def emptyTable = "oceanbase_streaming_empty"
+
+    sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+    sql """DROP TABLE IF EXISTS ${currentDb}.${table1} FORCE"""
+    sql """DROP TABLE IF EXISTS ${currentDb}.${table2} FORCE"""
+    sql """DROP TABLE IF EXISTS ${currentDb}.${emptyTable} FORCE"""
+
+    String enabled = context.config.otherConfigs.get("enableJdbcTest")
+    if (enabled != null && enabled.equalsIgnoreCase("true")) {
+        String oceanbaseCdcPort = 
context.config.otherConfigs.get("oceanbase_cdc_port")
+        String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+        String s3Endpoint = getS3Endpoint()
+        String bucket = getS3BucketName()
+        String driverUrl =
+                
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+
+        def dumpJobState = {
+            log.info("jobs: " + sql("""SELECT * FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""))
+            log.info("tasks: " + sql("""SELECT * FROM tasks("type"="insert") 
WHERE JobName='${jobName}'"""))
+        }
+
+        connect("root@test", "123456", 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}") {
+            sql """CREATE DATABASE IF NOT EXISTS ${sourceDb}"""
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${table1}"""
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${table2}"""
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${emptyTable}"""
+            sql """CREATE TABLE ${sourceDb}.${table1} (
+                        id INT NOT NULL,
+                        name VARCHAR(100),
+                        age INT,
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """CREATE TABLE ${sourceDb}.${table2} (
+                        id INT NOT NULL,
+                        description VARCHAR(100),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """CREATE TABLE ${sourceDb}.${emptyTable} (
+                        id INT NOT NULL,
+                        value VARCHAR(100),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES
+                        (1, 'Alice', 18),
+                        (2, 'Bob', 20)"""
+            sql """INSERT INTO ${sourceDb}.${table2} VALUES (10, 
'snapshot_order')"""
+        }
+
+        sql """CREATE JOB ${jobName}
+                ON STREAMING
+                FROM OCEANBASE (
+                    "jdbc_url" = 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}",
+                    "driver_url" = "${driverUrl}",
+                    "driver_class" = "com.mysql.cj.jdbc.Driver",
+                    "user" = "root@test",
+                    "password" = "123456",
+                    "database" = "${sourceDb}",
+                    "include_tables" = "${table1},${table2},${emptyTable}",
+                    "offset" = "initial"
+                )
+                TO DATABASE ${currentDb} (
+                    "table.create.properties.replication_num" = "1"
+                )"""
+
+        try {
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def users = sql """SELECT COUNT(*) FROM 
${currentDb}.${table1}"""
+                def orders = sql """SELECT COUNT(*) FROM 
${currentDb}.${table2}"""
+                def emptyTables = sql """SHOW TABLES FROM ${currentDb} LIKE 
'${emptyTable}'"""
+                users[0][0] == 2 && orders[0][0] == 1 && emptyTables.size() == 
1
+            })
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        order_qt_oceanbase_snapshot_users """
+            SELECT id, name, age FROM ${currentDb}.${table1} ORDER BY id
+        """
+        order_qt_oceanbase_snapshot_orders """
+            SELECT id, description FROM ${currentDb}.${table2} ORDER BY id
+        """
+        order_qt_oceanbase_snapshot_empty """
+            SELECT id, value FROM ${currentDb}.${emptyTable} ORDER BY id
+        """
+
+        connect("root@test", "123456", 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}") {
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (3, 'Carol', 30)"""
+            sql """UPDATE ${sourceDb}.${table1} SET age=21 WHERE id=2"""
+            sql """DELETE FROM ${sourceDb}.${table1} WHERE id=1"""
+            sql """INSERT INTO ${sourceDb}.${table2} VALUES (11, 
'incremental_order')"""
+        }
+
+        try {
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def users = sql """SELECT id, name, age FROM 
${currentDb}.${table1} ORDER BY id"""
+                def orders = sql """SELECT id, description FROM 
${currentDb}.${table2} ORDER BY id"""
+                users == [[2, 'Bob', 21], [3, 'Carol', 30]] &&
+                        orders == [[10, 'snapshot_order'], [11, 
'incremental_order']]
+            })
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        order_qt_oceanbase_incremental_users """
+            SELECT id, name, age FROM ${currentDb}.${table1} ORDER BY id
+        """
+        order_qt_oceanbase_incremental_orders """
+            SELECT id, description FROM ${currentDb}.${table2} ORDER BY id
+        """
+
+        def status = sql """SELECT Status FROM jobs("type"="insert") WHERE 
Name='${jobName}'"""
+        assert status.size() == 1 && status[0][0] == "RUNNING"
+        sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_all_type.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_all_type.groovy
new file mode 100644
index 00000000000..5d81bb0c2bc
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_all_type.groovy
@@ -0,0 +1,152 @@
+// 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
+
+suite("test_streaming_oceanbase_job_all_type",
+        
"p2,external,oceanbase,external_docker,external_docker_oceanbase,nondatalake") {
+    def jobName = "test_streaming_oceanbase_job_all_type"
+    def currentDb = (sql "SELECT DATABASE()")[0][0]
+    def sourceDb = "test_oceanbase_streaming_db"
+    def table1 = "oceanbase_streaming_all_type"
+
+    sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+    sql """DROP TABLE IF EXISTS ${currentDb}.${table1} FORCE"""
+
+    String enabled = context.config.otherConfigs.get("enableJdbcTest")
+    if (enabled != null && enabled.equalsIgnoreCase("true")) {
+        String oceanbaseCdcPort = 
context.config.otherConfigs.get("oceanbase_cdc_port")
+        String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+        String s3Endpoint = getS3Endpoint()
+        String bucket = getS3BucketName()
+        String driverUrl =
+                
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+        String sourceUrl = 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}?serverTimezone=UTC"
+
+        def dumpJobState = {
+            log.info("jobs: " + sql("""SELECT * FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""))
+            log.info("tasks: " + sql("""SELECT * FROM tasks("type"="insert") 
WHERE JobName='${jobName}'"""))
+        }
+
+        connect("root@test", "123456", sourceUrl) {
+            sql """CREATE DATABASE IF NOT EXISTS ${sourceDb}"""
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${table1}"""
+            sql """CREATE TABLE ${sourceDb}.${table1} (
+                        id INT NOT NULL,
+                        tiny_unsigned TINYINT UNSIGNED,
+                        big_unsigned BIGINT UNSIGNED,
+                        amount DECIMAL(18, 5),
+                        enabled BOOLEAN,
+                        event_date DATE,
+                        event_datetime DATETIME(6),
+                        event_timestamp TIMESTAMP(6) NULL,
+                        fixed_text CHAR(5),
+                        variable_text VARCHAR(32),
+                        long_text TEXT,
+                        binary_value BLOB,
+                        json_value JSON,
+                        set_value SET('A', 'B', 'C'),
+                        enum_value ENUM('NEW', 'DONE'),
+                        varbinary_value VARBINARY(16),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (
+                        1, 200, 18446744073709551610, 123456789.12345, TRUE,
+                        '2026-07-10', '2026-07-10 10:11:12.123456',
+                        '2026-07-10 10:11:12.123456', 'abc', 'snapshot',
+                        'snapshot text', X'010203', 
'{"id":1,"name":"snapshot"}',
+                        'A,C', 'NEW', X'0A0B0C'
+                    )"""
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (
+                        2, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 
NULL,
+                        NULL, NULL, NULL, NULL, NULL, NULL
+                    )"""
+        }
+
+        sql """CREATE JOB ${jobName}
+                ON STREAMING
+                FROM OCEANBASE (
+                    "jdbc_url" = "${sourceUrl}",
+                    "driver_url" = "${driverUrl}",
+                    "driver_class" = "com.mysql.cj.jdbc.Driver",
+                    "user" = "root@test",
+                    "password" = "123456",
+                    "database" = "${sourceDb}",
+                    "include_tables" = "${table1}",
+                    "offset" = "initial"
+                )
+                TO DATABASE ${currentDb} (
+                    "table.create.properties.replication_num" = "1"
+                )"""
+
+        try {
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT COUNT(*) FROM 
${currentDb}.${table1}"""
+                rows.size() == 1 && rows[0][0] == 2
+            })
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        order_qt_oceanbase_all_type_snapshot """
+            SELECT id, tiny_unsigned, big_unsigned, amount, enabled, 
event_date,
+                   event_datetime, event_timestamp, fixed_text, variable_text,
+                   long_text, HEX(binary_value), CAST(json_value AS STRING),
+                   set_value, enum_value, HEX(varbinary_value)
+            FROM ${currentDb}.${table1}
+            ORDER BY id
+        """
+
+        connect("root@test", "123456", sourceUrl) {
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (
+                        3, 100, 9000000000, -98765.43210, FALSE,
+                        '2026-07-11', '2026-07-11 11:12:13.654321',
+                        '2026-07-11 11:12:13.654321', 'xyz', 'incremental',
+                        'incremental text', X'FFEEDD', 
'{"id":3,"name":"incremental"}',
+                        'B', 'DONE', X'ABCDEF'
+                    )"""
+            sql """UPDATE ${sourceDb}.${table1}
+                    SET variable_text='updated', amount=1.25000 WHERE id=1"""
+            sql """DELETE FROM ${sourceDb}.${table1} WHERE id=2"""
+        }
+
+        try {
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def count = sql """SELECT COUNT(*) FROM 
${currentDb}.${table1}"""
+                def updated = sql """SELECT variable_text FROM 
${currentDb}.${table1} WHERE id=1"""
+                def inserted = sql """SELECT COUNT(*) FROM 
${currentDb}.${table1} WHERE id=3"""
+                count[0][0] == 2 && updated.size() == 1 && updated[0][0] == 
'updated' &&
+                        inserted[0][0] == 1
+            })
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        order_qt_oceanbase_all_type_incremental """
+            SELECT id, tiny_unsigned, big_unsigned, amount, enabled, 
event_date,
+                   event_datetime, event_timestamp, fixed_text, variable_text,
+                   long_text, HEX(binary_value), CAST(json_value AS STRING),
+                   set_value, enum_value, HEX(varbinary_value)
+            FROM ${currentDb}.${table1}
+            ORDER BY id
+        """
+
+        sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_offset.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_offset.groovy
new file mode 100644
index 00000000000..3fa947c87c5
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_offset.groovy
@@ -0,0 +1,203 @@
+// 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
+
+suite("test_streaming_oceanbase_job_offset",
+        
"p2,external,oceanbase,external_docker,external_docker_oceanbase,nondatalake") {
+    def currentDb = (sql "SELECT DATABASE()")[0][0]
+    def sourceDb = "test_oceanbase_streaming_db"
+    def suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8)
+    def latestJob = "test_streaming_oceanbase_offset_latest"
+    def earliestJob = "test_streaming_oceanbase_offset_earliest"
+    def specificJob = "test_streaming_oceanbase_offset_specific"
+    def latestTable = "oceanbase_offset_latest_${suffix}"
+    def earliestTable = "oceanbase_offset_earliest_${suffix}"
+    def specificTable = "oceanbase_offset_specific_${suffix}"
+
+    [latestJob, earliestJob, specificJob].each {
+        sql """DROP JOB IF EXISTS WHERE jobname='${it}'"""
+    }
+    [latestTable, earliestTable, specificTable].each {
+        sql """DROP TABLE IF EXISTS ${currentDb}.${it} FORCE"""
+    }
+
+    String enabled = context.config.otherConfigs.get("enableJdbcTest")
+    if (enabled != null && enabled.equalsIgnoreCase("true")) {
+        String oceanbaseCdcPort = 
context.config.otherConfigs.get("oceanbase_cdc_port")
+        String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+        String s3Endpoint = getS3Endpoint()
+        String bucket = getS3BucketName()
+        String driverUrl =
+                
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+        String sourceUrl = "jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}"
+
+        def waitForJobReady = { String jobName ->
+            Awaitility.await().atMost(180, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT Status, SucceedTaskCount FROM 
jobs("type"="insert")
+                                   WHERE Name='${jobName}'"""
+                rows.size() == 1 && rows[0][0] == "RUNNING" && (rows[0][1] as 
int) >= 1
+            })
+        }
+        def waitForRows = { String tableName, int count ->
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT COUNT(*) FROM 
${currentDb}.${tableName}"""
+                rows.size() == 1 && rows[0][0] == count
+            })
+        }
+        def waitForRowId = { String tableName, int id ->
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT COUNT(*) FROM 
${currentDb}.${tableName} WHERE id = ${id}"""
+                rows.size() == 1 && rows[0][0] == 1
+            })
+        }
+        def dumpJobState = { String jobName ->
+            log.info("jobs: " + sql("""SELECT * FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""))
+            log.info("tasks: " + sql("""SELECT * FROM tasks("type"="insert") 
WHERE JobName='${jobName}'"""))
+        }
+
+        connect("root@test", "123456", sourceUrl) {
+            sql """CREATE DATABASE IF NOT EXISTS ${sourceDb}"""
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${latestTable}"""
+            sql """CREATE TABLE ${sourceDb}.${latestTable} (
+                        id INT NOT NULL,
+                        name VARCHAR(100),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """INSERT INTO ${sourceDb}.${latestTable} VALUES (1, 
'before_latest')"""
+        }
+
+        // Consume later records first to ensure before_latest is visible in 
OBBinlog.
+        def binlogFile = ""
+        def binlogPosition = ""
+        connect("root@test", "123456", sourceUrl) {
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${specificTable}"""
+            sql """CREATE TABLE ${sourceDb}.${specificTable} (
+                        id INT NOT NULL,
+                        name VARCHAR(100),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            def masterStatus = sql """SHOW MASTER STATUS"""
+            binlogFile = masterStatus[0][0]
+            binlogPosition = masterStatus[0][1].toString()
+            sql """INSERT INTO ${sourceDb}.${specificTable} VALUES
+                        (10, 'specific_one'),
+                        (11, 'specific_two')"""
+        }
+        def offsetJson = 
"""{"file":"${binlogFile}","pos":"${binlogPosition}"}"""
+
+        sql """CREATE JOB ${specificJob}
+                ON STREAMING
+                FROM OCEANBASE (
+                    "jdbc_url" = "${sourceUrl}",
+                    "driver_url" = "${driverUrl}",
+                    "driver_class" = "com.mysql.cj.jdbc.Driver",
+                    "user" = "root@test",
+                    "password" = "123456",
+                    "database" = "${sourceDb}",
+                    "include_tables" = "${specificTable}",
+                    "offset" = '${offsetJson}'
+                )
+                TO DATABASE ${currentDb} (
+                    "table.create.properties.replication_num" = "1"
+                )"""
+
+        try {
+            waitForRows(specificTable, 2)
+        } catch (Exception ex) {
+            dumpJobState(specificJob)
+            throw ex
+        }
+        sql """DROP JOB IF EXISTS WHERE jobname='${specificJob}'"""
+
+        sql """CREATE JOB ${latestJob}
+                ON STREAMING
+                FROM OCEANBASE (
+                    "jdbc_url" = "${sourceUrl}",
+                    "driver_url" = "${driverUrl}",
+                    "driver_class" = "com.mysql.cj.jdbc.Driver",
+                    "user" = "root@test",
+                    "password" = "123456",
+                    "database" = "${sourceDb}",
+                    "include_tables" = "${latestTable}",
+                    "offset" = "latest"
+                )
+                TO DATABASE ${currentDb} (
+                    "table.create.properties.replication_num" = "1"
+                )"""
+
+        try {
+            waitForJobReady(latestJob)
+            connect("root@test", "123456", sourceUrl) {
+                sql """INSERT INTO ${sourceDb}.${latestTable} VALUES (2, 
'after_latest')"""
+            }
+            waitForRowId(latestTable, 2)
+        } catch (Exception ex) {
+            dumpJobState(latestJob)
+            throw ex
+        }
+
+        order_qt_oceanbase_offset_latest """
+            SELECT id, name FROM ${currentDb}.${latestTable} ORDER BY id
+        """
+        sql """DROP JOB IF EXISTS WHERE jobname='${latestJob}'"""
+
+        connect("root@test", "123456", sourceUrl) {
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${earliestTable}"""
+            sql """CREATE TABLE ${sourceDb}.${earliestTable} (
+                        id INT NOT NULL,
+                        name VARCHAR(100),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """INSERT INTO ${sourceDb}.${earliestTable} VALUES
+                        (1, 'earliest_one'),
+                        (2, 'earliest_two')"""
+        }
+
+        sql """CREATE JOB ${earliestJob}
+                ON STREAMING
+                FROM OCEANBASE (
+                    "jdbc_url" = "${sourceUrl}",
+                    "driver_url" = "${driverUrl}",
+                    "driver_class" = "com.mysql.cj.jdbc.Driver",
+                    "user" = "root@test",
+                    "password" = "123456",
+                    "database" = "${sourceDb}",
+                    "include_tables" = "${earliestTable}",
+                    "offset" = "earliest"
+                )
+                TO DATABASE ${currentDb} (
+                    "table.create.properties.replication_num" = "1"
+                )"""
+
+        try {
+            waitForRows(earliestTable, 2)
+        } catch (Exception ex) {
+            dumpJobState(earliestJob)
+            throw ex
+        }
+
+        order_qt_oceanbase_offset_earliest """
+            SELECT id, name FROM ${currentDb}.${earliestTable} ORDER BY id
+        """
+        sql """DROP JOB IF EXISTS WHERE jobname='${earliestJob}'"""
+
+        order_qt_oceanbase_offset_specific """
+            SELECT id, name FROM ${currentDb}.${specificTable} ORDER BY id
+        """
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_restart_fe.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_restart_fe.groovy
new file mode 100644
index 00000000000..d61732a31f9
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_restart_fe.groovy
@@ -0,0 +1,150 @@
+// 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.apache.doris.regression.suite.ClusterOptions
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_streaming_oceanbase_job_restart_fe",
+        
"p2,docker,oceanbase,external_docker,external_docker_oceanbase,nondatalake") {
+    def jobName = "test_streaming_oceanbase_job_restart_fe"
+    def sourceDb = "test_oceanbase_streaming_db"
+    def table1 = "oceanbase_streaming_restart_fe"
+    def options = new ClusterOptions()
+    options.setFeNum(1)
+    options.cloudMode = null
+
+    docker(options) {
+        def currentDb = (sql "SELECT DATABASE()")[0][0]
+
+        sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+        sql """DROP TABLE IF EXISTS ${currentDb}.${table1} FORCE"""
+
+        String enabled = context.config.otherConfigs.get("enableJdbcTest")
+        if (enabled != null && enabled.equalsIgnoreCase("true")) {
+            String oceanbaseCdcPort = 
context.config.otherConfigs.get("oceanbase_cdc_port")
+            String externalEnvIp = 
context.config.otherConfigs.get("externalEnvIp")
+            String s3Endpoint = getS3Endpoint()
+            String bucket = getS3BucketName()
+            String driverUrl =
+                    
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+            String sourceUrl = 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}"
+
+            def waitForValue = { int id, String expected ->
+                Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                    def rows = sql "SELECT value FROM ${currentDb}.${table1} 
WHERE id=${id}"
+                    rows.size() == 1 && rows[0][0] == expected
+                })
+            }
+            def waitForJobAfterRestart = {
+                Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS)
+                        .ignoreExceptions().until({
+                            context.reconnectFe()
+                            def rows = sql """SELECT Status FROM 
jobs("type"="insert")
+                                              WHERE Name='${jobName}'"""
+                            rows.size() == 1 && rows[0][0] == "RUNNING"
+                        })
+            }
+            def dumpJobState = {
+                log.info("jobs: " + sql("""SELECT * FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""))
+                log.info("tasks: " + sql("""SELECT * FROM 
tasks("type"="insert") WHERE JobName='${jobName}'"""))
+            }
+
+            connect("root@test", "123456", sourceUrl) {
+                sql """CREATE DATABASE IF NOT EXISTS ${sourceDb}"""
+                sql """DROP TABLE IF EXISTS ${sourceDb}.${table1}"""
+                sql """CREATE TABLE ${sourceDb}.${table1} (
+                            id INT NOT NULL,
+                            value VARCHAR(100),
+                            PRIMARY KEY (id)
+                        ) ENGINE=InnoDB"""
+                sql """INSERT INTO ${sourceDb}.${table1} VALUES (1, 
'snapshot')"""
+            }
+
+            sql """CREATE JOB ${jobName}
+                    ON STREAMING
+                    FROM OCEANBASE (
+                        "jdbc_url" = "${sourceUrl}",
+                        "driver_url" = "${driverUrl}",
+                        "driver_class" = "com.mysql.cj.jdbc.Driver",
+                        "user" = "root@test",
+                        "password" = "123456",
+                        "database" = "${sourceDb}",
+                        "include_tables" = "${table1}",
+                        "offset" = "initial"
+                    )
+                    TO DATABASE ${currentDb} (
+                        "table.create.properties.replication_num" = "1"
+                    )"""
+
+            try {
+                waitForValue(1, "snapshot")
+                connect("root@test", "123456", sourceUrl) {
+                    sql """INSERT INTO ${sourceDb}.${table1} VALUES (2, 
'before_restart')"""
+                }
+                waitForValue(2, "before_restart")
+            } catch (Exception ex) {
+                dumpJobState()
+                throw ex
+            }
+
+            def beforeOffset = null
+            Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql """SELECT currentOffset FROM 
jobs("type"="insert")
+                                   WHERE Name='${jobName}'"""
+                if (rows.size() != 1 || rows[0][0] == null) {
+                    return false
+                }
+                def parsed = parseJson(rows[0][0])
+                if (parsed.file == null || parsed.pos == null) {
+                    return false
+                }
+                beforeOffset = parsed
+                return true
+            })
+
+            cluster.restartFrontends()
+            waitForJobAfterRestart()
+            context.reconnectFe()
+
+            def afterOffsetRows = sql """SELECT currentOffset FROM 
jobs("type"="insert")
+                                          WHERE Name='${jobName}'"""
+            assert afterOffsetRows.size() == 1 && afterOffsetRows[0][0] != null
+            def afterOffset = parseJson(afterOffsetRows[0][0])
+            assert afterOffset.file > beforeOffset.file ||
+                    (afterOffset.file == beforeOffset.file &&
+                            (afterOffset.pos as long) >= (beforeOffset.pos as 
long))
+
+            connect("root@test", "123456", sourceUrl) {
+                sql """INSERT INTO ${sourceDb}.${table1} VALUES (3, 
'after_restart')"""
+                sql """UPDATE ${sourceDb}.${table1} SET 
value='updated_after_restart' WHERE id=2"""
+            }
+
+            try {
+                waitForValue(3, "after_restart")
+                waitForValue(2, "updated_after_restart")
+            } catch (Exception ex) {
+                dumpJobState()
+                throw ex
+            }
+
+            order_qt_oceanbase_restart_fe """
+                SELECT id, value FROM ${currentDb}.${table1} ORDER BY id
+            """
+            sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+        }
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_sc_restart_fe.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_sc_restart_fe.groovy
new file mode 100644
index 00000000000..fb07ad59028
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_sc_restart_fe.groovy
@@ -0,0 +1,156 @@
+// 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.apache.doris.regression.suite.ClusterOptions
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_streaming_oceanbase_job_sc_restart_fe",
+        
"p2,docker,oceanbase,external_docker,external_docker_oceanbase,nondatalake") {
+    def jobName = "test_streaming_oceanbase_job_sc_restart_fe"
+    def sourceDb = "test_oceanbase_streaming_db"
+    def table1 = "oceanbase_streaming_sc_restart_fe"
+    def options = new ClusterOptions()
+    options.setFeNum(1)
+    options.cloudMode = null
+
+    docker(options) {
+        def currentDb = (sql "SELECT DATABASE()")[0][0]
+
+        sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+        sql """DROP TABLE IF EXISTS ${currentDb}.${table1} FORCE"""
+
+        String enabled = context.config.otherConfigs.get("enableJdbcTest")
+        if (enabled != null && enabled.equalsIgnoreCase("true")) {
+            String oceanbaseCdcPort = 
context.config.otherConfigs.get("oceanbase_cdc_port")
+            String externalEnvIp = 
context.config.otherConfigs.get("externalEnvIp")
+            String s3Endpoint = getS3Endpoint()
+            String bucket = getS3BucketName()
+            String driverUrl =
+                    
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+            String sourceUrl = 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}"
+
+            def waitForColumn = { String column, boolean expected ->
+                Awaitility.await().atMost(180, SECONDS).pollInterval(2, 
SECONDS).until({
+                    (sql "DESC ${currentDb}.${table1}").any { it[0] == column 
} == expected
+                })
+            }
+            def waitForValue = { int id, String column, String expected ->
+                Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+                    def rows = sql "SELECT ${column} FROM 
${currentDb}.${table1} WHERE id=${id}"
+                    rows.size() == 1 && String.valueOf(rows[0][0]) == expected
+                })
+            }
+            def waitForJobAfterRestart = {
+                Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS)
+                        .ignoreExceptions().until({
+                            context.reconnectFe()
+                            def rows = sql """SELECT Status FROM 
jobs("type"="insert")
+                                              WHERE Name='${jobName}'"""
+                            rows.size() == 1 && rows[0][0] == "RUNNING"
+                        })
+            }
+            def dumpJobState = {
+                log.info("jobs: " + sql("""SELECT * FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""))
+                log.info("tasks: " + sql("""SELECT * FROM 
tasks("type"="insert") WHERE JobName='${jobName}'"""))
+            }
+
+            connect("root@test", "123456", sourceUrl) {
+                sql """CREATE DATABASE IF NOT EXISTS ${sourceDb}"""
+                sql """DROP TABLE IF EXISTS ${sourceDb}.${table1}"""
+                sql """CREATE TABLE ${sourceDb}.${table1} (
+                            id INT NOT NULL,
+                            value VARCHAR(100),
+                            PRIMARY KEY (id)
+                        ) ENGINE=InnoDB"""
+                sql """INSERT INTO ${sourceDb}.${table1} VALUES (1, 
'snapshot')"""
+            }
+
+            sql """CREATE JOB ${jobName}
+                    ON STREAMING
+                    FROM OCEANBASE (
+                        "jdbc_url" = "${sourceUrl}",
+                        "driver_url" = "${driverUrl}",
+                        "driver_class" = "com.mysql.cj.jdbc.Driver",
+                        "user" = "root@test",
+                        "password" = "123456",
+                        "database" = "${sourceDb}",
+                        "include_tables" = "${table1}",
+                        "offset" = "initial"
+                    )
+                    TO DATABASE ${currentDb} (
+                        "table.create.properties.replication_num" = "1"
+                    )"""
+
+            try {
+                waitForValue(1, "value", "snapshot")
+                connect("root@test", "123456", sourceUrl) {
+                    sql """ALTER TABLE ${sourceDb}.${table1} ADD COLUMN 
extra_value VARCHAR(50)"""
+                    sql """INSERT INTO ${sourceDb}.${table1}
+                            VALUES (2, 'before_restart', 
'schema_before_restart')"""
+                }
+                waitForColumn("extra_value", true)
+                waitForValue(2, "extra_value", "schema_before_restart")
+            } catch (Exception ex) {
+                dumpJobState()
+                throw ex
+            }
+
+            cluster.restartFrontends()
+            waitForJobAfterRestart()
+            context.reconnectFe()
+
+            connect("root@test", "123456", sourceUrl) {
+                sql """INSERT INTO ${sourceDb}.${table1}
+                        VALUES (3, 'after_restart', 'schema_after_restart')"""
+                sql """UPDATE ${sourceDb}.${table1}
+                        SET extra_value='updated_after_restart' WHERE id=2"""
+            }
+
+            try {
+                waitForValue(3, "extra_value", "schema_after_restart")
+                waitForValue(2, "extra_value", "updated_after_restart")
+            } catch (Exception ex) {
+                dumpJobState()
+                throw ex
+            }
+
+            order_qt_oceanbase_sc_after_restart """
+                SELECT id, value, extra_value FROM ${currentDb}.${table1} 
ORDER BY id
+            """
+
+            connect("root@test", "123456", sourceUrl) {
+                sql """ALTER TABLE ${sourceDb}.${table1} DROP COLUMN 
extra_value"""
+                sql """INSERT INTO ${sourceDb}.${table1} VALUES (4, 
'after_drop')"""
+            }
+
+            try {
+                waitForColumn("extra_value", false)
+                waitForValue(4, "value", "after_drop")
+            } catch (Exception ex) {
+                dumpJobState()
+                throw ex
+            }
+
+            order_qt_oceanbase_sc_restart_final """
+                SELECT id, value FROM ${currentDb}.${table1} ORDER BY id
+            """
+            def status = sql """SELECT Status FROM jobs("type"="insert") WHERE 
Name='${jobName}'"""
+            assert status.size() == 1 && status[0][0] == "RUNNING"
+            sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+        }
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_schema_change.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_schema_change.groovy
new file mode 100644
index 00000000000..0535ec60792
--- /dev/null
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_oceanbase_job_schema_change.groovy
@@ -0,0 +1,138 @@
+// 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
+
+suite("test_streaming_oceanbase_job_schema_change",
+        
"p2,external,oceanbase,external_docker,external_docker_oceanbase,nondatalake") {
+    def jobName = "test_streaming_oceanbase_job_schema_change"
+    def currentDb = (sql "SELECT DATABASE()")[0][0]
+    def sourceDb = "test_oceanbase_streaming_db"
+    def table1 = "oceanbase_streaming_schema_change"
+
+    sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+    sql """DROP TABLE IF EXISTS ${currentDb}.${table1} FORCE"""
+
+    String enabled = context.config.otherConfigs.get("enableJdbcTest")
+    if (enabled != null && enabled.equalsIgnoreCase("true")) {
+        String oceanbaseCdcPort = 
context.config.otherConfigs.get("oceanbase_cdc_port")
+        String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+        String s3Endpoint = getS3Endpoint()
+        String bucket = getS3BucketName()
+        String driverUrl =
+                
"https://${bucket}.${s3Endpoint}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar";
+
+        def waitForColumn = { String column, boolean expected ->
+            Awaitility.await().atMost(180, SECONDS).pollInterval(2, 
SECONDS).until({
+                (sql "DESC ${currentDb}.${table1}").any { it[0] == column } == 
expected
+            })
+        }
+        def waitForValue = { int id, String column, String expected ->
+            Awaitility.await().atMost(180, SECONDS).pollInterval(2, 
SECONDS).until({
+                def rows = sql "SELECT ${column} FROM ${currentDb}.${table1} 
WHERE id=${id}"
+                rows.size() == 1 && String.valueOf(rows[0][0]) == expected
+            })
+        }
+        def dumpJobState = {
+            log.info("jobs: " + sql("""SELECT * FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""))
+            log.info("tasks: " + sql("""SELECT * FROM tasks("type"="insert") 
WHERE JobName='${jobName}'"""))
+        }
+
+        connect("root@test", "123456", 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}") {
+            sql """CREATE DATABASE IF NOT EXISTS ${sourceDb}"""
+            sql """DROP TABLE IF EXISTS ${sourceDb}.${table1}"""
+            sql """CREATE TABLE ${sourceDb}.${table1} (
+                        id INT NOT NULL,
+                        name VARCHAR(100),
+                        PRIMARY KEY (id)
+                    ) ENGINE=InnoDB"""
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (1, 'snapshot')"""
+        }
+
+        sql """CREATE JOB ${jobName}
+                ON STREAMING
+                FROM OCEANBASE (
+                    "jdbc_url" = 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}",
+                    "driver_url" = "${driverUrl}",
+                    "driver_class" = "com.mysql.cj.jdbc.Driver",
+                    "user" = "root@test",
+                    "password" = "123456",
+                    "database" = "${sourceDb}",
+                    "include_tables" = "${table1}",
+                    "offset" = "initial"
+                )
+                TO DATABASE ${currentDb} (
+                    "table.create.properties.replication_num" = "1"
+                )"""
+
+        try {
+            waitForValue(1, "name", "snapshot")
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        connect("root@test", "123456", 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}") {
+            sql """ALTER TABLE ${sourceDb}.${table1} ADD COLUMN city 
VARCHAR(50)"""
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (2, 'after_add', 
'Hangzhou')"""
+        }
+
+        try {
+            waitForColumn("city", true)
+            waitForValue(2, "city", "Hangzhou")
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        order_qt_oceanbase_after_add """
+            SELECT id, name, city FROM ${currentDb}.${table1} ORDER BY id
+        """
+
+        connect("root@test", "123456", 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}") {
+            sql """UPDATE ${sourceDb}.${table1} SET city='Shanghai' WHERE 
id=2"""
+        }
+
+        try {
+            waitForValue(2, "city", "Shanghai")
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        connect("root@test", "123456", 
"jdbc:mysql://${externalEnvIp}:${oceanbaseCdcPort}") {
+            sql """ALTER TABLE ${sourceDb}.${table1} DROP COLUMN city"""
+            sql """INSERT INTO ${sourceDb}.${table1} VALUES (3, 
'after_drop')"""
+        }
+
+        try {
+            waitForColumn("city", false)
+            waitForValue(3, "name", "after_drop")
+        } catch (Exception ex) {
+            dumpJobState()
+            throw ex
+        }
+
+        order_qt_oceanbase_after_drop """
+            SELECT id, name FROM ${currentDb}.${table1} ORDER BY id
+        """
+
+        def status = sql """SELECT Status FROM jobs("type"="insert") WHERE 
Name='${jobName}'"""
+        assert status.size() == 1 && status[0][0] == "RUNNING"
+        sql """DROP JOB IF EXISTS WHERE jobname='${jobName}'"""
+    }
+}


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

Reply via email to