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

luwei16 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 ded08aebefd [fix](binlog) Commit table stream offsets for empty 
inserts (#67126)
ded08aebefd is described below

commit ded08aebefdb76b167c1f5fa164feaa1b4732205
Author: Luwei <[email protected]>
AuthorDate: Mon Aug 31 17:04:14 2026 +0800

    [fix](binlog) Commit table stream offsets for empty inserts (#67126)
    
    ### What problem does this PR solve?
    
    Issue Number: close #66273
    
    Related PR: None
    
    Problem Summary: When the optimizer reduces an INSERT INTO SELECT from a
    Table Stream to an empty relation, the previous empty-insert shortcut
    bypasses the transaction lifecycle. The target receives zero rows, but
    the stream offset is not committed, so the same stream state can be read
    repeatedly. Keep empty Table Stream reads in the transaction lifecycle,
    skip coordinator data execution, and commit the offset atomically.
    Ordinary empty inserts continue to use the existing fast-return path.
    
    ### Release note
    
    Table Stream offsets now advance when INSERT INTO SELECT succeeds with
    zero rows.
    
    ### Check List (For Author)
    
    - Test: Unit Test
    - InsertIntoTableCommandTableStreamTest and OlapInsertExecutorTest (21
    tests)
        - ./build.sh --fe -j 192
    - Behavior changed: Yes (successful zero-row Table Stream inserts now
    commit offsets; ordinary inserts are unchanged)
    - Does this need documentation: No
---
 .../commands/insert/AbstractInsertExecutor.java    | 12 +++-
 .../commands/insert/InsertIntoTableCommand.java    |  6 +-
 .../InsertIntoTableCommandTableStreamTest.java     | 77 ++++++++++++++++++++++
 .../commands/insert/OlapInsertExecutorTest.java    | 42 +++++++++++-
 4 files changed, 132 insertions(+), 5 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
index 4ea4fad8dd2..52d596315c6 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/AbstractInsertExecutor.java
@@ -268,7 +268,9 @@ public abstract class AbstractInsertExecutor {
             // Pre-execution work may register external resources, so it must 
share the transaction cleanup scope.
             beforeExec();
             executor.updateProfile(false);
-            execImpl(executor);
+            if (!emptyInsert) {
+                execImpl(executor);
+            }
             checkStrictModeAndFilterRatio();
             for (InsertExecutorListener listener : listeners) {
                 listener.beforeComplete(this, executor, jobId);
@@ -300,6 +302,14 @@ public abstract class AbstractInsertExecutor {
         return emptyInsert;
     }
 
+    /**
+     * Return whether this insert needs its transaction lifecycle. A Table 
Stream offset update
+     * must be committed even when optimization proves that the target 
receives no rows.
+     */
+    public boolean requiresTransaction() {
+        return !emptyInsert || !streamUpdateInfos.isEmpty();
+    }
+
     public void setStreamUpdateInfos(List<TableStreamUpdateInfo> 
streamUpdateInfos) {
         this.streamUpdateInfos = streamUpdateInfos;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
index 4a1440872f0..b7bb29a62f1 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
@@ -335,7 +335,7 @@ public class InsertIntoTableCommand extends Command 
implements NeedAuditEncrypti
                     newestTargetTableIf.readUnlock();
                     continue;
                 }
-                if (!insertExecutor.isEmptyInsert()) {
+                if (insertExecutor.requiresTransaction()) {
                     insertExecutor.beginTransaction();
                     insertExecutor.finalizeSink(
                             buildResult.planner.getFragments().get(0), 
buildResult.dataSink,
@@ -688,8 +688,8 @@ public class InsertIntoTableCommand extends Command 
implements NeedAuditEncrypti
 
     private void runInternal(ConnectContext ctx, StmtExecutor executor) throws 
Exception {
         AbstractInsertExecutor insertExecutor = initPlan(ctx, executor);
-        // if the insert stmt data source is empty, directly return, no need 
to be executed.
-        if (insertExecutor.isEmptyInsert()) {
+        // An empty Table Stream read still needs to commit its offset update 
atomically.
+        if (!insertExecutor.requiresTransaction()) {
             return;
         }
         if (insertExecutorListener != null) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTableStreamTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTableStreamTest.java
index 0d989e9e98a..f46ec641663 100755
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTableStreamTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommandTableStreamTest.java
@@ -46,6 +46,7 @@ import 
org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableStreamScan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
 import org.apache.doris.nereids.util.PlanChecker;
 import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.QueryState.MysqlStateType;
 import org.apache.doris.qe.StmtExecutor;
 import org.apache.doris.system.Backend;
 import org.apache.doris.system.SystemInfoService;
@@ -190,6 +191,82 @@ public class InsertIntoTableCommandTableStreamTest extends 
TestWithFeService {
         Assertions.assertEquals(update.getNext(), ((OlapTableStreamUpdate) 
txnUpdate).getNext());
     }
 
+    @Test
+    public void testEmptyInsertStartsTransactionForStreamOffsetUpdate() throws 
Exception {
+        String sql = "insert into test_stream.tbl_target "
+                + "select * from test_stream.s1 where false";
+        InsertIntoTableCommand command = (InsertIntoTableCommand) 
parser.parseSingle(sql);
+
+        resetQueryContext();
+        AbstractInsertExecutor insertExecutor = command.initPlan(
+                connectContext, new StmtExecutor(connectContext, sql), true);
+        try {
+            Assertions.assertTrue(insertExecutor.isEmptyInsert());
+            
Assertions.assertFalse(insertExecutor.getStreamUpdateInfos().isEmpty());
+            Assertions.assertNotEquals(AbstractInsertExecutor.INVALID_TXN_ID, 
insertExecutor.getTxnId());
+        } finally {
+            insertExecutor.onFail(new RuntimeException("test cleanup"));
+            resetQueryContext();
+        }
+    }
+
+    @Test
+    public void testOrdinaryEmptyInsertStillSkipsTransaction() throws 
Exception {
+        String sql = "insert into test_stream.tbl_target "
+                + "select * from test_stream.tbl_stream_base where false";
+        InsertIntoTableCommand command = (InsertIntoTableCommand) 
parser.parseSingle(sql);
+
+        resetQueryContext();
+        AbstractInsertExecutor insertExecutor = command.initPlan(
+                connectContext, new StmtExecutor(connectContext, sql), true);
+
+        Assertions.assertTrue(insertExecutor.isEmptyInsert());
+        Assertions.assertTrue(insertExecutor.getStreamUpdateInfos().isEmpty());
+        Assertions.assertFalse(insertExecutor.requiresTransaction());
+        Assertions.assertEquals(AbstractInsertExecutor.INVALID_TXN_ID, 
insertExecutor.getTxnId());
+    }
+
+    @Test
+    public void testEmptyInsertCommitsStreamOffsetUpdate() throws Exception {
+        Database db = (Database) 
Env.getCurrentInternalCatalog().getDbOrMetaException("test_stream");
+        OlapTable baseTable = (OlapTable) 
db.getTableOrMetaException("tbl_stream_base");
+        createTable("create stream if not exists 
test_stream.s_empty_insert_commit "
+                + "on table test_stream.tbl_stream_base 
properties('show_initial_rows' = 'false')");
+        OlapTableStream stream = (OlapTableStream) 
db.getTableOrMetaException("s_empty_insert_commit");
+        Map<Long, Long> originalTso = new HashMap<>();
+        Map<Long, Long> expectedTso = new HashMap<>();
+
+        for (Partition partition : baseTable.getPartitions()) {
+            originalTso.put(partition.getId(), partition.getTso());
+            long nextTso = partition.getTso() + 1000;
+            expectedTso.put(partition.getId(), nextTso);
+            partition.setVisibleVersionAndTime(
+                    partition.getVisibleVersion(), 
partition.getVisibleVersionTime(), nextTso);
+        }
+
+        String sql = "insert into test_stream.tbl_target "
+                + "select * from test_stream.s_empty_insert_commit where 
false";
+        InsertIntoTableCommand command = (InsertIntoTableCommand) 
parser.parseSingle(sql);
+        resetQueryContext();
+        connectContext.getState().reset();
+        connectContext.resetReturnRows();
+        try {
+            command.run(connectContext, new StmtExecutor(connectContext, sql));
+
+            Assertions.assertEquals(MysqlStateType.OK, 
connectContext.getState().getStateType());
+            Assertions.assertEquals(0L, connectContext.getReturnRows());
+            for (Map.Entry<Long, Long> entry : expectedTso.entrySet()) {
+                Assertions.assertEquals(entry.getValue(), 
stream.getStreamUpdate(entry.getKey()).first);
+            }
+        } finally {
+            for (Partition partition : baseTable.getPartitions()) {
+                
partition.setVisibleVersionAndTime(partition.getVisibleVersion(),
+                        partition.getVisibleVersionTime(), 
originalTso.get(partition.getId()));
+            }
+            resetQueryContext();
+        }
+    }
+
     @Test
     public void testInitPlanCollectsUpdatesForTwoStreams() throws Exception {
         Database db = (Database) 
Env.getCurrentInternalCatalog().getDbOrMetaException("test_stream");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
index 96fb43ffbae..3d7c9b1aa0f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutorTest.java
@@ -22,6 +22,7 @@ import org.apache.doris.catalog.Database;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.EnvFactory;
 import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.stream.TableStreamUpdateInfo;
 import org.apache.doris.common.Status;
 import org.apache.doris.common.profile.ExecutionProfile;
 import org.apache.doris.common.profile.Profile;
@@ -53,6 +54,7 @@ import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
+import java.util.List;
 import java.util.Optional;
 
 /**
@@ -208,6 +210,40 @@ class OlapInsertExecutorTest {
         }
     }
 
+    @Test
+    void testEmptyStreamInsertCommitsWithoutCoordinatorExecution() throws 
Exception {
+        ConnectContext ctx = createExecutorContext();
+        Coordinator coordinator = createCoordinator();
+        GlobalTransactionMgrIface txnMgr = 
Mockito.mock(GlobalTransactionMgrIface.class);
+        TransactionState txnState = Mockito.mock(TransactionState.class);
+        LoadManager loadManager = Mockito.mock(LoadManager.class);
+        Env currentEnv = createCurrentEnv(loadManager);
+        StmtExecutor stmtExecutor = createStmtExecutor();
+        List<TableStreamUpdateInfo> streamUpdateInfos = 
List.of(Mockito.mock(TableStreamUpdateInfo.class));
+
+        try (MockedStatic<EnvFactory> envFactoryMock = 
Mockito.mockStatic(EnvFactory.class);
+                MockedStatic<Env> envMock = Mockito.mockStatic(Env.class)) {
+            prepareFactoryMocks(envFactoryMock, envMock, coordinator, txnMgr, 
txnState, currentEnv);
+            ctx.setEnv(currentEnv);
+            Mockito.when(txnMgr.commitAndPublishTransaction(
+                    Mockito.any(), Mockito.anyList(), Mockito.anyLong(), 
Mockito.anyList(), Mockito.anyLong(),
+                    Mockito.isNull(), 
Mockito.eq(streamUpdateInfos))).thenReturn(true);
+
+            OlapInsertExecutor executor = createExecutor(ctx, true);
+            executor.txnId = 10005L;
+            executor.setStreamUpdateInfos(streamUpdateInfos);
+            executor.executeSingleInsert(stmtExecutor);
+
+            Mockito.verify(coordinator, Mockito.never()).exec();
+            Mockito.verify(txnMgr).commitAndPublishTransaction(
+                    Mockito.eq(executor.getDatabase()), Mockito.anyList(), 
Mockito.eq(10005L),
+                    Mockito.argThat(List::isEmpty), Mockito.anyLong(), 
Mockito.isNull(),
+                    Mockito.eq(streamUpdateInfos));
+            Assertions.assertEquals(TransactionStatus.VISIBLE, 
executor.txnStatus);
+            Assertions.assertEquals(0L, ctx.getReturnRows());
+        }
+    }
+
     // Build a fresh context per case so insertResult and QueryState do not 
leak between tests.
     private ConnectContext createExecutorContext() {
         ConnectContext ctx = new ConnectContext();
@@ -269,6 +305,10 @@ class OlapInsertExecutorTest {
 
     // Create an executor with mocked table metadata because this test only 
validates timeout result handling.
     private OlapInsertExecutor createExecutor(ConnectContext ctx) {
+        return createExecutor(ctx, false);
+    }
+
+    private OlapInsertExecutor createExecutor(ConnectContext ctx, boolean 
emptyInsert) {
         Database database = Mockito.mock(Database.class);
         Mockito.when(database.getFullName()).thenReturn("test_db");
         Mockito.when(database.getId()).thenReturn(1L);
@@ -280,7 +320,7 @@ class OlapInsertExecutorTest {
         Mockito.when(table.getId()).thenReturn(2L);
 
         return new OlapInsertExecutor(ctx, table, "label_test", 
Mockito.mock(NereidsPlanner.class),
-                Optional.empty(), false, 0L);
+                Optional.empty(), emptyInsert, 0L);
     }
 
     private OlapInsertExecutor 
createExecutorWithBeforeExecFailure(ConnectContext ctx) {


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

Reply via email to