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

morningman 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 0796543f6a0 [fix](arrow-flight) Forward a statement to the master FE 
without touching the MySQL channel (#67569)
0796543f6a0 is described below

commit 0796543f6a025a16948d507ecc161761da574d58
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Wed Sep 9 09:52:43 2026 +0800

    [fix](arrow-flight) Forward a statement to the master FE without touching 
the MySQL channel (#67569)
    
    ### What problem does this PR solve?
    
    Related PR: #61050
    
    Problem Summary:
    
    **1. A Flight session could not forward a statement at all.**
    
    An Arrow Flight SQL session has no `MysqlChannel`:
    `FlightSqlConnectContext` overrides
    `getMysqlChannel()` to throw `getMysqlChannel not in mysql connection`.
    Since #61050,
    `FEOpExecutor.buildStmtForwardParams()` reads the client's
    `CLIENT_DEPRECATE_EOF` capability
    straight off that channel, so on a multi-FE deployment every statement a
    Flight connection has to
    forward to the master fails with that message before the request is even
    sent:
    
    - any DDL issued over Arrow Flight SQL to a follower or observer FE;
    - any statement at all when `force_forward_all_queries` is on.
    
    `CLIENT_DEPRECATE_EOF` is a MySQL protocol capability, so only read it
    for a MySQL connection. The
    thrift field is `optional` with no IDL default, and leaving it unset for
    other protocols puts the
    master back on the packet layout it used before #61050 —
    `ConnectProcessor` applies it only when
    `isSetClientDeprecatedEOF() && isClientDeprecatedEOF()`, so unset is
    equivalent to set-false for
    every reader in every rolling-upgrade direction.
    
    **2. Once the forward succeeds, nothing carries the master's answer back
    to the Flight client.**
    
    `ConnectProcessor.finalizeCommand()` is the only place a forwarded
    statement's status and result set
    are replayed, and it is MySQL-only: it opens with
    `Preconditions.checkState(connectType.equals(ConnectType.MYSQL))`.
    Nothing else reads
    `getProxyStatusCode()` / `getShowResultSet()` / `getOutputPacket()`
    except the audit log. So after
    the RPC, `ctx.getState()` stays at the `OK` that `executeQuery()` set
    with `reset()`, the
    `FlightSqlChannel` stays empty, and `DorisFlightSqlProducer` answers
    with `addOKResult()`'s
    synthesized one-row `StatusResult = 0`. With a client connected over
    Arrow Flight SQL to a follower
    or observer:
    
    - a DDL that **failed** on the master was reported to the client as
    success — only the follower's
      audit event recorded the real error;
    - a forwarded statement that returns rows (`SHOW FRONTENDS`, `SHOW
    LOAD`, … — about 33
    `ShowCommand` subclasses both forward and return rows) returned that
    single `StatusResult` row
    instead of the master's rows. The same statement run against the master
    returns real rows, because
    `StmtExecutor.sendResultSet()` already has a working Arrow Flight
    branch.
    
    This gap is older than #61050 (`finalizeCommand()` was already
    MySQL-only before it), so fixing only
    part 1 would have turned a loud, diagnosable error into a silent wrong
    answer, which the
    repository's "Error Means Failure" invariant does not allow. Part 2 is
    therefore fixed here too:
    
    - `ConnectProcessor.carryForwardedOutcomeToFlightSession()` is the Arrow
    Flight counterpart of
    `finalizeCommand()`'s forwarded-statement branch. It copies a non-zero
    master status into
    `ctx.getState()` (and logs it at WARN — the follower used to say
    nothing), and otherwise replays
    the master's result set through the existing `sendResultSet()` Arrow
    Flight branch.
    - It is called from the branch of `executeQuery()` that is **already**
    scoped to
    `ARROW_FLIGHT_SQL`, so it structurally cannot fire on a MySQL connection
    and
      `finalizeCommand()`'s `getStateType() != ERR` condition is untouched.
    - `StmtExecutor` now refuses to forward a *query* on an Arrow Flight SQL
    connection, before the RPC.
    A query result comes back as MySQL wire packets in
    `TMasterOpResult.queryResultBufList`, which
    only `finalizeCommand()` can replay and which cannot be converted to
    Arrow batches; answering it
    with a synthesized empty success would be the same silent wrong answer.
    This is reachable only
    under `force_forward_all_queries`, which `Config` documents as "For
    testing purposes".
    
    The MySQL path is byte-for-byte unchanged.
---
 .../java/org/apache/doris/qe/ConnectProcessor.java |  33 ++++++
 .../java/org/apache/doris/qe/FEOpExecutor.java     |   8 +-
 .../java/org/apache/doris/qe/StmtExecutor.java     |   9 ++
 .../ConnectProcessorFlightForwardOutcomeTest.java  | 121 +++++++++++++++++++++
 .../doris/qe/FEOpExecutorFlightForwardTest.java    | 120 ++++++++++++++++++++
 .../test_arrow_flight_forward_to_master.groovy     |  97 +++++++++++++++++
 6 files changed, 387 insertions(+), 1 deletion(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
index 55f5d2c9c6c..18b6a4d64ad 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
@@ -86,6 +86,7 @@ import org.apache.doris.thrift.TMasterOpResult;
 import org.apache.doris.thrift.TUniqueId;
 import org.apache.doris.transaction.TransactionEntry;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -382,6 +383,9 @@ public abstract class ConnectProcessor {
                             }
                         }
                     } else if 
