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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 3ba5470a3d [Feature][Connector-V2] Support timer flush for Doris sink 
(#11506)
3ba5470a3d is described below

commit 3ba5470a3d581dd59b1af14c92d763f34edf3d30
Author: zhiwei.niu <[email protected]>
AuthorDate: Sat Jul 25 21:37:07 2026 +0800

    [Feature][Connector-V2] Support timer flush for Doris sink (#11506)
---
 docs/en/connectors/sink/Doris.md                   |  29 ++-
 docs/zh/connectors/sink/Doris.md                   |  29 ++-
 .../doris/sink/writer/DorisSinkWriter.java         |  13 ++
 .../doris/sink/writer/DorisSinkWriterTest.java     |  66 +++++++
 .../e2e/connector/doris/DorisTimerFlushIT.java     | 200 +++++++++++++++++++++
 .../resources/mysqlcdc_to_doris_timer_flush.conf   |  53 ++++++
 6 files changed, 388 insertions(+), 2 deletions(-)

diff --git a/docs/en/connectors/sink/Doris.md b/docs/en/connectors/sink/Doris.md
index 0ff0b67130..d70c3ac2ee 100644
--- a/docs/en/connectors/sink/Doris.md
+++ b/docs/en/connectors/sink/Doris.md
@@ -21,7 +21,7 @@ import ChangeLog from '../changelog/connector-doris.md';
 - [x] [exactly-once](../../introduction/concepts/connector-v2-features.md)
 - [x] [cdc](../../introduction/concepts/connector-v2-features.md)
 - [x] [support multiple table 
write](../../introduction/concepts/connector-v2-features.md)
-- [ ] [timer flush](../../introduction/concepts/connector-v2-features.md)
+- [x] [timer flush](../../introduction/concepts/connector-v2-features.md)
 
 ## Description
 
@@ -195,6 +195,33 @@ This is because the total amount of data arriving at the 
end may not exceed the
 
 Otherwise, if you enable the 2pc by the property `sink.enable-2pc=true`.The 
`sink.buffer-size` will have no effect. So only the checkpoint can trigger the 
commit.
 
+### Timer flush on Zeta
+
+This engine-level feature is supported only by Zeta. Spark and Flink do not 
inject `FlushSignal` records. On Zeta, configure `sink.flush.interval` in the 
`env` block to finish the current Stream Load before `doris.batch.size` is 
reached.
+
+Timer flush is registered only when `sink.enable-2pc=false`. It is 
intentionally disabled when `sink.enable-2pc=true` because flushing and opening 
a new Stream Load between checkpoints would break the 2PC transaction boundary 
and exactly-once guarantee. The initial timer flush implementation therefore 
provides at-least-once delivery only.
+
+```hocon
+env {
+  job.mode = "STREAMING"
+  checkpoint.interval = 300000
+  sink.flush.interval = 5000
+}
+
+sink {
+  Doris {
+    fenodes = "doris-fe:8030"
+    username = root
+    password = ""
+    database = "mydb"
+    table = "mytable"
+    sink.label-prefix = "timer-flush"
+    sink.enable-2pc = false
+    doris.batch.size = 10000
+  }
+}
+```
+
 ## Troubleshooting 307 Temporary Redirect
 
 If the job fails with `HTTP/1.1 307 Temporary Redirect`, check the following 
items first:
diff --git a/docs/zh/connectors/sink/Doris.md b/docs/zh/connectors/sink/Doris.md
index 32ace9bbda..70af62651b 100644
--- a/docs/zh/connectors/sink/Doris.md
+++ b/docs/zh/connectors/sink/Doris.md
@@ -21,7 +21,7 @@ import ChangeLog from '../changelog/connector-doris.md';
 - [x] [精确一次](../../introduction/concepts/connector-v2-features.md)
 - [x] [变更数据捕获](../../introduction/concepts/connector-v2-features.md)
 - [x] [支持多表写入](../../introduction/concepts/connector-v2-features.md)
-- [ ] [定时刷新](../../introduction/concepts/connector-v2-features.md)
+- [x] [定时刷新](../../introduction/concepts/connector-v2-features.md)
 
 ## 描述
 
@@ -192,6 +192,33 @@ CREATE TABLE IF NOT EXISTS `${database}`.`${table_name}`
 
 此外,如果你通过`sink.enable-2pc=true`属性启用2pc。`sink.buffer-size`将会失去作用,只有检查点才能触发提交。
 
+### Zeta 定时刷新
+
+该引擎级能力仅由 Zeta 支持,Spark 和 Flink 不会注入 `FlushSignal`。在 Zeta 中,可以在 `env` 块配置 
`sink.flush.interval`,使当前 Stream Load 在达到 `doris.batch.size` 之前也能定时完成。
+
+仅当 `sink.enable-2pc=false` 时才会注册定时刷新。`sink.enable-2pc=true` 时会明确禁用该能力,因为在 
checkpoint 之间完成当前 Stream Load 并开启新 Stream Load 会破坏 2PC 
事务边界和精确一次保证。因此,首版定时刷新仅提供至少一次语义。
+
+```hocon
+env {
+  job.mode = "STREAMING"
+  checkpoint.interval = 300000
+  sink.flush.interval = 5000
+}
+
+sink {
+  Doris {
+    fenodes = "doris-fe:8030"
+    username = root
+    password = ""
+    database = "mydb"
+    table = "mytable"
+    sink.label-prefix = "timer-flush"
+    sink.enable-2pc = false
+    doris.batch.size = 10000
+  }
+}
+```
+
 ## `307 Temporary Redirect` 排查
 
 如果任务报错 `HTTP/1.1 307 Temporary Redirect`,建议优先检查以下项目:
diff --git 
a/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
 
b/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
index 3017ed9f3f..c2cbf3a24d 100644
--- 
a/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
+++ 
b/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
@@ -136,6 +136,9 @@ public class DorisSinkWriter
         this.schemaChangeManager = new SchemaChangeManager(dorisSinkConfig);
         this.streamLoadFactory = streamLoadFactory;
         this.initializeLoad();
+        if (!dorisSinkConfig.getEnable2PC()) {
+            context.registerFlushAction(this::timerFlush);
+        }
     }
 
     private void initializeLoad() {
@@ -276,6 +279,16 @@ public class DorisSinkWriter
         return respContent;
     }
 
+    /**
+     * Finishes the current non-2PC Stream Load and opens a new one when the 
Zeta engine delivers a
+     * timer flush signal.
+     */
+    private void timerFlush() throws IOException {
+        checkLoadException();
+        flush();
+        startLoad(labelGenerator.generateLabel(lastCheckpointId));
+    }
+
     @Override
     public List<DorisSinkState> snapshotState(long checkpointId) {
         checkState(dorisStreamLoad != null);
diff --git 
a/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
 
b/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
index 5cc77387ae..3ba8eda2ab 100644
--- 
a/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
+++ 
b/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
@@ -19,6 +19,7 @@ package org.apache.seatunnel.connectors.doris.sink.writer;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.sink.DefaultSinkWriterContext;
+import org.apache.seatunnel.api.sink.SinkWriter;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
 import org.apache.seatunnel.api.table.catalog.TableIdentifier;
@@ -27,6 +28,7 @@ import org.apache.seatunnel.api.table.catalog.TableSchema;
 import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
 import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
 import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.common.utils.function.RunnableWithException;
 import org.apache.seatunnel.connectors.doris.config.DorisSinkConfig;
 import 
org.apache.seatunnel.connectors.doris.exception.DorisSchemaChangeException;
 import org.apache.seatunnel.connectors.doris.rest.models.RespContent;
@@ -36,6 +38,7 @@ import 
org.apache.seatunnel.connectors.doris.sink.committer.DorisCommitInfo;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InOrder;
 
 import java.util.ArrayList;
@@ -60,6 +63,69 @@ import static org.mockito.Mockito.when;
 
 public class DorisSinkWriterTest {
 
+    @Test
+    void testTimerFlushRegisteredAndReopensLoadForNonTwoPhaseCommit() throws 
Exception {
+        DorisStreamLoad frontendLoad = mock(DorisStreamLoad.class);
+        when(frontendLoad.stopLoad()).thenReturn(successRespContent(1L));
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+
+        RecordingStreamLoadFactory factory = new RecordingStreamLoadFactory();
+        factory.register("fe1:8030", frontendLoad);
+
+        DorisSinkWriter writer = null;
+        try {
+            writer =
+                    new DorisSinkWriter(
+                            context,
+                            new ArrayList<>(),
+                            mockCatalogTable(),
+                            createSinkConfig(false, false),
+                            "job_1",
+                            factory);
+
+            ArgumentCaptor<RunnableWithException> flushAction =
+                    ArgumentCaptor.forClass(RunnableWithException.class);
+            verify(context).registerFlushAction(flushAction.capture());
+
+            flushAction.getValue().run();
+
+            verify(frontendLoad, times(1)).stopLoad();
+            verify(frontendLoad, times(2)).startLoad(anyString());
+        } finally {
+            if (writer != null) {
+                writer.close();
+            }
+        }
+    }
+
+    @Test
+    void testTimerFlushNotRegisteredForTwoPhaseCommit() throws Exception {
+        DorisStreamLoad frontendLoad = mock(DorisStreamLoad.class);
+        doNothing().when(frontendLoad).abortPreCommit(anyString(), anyLong());
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+
+        RecordingStreamLoadFactory factory = new RecordingStreamLoadFactory();
+        factory.register("fe1:8030", frontendLoad);
+
+        DorisSinkWriter writer = null;
+        try {
+            writer =
+                    new DorisSinkWriter(
+                            context,
+                            new ArrayList<>(),
+                            mockCatalogTable(),
+                            createSinkConfig(false, true),
+                            "job_1",
+                            factory);
+
+            verify(context, never()).registerFlushAction(any());
+        } finally {
+            if (writer != null) {
+                writer.close();
+            }
+        }
+    }
+
     @Test
     void testInitializeLoadUsesFrontendWhenDirectToBeDisabled() throws 
Exception {
         DorisStreamLoad frontendLoad = mock(DorisStreamLoad.class);
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisTimerFlushIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisTimerFlushIT.java
new file mode 100644
index 0000000000..dfa5c6e6f1
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisTimerFlushIT.java
@@ -0,0 +1,200 @@
+/*
+ * 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.seatunnel.e2e.connector.doris;
+
+import 
org.apache.seatunnel.connectors.seatunnel.cdc.mysql.testutils.MySqlContainer;
+import 
org.apache.seatunnel.connectors.seatunnel.cdc.mysql.testutils.MySqlVersion;
+import 
org.apache.seatunnel.connectors.seatunnel.cdc.mysql.testutils.UniqueDatabase;
+import org.apache.seatunnel.e2e.common.container.ContainerExtendedFactory;
+import org.apache.seatunnel.e2e.common.container.EngineType;
+import org.apache.seatunnel.e2e.common.container.TestContainer;
+import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer;
+import org.apache.seatunnel.e2e.common.junit.TestContainerExtension;
+import org.apache.seatunnel.e2e.common.util.JobIdGenerator;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.TestTemplate;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.lifecycle.Startables;
+import org.testcontainers.utility.DockerLoggerFactory;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import static org.awaitility.Awaitility.await;
+
+@Slf4j
+@DisabledOnContainer(
+        value = {},
+        type = {EngineType.SPARK, EngineType.FLINK},
+        disabledReason =
+                "engine-level timer flush (sink.flush.interval) is only 
supported on Zeta engine")
+public class DorisTimerFlushIT extends AbstractDorisIT {
+
+    private static final String MYSQL_HOST = "mysql_doris_timer_flush_e2e";
+    private static final String MYSQL_USER_NAME = "mysqluser";
+    private static final String MYSQL_USER_PASSWORD = "mysqlpw";
+    private static final String MYSQL_DATABASE = "mysql_cdc";
+    private static final String MYSQL_CDC_PLUGIN_LIB = 
"/tmp/seatunnel/plugins/MySQL-CDC/lib";
+    private static final String SINK_DATABASE = "timer_flush";
+    private static final String SINK_TABLE = "doris_timer_flush";
+    private static final MySqlContainer MYSQL_CONTAINER = 
createMySqlContainer(MySqlVersion.V8_0);
+
+    private final UniqueDatabase inventoryDatabase =
+            new UniqueDatabase(MYSQL_CONTAINER, MYSQL_DATABASE);
+
+    @TestContainerExtension
+    private final ContainerExtendedFactory extendedFactory =
+            container -> copyMySQLDriverToContainer(container, 
MYSQL_CDC_PLUGIN_LIB);
+
+    @BeforeAll
+    public void init() {
+        Startables.deepStart(Stream.of(MYSQL_CONTAINER)).join();
+        inventoryDatabase.createAndInitialize();
+        initializeTimerFlushTable();
+    }
+
+    @TestTemplate
+    public void testDorisTimerFlush(TestContainer testContainer) throws 
Exception {
+        String jobId = String.valueOf(JobIdGenerator.newJobId());
+        CompletableFuture<Container.ExecResult> jobFuture =
+                CompletableFuture.supplyAsync(
+                        () -> {
+                            try {
+                                return testContainer.executeJob(
+                                        "/mysqlcdc_to_doris_timer_flush.conf", 
jobId);
+                            } catch (Exception e) {
+                                throw new RuntimeException(e);
+                            }
+                        });
+
+        try {
+            await().atMost(2, TimeUnit.MINUTES)
+                    .pollInterval(2, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                if (jobFuture.isDone()) {
+                                    Container.ExecResult jobResult = 
jobFuture.get();
+                                    Assertions.fail(
+                                            "The streaming job terminated 
before reaching RUNNING: "
+                                                    + jobResult.getStderr());
+                                }
+                                Assertions.assertEquals(
+                                        "RUNNING", 
testContainer.getJobStatus(jobId));
+                            });
+
+            await().atMost(120, TimeUnit.SECONDS)
+                    .ignoreExceptions()
+                    .pollInterval(2, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                Assertions.assertFalse(
+                                        jobFuture.isDone(),
+                                        "The streaming job must still be 
running when timer flush publishes the snapshot");
+                                Assertions.assertEquals(2, tableCount());
+                            });
+
+            try (Connection connection = inventoryDatabase.getJdbcConnection();
+                    Statement statement = connection.createStatement()) {
+                statement.executeUpdate(
+                        "INSERT INTO mysql_cdc_e2e_source_table (uuid, name, 
score) "
+                                + "VALUES (3, 'timer-flush', 100)");
+            }
+
+            await().atMost(120, TimeUnit.SECONDS)
+                    .ignoreExceptions()
+                    .pollInterval(2, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                Assertions.assertFalse(
+                                        jobFuture.isDone(),
+                                        "The streaming job must still be 
running when timer flush publishes the binlog event");
+                                Assertions.assertEquals(3, tableCount());
+                            });
+        } finally {
+            if (!jobFuture.isDone()) {
+                Container.ExecResult cancelResult = 
testContainer.cancelJob(jobId);
+                Assertions.assertEquals(0, cancelResult.getExitCode(), 
cancelResult.getStderr());
+            }
+        }
+
+        Container.ExecResult jobResult = jobFuture.get(120, TimeUnit.SECONDS);
+        Assertions.assertEquals(0, jobResult.getExitCode(), 
jobResult.getStderr());
+    }
+
+    private void initializeTimerFlushTable() {
+        String createTableSql =
+                "CREATE TABLE IF NOT EXISTS "
+                        + SINK_DATABASE
+                        + "."
+                        + SINK_TABLE
+                        + " ("
+                        + "uuid BIGINT NOT NULL, "
+                        + "name VARCHAR(128), "
+                        + "score INT) "
+                        + "DUPLICATE KEY(uuid) "
+                        + "DISTRIBUTED BY HASH(uuid) BUCKETS 1 "
+                        + "PROPERTIES ('replication_num' = '1')";
+        try (Statement statement = jdbcConnection.createStatement()) {
+            statement.execute("CREATE DATABASE IF NOT EXISTS " + 
SINK_DATABASE);
+            statement.execute(createTableSql);
+            statement.execute("TRUNCATE TABLE " + SINK_DATABASE + "." + 
SINK_TABLE);
+        } catch (SQLException e) {
+            throw new RuntimeException("Failed to initialize Doris timer flush 
table", e);
+        }
+    }
+
+    private int tableCount() {
+        String sql = "SELECT COUNT(*) FROM " + SINK_DATABASE + "." + 
SINK_TABLE;
+        try (Statement statement = jdbcConnection.createStatement();
+                ResultSet resultSet = statement.executeQuery(sql)) {
+            Assertions.assertTrue(resultSet.next());
+            return resultSet.getInt(1);
+        } catch (SQLException e) {
+            throw new RuntimeException("Failed to count rows in Doris timer 
flush table", e);
+        }
+    }
+
+    private static MySqlContainer createMySqlContainer(MySqlVersion version) {
+        return new MySqlContainer(version)
+                .withConfigurationOverride("docker/server-gtids/my.cnf")
+                .withSetupSQL("docker/setup.sql")
+                .withNetwork(NETWORK)
+                .withNetworkAliases(MYSQL_HOST)
+                .withDatabaseName(MYSQL_DATABASE)
+                .withUsername(MYSQL_USER_NAME)
+                .withPassword(MYSQL_USER_PASSWORD)
+                .withLogConsumer(
+                        new 
Slf4jLogConsumer(DockerLoggerFactory.getLogger("mysql-docker-image")));
+    }
+
+    @AfterAll
+    public void close() {
+        MYSQL_CONTAINER.close();
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/mysqlcdc_to_doris_timer_flush.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/mysqlcdc_to_doris_timer_flush.conf
new file mode 100644
index 0000000000..830b0a36c1
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/mysqlcdc_to_doris_timer_flush.conf
@@ -0,0 +1,53 @@
+#
+# 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.
+#
+
+env {
+  parallelism = 1
+  job.mode = "STREAMING"
+  checkpoint.interval = 300000
+  sink.flush.interval = 500
+}
+
+source {
+  MySQL-CDC {
+    parallelism = 1
+    server-id = 5664
+    username = "st_user_source"
+    password = "mysqlpw"
+    table-names = ["mysql_cdc.mysql_cdc_e2e_source_table"]
+    url = "jdbc:mysql://mysql_doris_timer_flush_e2e:3306/mysql_cdc"
+  }
+}
+
+sink {
+  Doris {
+    fenodes = "doris_e2e:8030"
+    username = root
+    password = ""
+    database = "timer_flush"
+    table = "doris_timer_flush"
+    sink.label-prefix = "timer-flush"
+    sink.enable-2pc = false
+    doris.batch.size = 100000
+    schema_save_mode = "IGNORE"
+    data_save_mode = "APPEND_DATA"
+    doris.config {
+      format = "json"
+      read_json_by_line = "true"
+    }
+  }
+}

Reply via email to