(connectType.equals(ConnectType.ARROW_FLIGHT_SQL)) {
+                        if (executor.hasForwardedToMaster()) {
+                            carryForwardedOutcomeToFlightSession(executor);
+                        }
                         if (!ctx.isReturnResultFromLocal()) {
                             returnResultFromRemoteExecutor.add(executor);
                         }
@@ -685,6 +689,35 @@ public abstract class ConnectProcessor {
         LOG.debug("End finalizing command for query {}", 
DebugUtil.printId(ctx.queryId));
     }
 
+    // Arrow Flight SQL counterpart of the forwarded-statement branch of 
finalizeCommand(). That
+    // method is the only place a forwarded statement's status and result set 
are replayed to the
+    // client, and it is MySQL-only: it opens with
+    // Preconditions.checkState(connectType.equals(ConnectType.MYSQL)). 
Without this method a
+    // forwarded statement leaves ctx.getState() at the OK that executeQuery() 
set with reset() and
+    // leaves the FlightSqlChannel empty, so DorisFlightSqlProducer answers 
with addOKResult()'s
+    // synthesized StatusResult=0 -- reporting success for a statement that 
failed on the master,
+    // and an empty status row instead of the rows a forwarded SHOW produced.
+    @VisibleForTesting
+    void carryForwardedOutcomeToFlightSession(StmtExecutor executor) throws 
IOException {
+        if (executor.getProxyStatusCode() != 0) {
+            // The master rejected the statement, e.g. CREATE TABLE on a table 
that already exists.
+            // TMasterOpResult carries the master's error code as a plain int 
and ErrorCode has no
+            // reverse lookup, so the master's code travels in the message 
instead.
+            String errMsg = "forwarded statement failed on master FE, error 
code: "
+                    + executor.getProxyStatusCode() + ", error message: " + 
executor.getProxyErrMsg();
+            LOG.warn(errMsg);
+            ctx.getState().setError(ErrorCode.ERR_UNKNOWN_ERROR, errMsg);
+            return;
+        }
+        // Set exactly when the forwarded statement produced rows: 
proxyExecute() fills
+        // TMasterOpResult.resultSet from getProxyShowResultSet(). A forwarded 
DDL produces none,
+        // and the synthesized StatusResult=0 is the right answer for it.
+        ShowResultSet resultSet = executor.getShowResultSet();
+        if (resultSet != null) {
+            executor.sendResultSet(resultSet);
+        }
+    }
+
     public TMasterOpResult proxyExecute(TMasterOpRequest request) throws 
TException {
         ctx.setDatabase(request.db);
         ctx.setEnv(Env.getCurrentEnv());
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java
index cb1f6d5da9e..414a9725c70 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java
@@ -26,6 +26,7 @@ import org.apache.doris.common.Config;
 import org.apache.doris.common.ErrorCode;
 import org.apache.doris.datasource.DelegatedCredential;
 import org.apache.doris.mysql.MysqlCommand;
+import org.apache.doris.qe.ConnectContext.ConnectType;
 import org.apache.doris.thrift.FrontendService;
 import org.apache.doris.thrift.TExpr;
 import org.apache.doris.thrift.TExprNode;
@@ -225,7 +226,12 @@ public class FEOpExecutor {
 
         // Propagate the client's CLIENT_DEPRECATE_EOF capability so the 
master FE
         // generates packets matching the original client's protocol 
expectations.
-        
params.setClientDeprecatedEOF(ctx.getMysqlChannel().clientDeprecatedEOF());
+        // Only a MySQL connection negotiates this capability and owns a 
MysqlChannel;
+        // an Arrow Flight SQL session has none, and leaving the field unset 
keeps the
+        // master on its default packet layout.
+        if (ctx.getConnectType() == ConnectType.MYSQL) {
+            
params.setClientDeprecatedEOF(ctx.getMysqlChannel().clientDeprecatedEOF());
+        }
 
         return params;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
index 396ec17a08c..ae4bc830fd8 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
@@ -915,6 +915,15 @@ public class StmtExecutor {
                 if (context.getCommand() == MysqlCommand.COM_STMT_PREPARE) {
                     throw new UserException("Forward master command is not 
supported for prepare statement");
                 }
+                if (context.getConnectType() == ConnectType.ARROW_FLIGHT_SQL) {
+                    // The master returns a query result as MySQL wire packets 
in
+                    // TMasterOpResult.queryResultBufList, which only 
ConnectProcessor.finalizeCommand()
+                    // can replay and which cannot be converted to Arrow 
batches. Refuse here, before
+                    // the RPC, rather than let the master build a result set 
this FE would discard
+                    // and answer the client with a synthesized empty success.
+                    throw new UserException("Forwarding a query to the master 
FE is not supported on an"
+                            + " Arrow Flight SQL connection. Connect to the 
master FE to run this query.");
+                }
                 if (isProxy) {
                     // This is already a stmt forwarded from other FE.
                     // If we goes here, means we can't find a valid Master 
FE(some error happens).
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectProcessorFlightForwardOutcomeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectProcessorFlightForwardOutcomeTest.java
new file mode 100644
index 00000000000..e7824798cc5
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectProcessorFlightForwardOutcomeTest.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.qe;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.service.arrowflight.sessions.FlightSqlConnectContext;
+
+import com.google.common.collect.Lists;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.List;
+
+/**
+ * ConnectProcessor.finalizeCommand() is the only place a forwarded 
statement's status and result set
+ * are replayed to the client, and it is MySQL-only (it opens with a 
Preconditions.checkState on the
+ * connect type). carryForwardedOutcomeToFlightSession() is its Arrow Flight 
SQL counterpart. Without
+ * it, ctx.getState() stays at the OK that executeQuery() set with reset() and 
the FlightSqlChannel
+ * stays empty, so DorisFlightSqlProducer answers with addOKResult()'s 
synthesized StatusResult=0:
+ * success for a statement that failed on the master, and that same row 
instead of the rows a
+ * forwarded SHOW produced.
+ */
+public class ConnectProcessorFlightForwardOutcomeTest {
+    private boolean savedRunningUnitTest;
+    private FlightSqlConnectContext context;
+    private ConnectProcessor processor;
+
+    @BeforeEach
+    public void setUp() {
+        savedRunningUnitTest = FeConstants.runningUnitTest;
+        // ConnectContext.init() registers the session with Env unless running 
as a unit test.
+        FeConstants.runningUnitTest = true;
+        context = new FlightSqlConnectContext("test-peer-identity");
+        processor = new TestConnectProcessor(context);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        FeConstants.runningUnitTest = savedRunningUnitTest;
+    }
+
+    @Test
+    public void testMasterFailureIsReportedToTheFlightClient() throws 
Exception {
+        StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+        // e.g. CREATE TABLE on a table that already exists.
+        Mockito.when(executor.getProxyStatusCode()).thenReturn(1050);
+        Mockito.when(executor.getProxyErrMsg()).thenReturn("Table 'tbl' 
already exists");
+
+        processor.carryForwardedOutcomeToFlightSession(executor);
+
+        Assertions.assertEquals(QueryState.MysqlStateType.ERR, 
context.getState().getStateType());
+        Assertions.assertEquals(ErrorCode.ERR_UNKNOWN_ERROR, 
context.getState().getErrorCode());
+        // The master's own error code has no ErrorCode enum on this side, so 
it travels in the text.
+        
Assertions.assertTrue(context.getState().getErrorMessage().contains("1050"),
+                context.getState().getErrorMessage());
+        
Assertions.assertTrue(context.getState().getErrorMessage().contains("Table 
'tbl' already exists"),
+                context.getState().getErrorMessage());
+        // A statement that failed must not also replay a result set.
+        Mockito.verify(executor, Mockito.never()).sendResultSet(Mockito.any());
+    }
+
+    @Test
+    public void testForwardedResultSetIsReplayedToTheFlightChannel() throws 
Exception {
+        ShowResultSet resultSet = new ShowResultSet(
+                ShowResultSetMetaData.builder()
+                        .addColumn(new Column("JobId", 
ScalarType.createVarchar(20)))
+                        .build(),
+                Lists.<List<String>>newArrayList(Lists.newArrayList("10086")));
+        StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+        Mockito.when(executor.getProxyStatusCode()).thenReturn(0);
+        Mockito.when(executor.getShowResultSet()).thenReturn(resultSet);
+
+        processor.carryForwardedOutcomeToFlightSession(executor);
+
+        Assertions.assertNotEquals(QueryState.MysqlStateType.ERR, 
context.getState().getStateType());
+        // sendResultSet()'s ARROW_FLIGHT_SQL branch puts the rows into the 
FlightSqlChannel, which is
+        // what DorisFlightSqlProducer hands back instead of the synthesized 
StatusResult row.
+        Mockito.verify(executor).sendResultSet(resultSet);
+    }
+
+    @Test
+    public void testForwardedDdlKeepsTheSynthesizedOkResult() throws Exception 
{
+        StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+        Mockito.when(executor.getProxyStatusCode()).thenReturn(0);
+        // A forwarded DDL carries no result set, and StatusResult=0 is the 
right answer for it.
+        Mockito.when(executor.getShowResultSet()).thenReturn(null);
+
+        processor.carryForwardedOutcomeToFlightSession(executor);
+
+        Assertions.assertNotEquals(QueryState.MysqlStateType.ERR, 
context.getState().getStateType());
+        Assertions.assertEquals(0L, context.getFlightSqlChannel().resultNum());
+        Mockito.verify(executor, Mockito.never()).sendResultSet(Mockito.any());
+    }
+
+    private static class TestConnectProcessor extends ConnectProcessor {
+        private TestConnectProcessor(ConnectContext context) {
+            super(context);
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/FEOpExecutorFlightForwardTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/FEOpExecutorFlightForwardTest.java
new file mode 100644
index 00000000000..af39b6f936d
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/FEOpExecutorFlightForwardTest.java
@@ -0,0 +1,120 @@
+// 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.qe;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.service.arrowflight.sessions.FlightSqlConnectContext;
+import org.apache.doris.system.SystemInfoService;
+import org.apache.doris.thrift.TMasterOpRequest;
+import org.apache.doris.thrift.TNetworkAddress;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+/**
+ * Building the forward request for the master FE must not reach for a 
MysqlChannel unconditionally:
+ * an Arrow Flight SQL session has none, so every statement a Flight 
connection forwards (any DDL on
+ * a non-master FE, or anything at all under force_forward_all_queries) used 
to fail with
+ * "getMysqlChannel not in mysql connection". CLIENT_DEPRECATE_EOF is a MySQL 
protocol capability and
+ * is only carried for MySQL connections.
+ */
+public class FEOpExecutorFlightForwardTest {
+    private boolean savedRunningUnitTest;
+
+    @BeforeEach
+    public void setUp() {
+        savedRunningUnitTest = FeConstants.runningUnitTest;
+        // ConnectContext.init() registers the session with Env unless running 
as a unit test.
+        FeConstants.runningUnitTest = true;
+    }
+
+    @AfterEach
+    public void tearDown() {
+        FeConstants.runningUnitTest = savedRunningUnitTest;
+    }
+
+    @Test
+    public void testFlightSessionForwardsWithoutMysqlChannel() throws 
Exception {
+        try (MockedStatic<Env> mockedEnv = mockSelfNode()) {
+            FlightSqlConnectContext context = new 
FlightSqlConnectContext("test-peer-identity");
+            prepare(context);
+
+            TMasterOpRequest request = new TestFEOpExecutor(context).build();
+
+            Assertions.assertFalse(request.isSetClientDeprecatedEOF());
+            Assertions.assertEquals("select 1", request.getSql());
+        }
+    }
+
+    @Test
+    public void testMysqlSessionKeepsCarryingDeprecatedEof() throws Exception {
+        try (MockedStatic<Env> mockedEnv = mockSelfNode()) {
+            ConnectContext context = new ConnectContext();
+            prepare(context);
+            context.getMysqlChannel().setClientDeprecatedEOF();
+
+            TMasterOpRequest request = new TestFEOpExecutor(context).build();
+
+            Assertions.assertTrue(request.isSetClientDeprecatedEOF());
+            Assertions.assertTrue(request.isClientDeprecatedEOF());
+        }
+    }
+
+    @Test
+    public void testMysqlSessionWithoutDeprecatedEof() throws Exception {
+        try (MockedStatic<Env> mockedEnv = mockSelfNode()) {
+            ConnectContext context = new ConnectContext();
+            prepare(context);
+
+            TMasterOpRequest request = new TestFEOpExecutor(context).build();
+
+            Assertions.assertTrue(request.isSetClientDeprecatedEOF());
+            Assertions.assertFalse(request.isClientDeprecatedEOF());
+        }
+    }
+
+    private static MockedStatic<Env> mockSelfNode() {
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getSelfNode()).thenReturn(new 
SystemInfoService.HostInfo("127.0.0.1", 9010));
+        MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class);
+        mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+        return mockedEnv;
+    }
+
+    private static void prepare(ConnectContext context) {
+        
context.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("alice",
 "%"));
+        context.setRemoteIP("127.0.0.1");
+    }
+
+    private static class TestFEOpExecutor extends FEOpExecutor {
+        private TestFEOpExecutor(ConnectContext context) {
+            super(new TNetworkAddress("127.0.0.1", 9010), new 
OriginStatement("select 1", 0), context, true);
+        }
+
+        private TMasterOpRequest build() throws AnalysisException {
+            return buildStmtForwardParams();
+        }
+    }
+}
diff --git 
a/regression-test/suites/arrow_flight_sql_p0/test_arrow_flight_forward_to_master.groovy
 
b/regression-test/suites/arrow_flight_sql_p0/test_arrow_flight_forward_to_master.groovy
new file mode 100644
index 00000000000..7927272a428
--- /dev/null
+++ 
b/regression-test/suites/arrow_flight_sql_p0/test_arrow_flight_forward_to_master.groovy
@@ -0,0 +1,97 @@
+// 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
+
+// A statement issued over Arrow Flight SQL to a non-master FE has to be 
forwarded to the master.
+// This needs more than one FE, so it can only be exercised in a docker 
cluster: on a single-FE
+// deployment StmtExecutor.shouldForwardToMaster() returns false immediately.
+//
+// The group is 'docker' and not 'arrow_flight_sql' on purpose. 
SuiteContext.useArrowFlightSql()
+// keys off the suite group, so keeping it out means `sql` resolves to the 
thread-local connection
+// this suite opens itself (the follower's Flight port) instead of the 
globally configured one.
+suite("test_arrow_flight_forward_to_master", "docker") {
+    def options = new ClusterOptions()
+    options.setFeNum(2)
+    options.connectToFollower = true
+
+    docker(options) {
+        def follower = cluster.getOneFollowerFe()
+        assertNotNull(follower, "a follower FE is required to exercise the 
forward-to-master path")
+
+        // Every docker FE serves Arrow Flight SQL on the same fixed port, see
+        // FE_ARROW_FLIGHT_SQL_PORT in docker/runtime/doris-compose/cluster.py.
+        Class.forName("org.apache.arrow.driver.jdbc.ArrowFlightJdbcDriver")
+        def flightUrl = 
"jdbc:arrow-flight-sql://${follower.host}:8070/catalog=${context.dbName}" +
+                "?useServerPrepStmts=false&useSSL=false&useEncryption=false"
+        logger.info("connect to follower over arrow flight sql: 
${flightUrl}".toString())
+
+        sql "DROP TABLE IF EXISTS test_arrow_flight_forward_to_master"
+
+        connect('root', '', flightUrl) {
+            // 1. A DDL is a Redirect command, so on a follower it is 
forwarded to the master.
+            //    Before the CLIENT_DEPRECATE_EOF capability was read only for 
MySQL connections
+            //    this failed with "getMysqlChannel not in mysql connection".
+            sql """
+                CREATE TABLE test_arrow_flight_forward_to_master (k1 int)
+                DISTRIBUTED BY HASH(k1) BUCKETS 1
+                PROPERTIES ("replication_num" = "1")
+            """
+
+            // 2. The same DDL again fails on the master. The follower replays 
a forwarded
+            //    statement's outcome only in 
ConnectProcessor.finalizeCommand(), which is
+            //    MySQL-only, so without 
carryForwardedOutcomeToFlightSession() the master's error
+            //    was dropped and the client was told StatusResult=0 -- 
success for a statement
+            //    that failed.
+            test {
+                sql """
+                    CREATE TABLE test_arrow_flight_forward_to_master (k1 int)
+                    DISTRIBUTED BY HASH(k1) BUCKETS 1
+                    PROPERTIES ("replication_num" = "1")
+                """
+                exception "already exists"
+            }
+
+            // 3. SHOW FRONTENDS is forwarded too (forward_to_master defaults 
to true) and it
+            //    carries a result set. Without the fix the master's rows were 
dropped and the
+            //    client got the synthesized single column named StatusResult 
instead.
+            def frontends = sql_return_maparray "SHOW FRONTENDS"
+            assertFalse(frontends.isEmpty(), "SHOW FRONTENDS returned nothing")
+            assertFalse(frontends[0].containsKey("StatusResult"),
+                    "got the synthesized status row instead of the master's 
result set: " + frontends[0])
+            assertTrue(frontends.size() >= 2, "expected both FEs, got " + 
frontends.size())
+            assertEquals(1, frontends.count { it.IsMaster == "true" })
+        }
+
+        // The forwarded DDL really took effect on the master. 
CreateTableCommand forwards with
+        // sync, so by now the follower has replayed it too.
+        def tables = sql_return_maparray "SHOW TABLES LIKE 
'test_arrow_flight_forward_to_master'"
+        assertEquals(1, tables.size())
+
+        // 4. A forwarded *query* returns its result as MySQL wire packets, 
which cannot be turned
+        //    into Arrow batches. That must be refused explicitly rather than 
answered with the
+        //    synthesized empty success. Use a fresh Flight session so 
force_forward_all_queries
+        //    cannot leak into the assertions above.
+        connect('root', '', flightUrl) {
+            sql "SET force_forward_all_queries = true"
+            test {
+                sql "SELECT k1 FROM test_arrow_flight_forward_to_master"
+                exception "not supported on an Arrow Flight SQL connection"
+            }
+        }
+    }
+}


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

Reply via email to