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

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


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new f0b32a6d086 branch-4.1: [fix](protocol) Prevent Connector/J cursor 
fetch from hanging on empty results (#68064)
f0b32a6d086 is described below

commit f0b32a6d0863188710598cb1fb59df2066e02131
Author: Calvin Kirs <[email protected]>
AuthorDate: Thu Sep 17 21:12:02 2026 +0800

    branch-4.1: [fix](protocol) Prevent Connector/J cursor fetch from hanging 
on empty results (#68064)
    
    https://github.com/apache/doris/pull/67520
---
 .../doris/mysql/MysqlCursorFetchCompatibility.java |  61 ++++++
 .../java/org/apache/doris/mysql/MysqlOkPacket.java |  15 +-
 .../java/org/apache/doris/mysql/MysqlProto.java    |  10 +-
 .../doris/mysql/MysqlResultSetEndPacket.java       |   1 +
 .../nereids/trees/plans/commands/LoadCommand.java  |   2 +-
 .../plans/commands/load/MysqlLoadCommand.java      |   2 +-
 .../java/org/apache/doris/qe/ConnectContext.java   |  12 ++
 .../java/org/apache/doris/qe/ConnectProcessor.java |  27 ++-
 .../java/org/apache/doris/qe/FEOpExecutor.java     |  85 +++++++-
 .../org/apache/doris/qe/MysqlConnectProcessor.java |  12 +-
 .../java/org/apache/doris/qe/StmtExecutor.java     |  40 ++--
 .../mysql/MysqlCursorFetchCompatibilityTest.java   |  59 ++++++
 .../org/apache/doris/mysql/MysqlOkPacketTest.java  |  19 +-
 .../org/apache/doris/mysql/MysqlProtoTest.java     |  38 +++-
 .../doris/mysql/MysqlResultSetEndPacketTest.java   |  26 +++
 .../commands/MysqlLoadCommandCapabilityTest.java   |  85 ++++++++
 .../qe/ConnectProcessorForwardProtocolTest.java    | 148 +++++++++++++
 .../doris/qe/FEOpExecutorMysqlProtocolTest.java    | 229 +++++++++++++++++++++
 .../qe/MysqlConnectProcessorCursorFetchTest.java   | 112 ++++++++++
 .../java/org/apache/doris/qe/StmtExecutorTest.java |  98 +++++++++
 gensrc/thrift/FrontendService.thrift               |   6 +
 .../prepared_stmt_p0/cursor_fetch_empty_result.out |  11 +
 .../suites/arrow_flight_sql_p0/test_ddl.groovy     |  25 +++
 .../cursor_fetch_empty_result.groovy               | 107 ++++++++++
 24 files changed, 1171 insertions(+), 59 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlCursorFetchCompatibility.java
 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlCursorFetchCompatibility.java
new file mode 100644
index 00000000000..981ce75da47
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlCursorFetchCompatibility.java
@@ -0,0 +1,61 @@
+// 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.mysql;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/** Resolves the incompatible cursor result-set behavior used by Connector/J 
releases. */
+public final class MysqlCursorFetchCompatibility {
+    private static final Set<String> MYSQL_CONNECTOR_J_CLIENT_NAMES = 
ImmutableSet.of(
+            "MySQL Connector/J", "MySQL Connector Java");
+    private static final Pattern CONSUMES_METADATA_TERMINATOR =
+            Pattern.compile("^(?:(?:5|6|8)\\.|9\\.[0-4](?:\\.|$))");
+    private static final Pattern VERSION = 
Pattern.compile("^\\d+(?:\\.\\d+)+(?:[-+].*)?$");
+
+    public enum Behavior {
+        CONSUMES_METADATA_TERMINATOR,
+        STANDARD,
+        UNKNOWN
+    }
+
+    private MysqlCursorFetchCompatibility() {
+    }
+
+    public static Behavior resolve(Map<String, String> connectAttributes) {
+        String clientName = connectAttributes.get("_client_name");
+        if (clientName == null) {
+            return Behavior.UNKNOWN;
+        }
+        if (!MYSQL_CONNECTOR_J_CLIENT_NAMES.contains(clientName)) {
+            return Behavior.STANDARD;
+        }
+
+        String clientVersion = connectAttributes.get("_client_version");
+        if (clientVersion == null || 
!VERSION.matcher(clientVersion).matches()) {
+            return Behavior.UNKNOWN;
+        }
+        if (CONSUMES_METADATA_TERMINATOR.matcher(clientVersion).find()) {
+            return Behavior.CONSUMES_METADATA_TERMINATOR;
+        }
+        return Behavior.STANDARD;
+    }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlOkPacket.java 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlOkPacket.java
index 4fa80102317..f9cf43b3691 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlOkPacket.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlOkPacket.java
@@ -58,16 +58,11 @@ public class MysqlOkPacket extends MysqlPacket {
             // TODO(zhaochun): STATUS_FLAGS
             // if ((STATUS_FLAGS & 
MysqlStatusFlag.SERVER_SESSION_STATE_CHANGED) != 0) {
             // }
-        } else {
-            // Always write the info field as a length-encoded string.
-            // When CLIENT_DEPRECATE_EOF is negotiated, the driver's 
OkPacket.parse()
-            // unconditionally reads STRING_LENENC for info, so an empty 
string must
-            // still be written (as a single 0x00 byte representing length 0).
-            if (Strings.isNullOrEmpty(infoMessage)) {
-                serializer.writeVInt(0);
-            } else {
-                serializer.writeLenEncodedString(infoMessage);
-            }
+        } else if (!Strings.isNullOrEmpty(infoMessage)) {
+            serializer.writeLenEncodedString(infoMessage);
+        } else if (capability.isDeprecatedEOF()) {
+            // Connector/J parses the info field for CLIENT_DEPRECATE_EOF even 
when it is empty.
+            serializer.writeVInt(0);
         }
     }
 }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlProto.java 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlProto.java
index 55960c36b89..1137f688fea 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlProto.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlProto.java
@@ -183,10 +183,6 @@ public class MysqlProto {
         if (handshakeResponse == null) {
             return failHandshake(context, 
CLIENT_CLOSED_CONNECTION_DURING_HANDSHAKE);
         }
-        if (capability.isDeprecatedEOF()) {
-            context.getMysqlChannel().setClientDeprecatedEOF();
-        }
-
         // we do not save client capability to context, so here we save 
CLIENT_MULTI_STATEMENTS to MysqlChannel
         if (capability.isClientMultiStatements()) {
             context.getMysqlChannel().setClientMultiStatements();
@@ -208,7 +204,11 @@ public class MysqlProto {
         }
 
         // change the capability of serializer
-        context.setCapability(context.getServerCapability());
+        context.setCapability(new 
MysqlCapability(context.getServerCapability().getFlags()
+                & authPacket.getCapability().getFlags()));
+        if (context.getCapability().isDeprecatedEOF()) {
+            channel.setClientDeprecatedEOF();
+        }
         serializer.setCapability(context.getCapability());
 
         String qualifiedUser = parseUser(context, 
authPacket.getAuthResponse(), authPacket.getUser());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlResultSetEndPacket.java 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlResultSetEndPacket.java
index 5543b85c361..fd76924949d 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlResultSetEndPacket.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/MysqlResultSetEndPacket.java
@@ -40,6 +40,7 @@ public class MysqlResultSetEndPacket extends MysqlPacket {
 
     public MysqlResultSetEndPacket(QueryState state) {
         this.serverStatus = state.serverStatus;
+        this.warningCount = state.getWarningRows();
     }
 
     @Override
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/LoadCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/LoadCommand.java
index 7236898ed43..fb6e948925a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/LoadCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/LoadCommand.java
@@ -481,7 +481,7 @@ public class LoadCommand extends Command implements 
NeedAuditEncryption, Forward
             }
             LoadManager loadManager = ctx.getEnv().getLoadManager();
             if (etlJobType == EtlJobType.LOCAL_FILE) {
-                if (!ctx.getCapability().supportClientLocalFile()) {
+                if (getDataDescriptions().get(0).isClientLocal() && 
!ctx.getCapability().supportClientLocalFile()) {
                     ctx.getState().setError(ErrorCode.ERR_NOT_ALLOWED_COMMAND, 
"This client is not support"
                             + " to load client local file.");
                     return;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/load/MysqlLoadCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/load/MysqlLoadCommand.java
index ed35ee0b679..95dd989c893 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/load/MysqlLoadCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/load/MysqlLoadCommand.java
@@ -219,7 +219,7 @@ public class MysqlLoadCommand extends Command implements 
NoForward {
     private void handleMysqlLoadCommand(ConnectContext ctx) {
         try {
             LoadManager loadManager = ctx.getEnv().getLoadManager();
-            if (!ctx.getCapability().supportClientLocalFile()) {
+            if (mysqlDataDescription.isClientLocal() && 
!ctx.getCapability().supportClientLocalFile()) {
                 ctx.getState().setError(ErrorCode.ERR_NOT_ALLOWED_COMMAND, 
"This client is not support"
                         + " to load client local file.");
                 return;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
index 1d4aac17bd9..620e4427348 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
@@ -291,6 +291,9 @@ public class ConnectContext {
     @Setter
     private ByteBuffer prepareExecuteBuffer;
 
+    // Whether the current COM_STMT_EXECUTE requested a server-side read-only 
cursor.
+    private boolean cursorFetchRequested;
+
     private MysqlHandshakePacket mysqlHandshakePacket;
 
     public void setUserQueryTimeout(int queryTimeout) {
@@ -513,6 +516,14 @@ public class ConnectContext {
         this.connectAttributes = new HashMap<>(connectAttributes);
     }
 
+    public boolean isCursorFetchRequested() {
+        return cursorFetchRequested;
+    }
+
+    public void setCursorFetchRequested(boolean cursorFetchRequested) {
+        this.cursorFetchRequested = cursorFetchRequested;
+    }
+
     public boolean isTxnModel() {
         return txnEntry != null && txnEntry.isTxnModel();
     }
@@ -1027,6 +1038,7 @@ public class ConnectContext {
         statementContext = null;
         loadBackendSelectionDecision = null;
         loadBackendSelectionHint = null;
+        cursorFetchRequested = false;
     }
 
     public PlSqlOperation getPlSqlOperation() {
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 fa6f646f5e8..d6662d9cb6f 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
@@ -40,6 +40,7 @@ import org.apache.doris.common.util.SqlUtils;
 import org.apache.doris.common.util.Util;
 import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.metric.MetricRepo;
+import org.apache.doris.mysql.MysqlCapability;
 import org.apache.doris.mysql.MysqlChannel;
 import org.apache.doris.mysql.MysqlCommand;
 import org.apache.doris.mysql.MysqlPacket;
@@ -712,15 +713,10 @@ public abstract class ConnectProcessor {
         // set compute group
         
ctx.setComputeGroup(Env.getCurrentEnv().getAuth().getComputeGroup(ctx.getQualifiedUser()));
 
-        // Propagate the client's CLIENT_DEPRECATE_EOF capability to the proxy 
channel.
-        // This ensures the master generates packets matching the original 
client's protocol.
-        if (request.isSetClientDeprecatedEOF() && 
request.isClientDeprecatedEOF()) {
-            ctx.getMysqlChannel().setClientDeprecatedEOF();
-        }
-
         ctx.setThreadLocalInfo();
         StmtExecutor executor = null;
         try {
+            restoreForwardedMysqlContext(ctx, request);
             // 0 for compatibility.
             int idx = request.isSetStmtIdx() ? request.getStmtIdx() : 0;
             executor = new StmtExecutor(ctx, new 
OriginStatement(request.getSql(), idx), true);
@@ -796,6 +792,7 @@ public abstract class ConnectProcessor {
             ctx.getState().serverStatus |= 
MysqlServerStatusFlag.SERVER_MORE_RESULTS_EXISTS;
         }
         result.setPacket(getResultPacket());
+        
result.setClientDeprecatedEofApplied(ctx.getMysqlChannel().clientDeprecatedEOF());
         result.setStatus(ctx.getState().toString());
         if (ctx.getState().getStateType() == MysqlStateType.OK) {
             result.setStatusCode(0);
@@ -826,6 +823,24 @@ public abstract class ConnectProcessor {
         return result;
     }
 
+    static void restoreForwardedMysqlContext(ConnectContext context, 
TMasterOpRequest request) {
+        int flags = request.isSetMysqlCapability() ? 
request.getMysqlCapability()
+                : MysqlCapability.DEFAULT_CAPABILITY.getFlags()
+                        & 
~MysqlCapability.Flag.CLIENT_DEPRECATE_EOF.getFlagBit();
+        if (request.isSetClientDeprecatedEOF() && 
request.isClientDeprecatedEOF()) {
+            flags |= MysqlCapability.Flag.CLIENT_DEPRECATE_EOF.getFlagBit();
+        }
+        MysqlCapability capability = new MysqlCapability(flags);
+        context.setCapability(capability);
+        context.getMysqlChannel().getSerializer().setCapability(capability);
+        if (capability.isDeprecatedEOF()) {
+            context.getMysqlChannel().setClientDeprecatedEOF();
+        }
+        // Old followers do not carry the cursor flag. Keep their existing 
behavior; they must
+        // be upgraded to preserve cursor intent. Do not reject their ordinary 
prepared statements.
+        context.setCursorFetchRequested(request.isSetCursorFetchRequested() && 
request.isCursorFetchRequested());
+    }
+
     // only Mysql protocol
     public void processOnce() throws IOException, NotImplementedException {
         throw new NotImplementedException("Not Impl processOnce");
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 c02e47ba8da..43a01af0530 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
@@ -24,6 +24,11 @@ import org.apache.doris.common.ClientPool;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.ErrorCode;
 import org.apache.doris.mysql.MysqlCommand;
+import org.apache.doris.mysql.MysqlCursorFetchCompatibility;
+import org.apache.doris.mysql.MysqlProto;
+import org.apache.doris.mysql.MysqlResultSetEndPacket;
+import org.apache.doris.mysql.MysqlSerializer;
+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;
@@ -42,6 +47,7 @@ import org.apache.thrift.TException;
 import org.apache.thrift.transport.TTransportException;
 
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -210,11 +216,18 @@ public class FEOpExecutor {
             if (null != ctx.getPrepareExecuteBuffer()) {
                 params.setPrepareExecuteBuffer(ctx.getPrepareExecuteBuffer());
             }
+            params.setCursorFetchRequested(ctx.isCursorFetchRequested());
         }
 
         // 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());
+            params.setMysqlCapability(ctx.getCapability().getFlags());
+        }
 
         return params;
     }
@@ -247,6 +260,76 @@ public class FEOpExecutor {
         return result.packet;
     }
 
+    public boolean isClientDeprecatedEofApplied() {
+        return result != null && result.isSetClientDeprecatedEofApplied()
+                && result.isClientDeprecatedEofApplied();
+    }
+
+    public boolean hasQueryResultPackets() {
+        return result != null && result.isSetQueryResultBufList()
+                && !result.getQueryResultBufList().isEmpty();
+    }
+
+    // An old master cannot add the Connector/J cursor terminator. Normalize 
its buffered
+    // result at the follower, which still has the original execute flag and 
client capability.
+    // DML/DDL OK and ERR packets are retained verbatim, including warnings 
and load info.
+    public void prepareQueryResultForClient() {
+        if (!ctx.getMysqlChannel().clientDeprecatedEOF() || 
isClientDeprecatedEofApplied()
+                || !hasQueryResultPackets()) {
+            return;
+        }
+        List<ByteBuffer> packets = new 
ArrayList<>(result.getQueryResultBufList());
+        int metadataEnd = 
Math.toIntExact(MysqlProto.readVInt(packets.get(0).duplicate())) + 1;
+        boolean needsCursorTerminator = ctx.isCursorFetchRequested()
+                && 
MysqlCursorFetchCompatibility.resolve(ctx.getConnectAttributes())
+                        != MysqlCursorFetchCompatibility.Behavior.STANDARD;
+        // An execution error may occur after only part of the metadata has 
been buffered.
+        if (metadataEnd > packets.size()) {
+            Preconditions.checkState(isErrorPacket(result.packet));
+            return;
+        }
+        boolean hasMetadataTerminator = metadataEnd < packets.size()
+                && isEofPacket(packets.get(metadataEnd));
+        if (hasMetadataTerminator) {
+            ByteBuffer metadata = packets.remove(metadataEnd);
+            if (needsCursorTerminator) {
+                packets.add(metadataEnd, resultSetTerminator(metadata));
+            }
+        } else if (needsCursorTerminator) {
+            MysqlSerializer serializer = 
MysqlSerializer.newInstance(ctx.getCapability());
+            new MysqlResultSetEndPacket(new QueryState()).writeTo(serializer);
+            packets.add(metadataEnd, serializer.toByteBuffer());
+        }
+        result.setQueryResultBufList(packets);
+        if (isEofPacket(result.packet)) {
+            result.setPacket(resultSetTerminator(result.packet));
+        }
+        result.setClientDeprecatedEofApplied(true);
+    }
+
+    private static boolean isErrorPacket(ByteBuffer packet) {
+        return Byte.toUnsignedInt(packet.get(packet.position())) == 0xFF;
+    }
+
+    private static boolean isEofPacket(ByteBuffer packet) {
+        return packet.remaining() <= 8 && 
Byte.toUnsignedInt(packet.get(packet.position())) == 0xFE;
+    }
+
+    private static ByteBuffer resultSetTerminator(ByteBuffer packet) {
+        if (packet.remaining() != 5) {
+            return packet;
+        }
+        ByteBuffer eof = packet.duplicate();
+        MysqlProto.readInt1(eof);
+        int warnings = MysqlProto.readInt2(eof);
+        QueryState state = new QueryState();
+        state.setOk(0, warnings, null);
+        state.serverStatus = MysqlProto.readInt2(eof);
+        MysqlSerializer serializer = MysqlSerializer.newInstance();
+        new MysqlResultSetEndPacket(state).writeTo(serializer);
+        return serializer.toByteBuffer();
+    }
+
     public TUniqueId getQueryId() {
         if (result != null && result.isSetQueryId()) {
             return result.getQueryId();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
index 5bda3026fa6..eb20e67bdac 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
@@ -62,6 +62,7 @@ import java.util.Optional;
  */
 public class MysqlConnectProcessor extends ConnectProcessor {
     private static final Logger LOG = 
LogManager.getLogger(MysqlConnectProcessor.class);
+    private static final int CURSOR_TYPE_READ_ONLY = 0x01;
 
     private ByteBuffer packetBuf;
 
@@ -127,13 +128,14 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
         String stmtStr = "";
         try {
             StatementContext statementContext = prepCtx.statementContext;
+            if (!ctx.isProxy()) {
+                // An empty buffer still identifies a zero-parameter 
COM_STMT_EXECUTE when forwarding.
+                ctx.setPrepareExecuteBuffer(packetBuf.duplicate());
+            }
             if (paramCount > 0) {
                 if (LOG.isDebugEnabled()) {
                     LOG.debug("execute param buf: {}, array: {}", packetBuf, 
getHexStr(packetBuf));
                 }
-                if (!ctx.isProxy()) {
-                    ctx.setPrepareExecuteBuffer(packetBuf.duplicate());
-                }
                 byte[] nullbitmapData = new byte[(paramCount + 7) / 8];
                 packetBuf.get(nullbitmapData);
                 // new_params_bind_flag
@@ -211,8 +213,8 @@ public class MysqlConnectProcessor extends ConnectProcessor 
{
         packetBuf = packetBuf.order(ByteOrder.LITTLE_ENDIAN);
         // parse stmt_id, flags, params
         int stmtId = packetBuf.getInt();
-        // flag
-        packetBuf.get();
+        int flags = Byte.toUnsignedInt(packetBuf.get());
+        ctx.setCursorFetchRequested((flags & CURSOR_TYPE_READ_ONLY) != 0);
         // iteration_count always 1,
         packetBuf.getInt();
         if (LOG.isDebugEnabled()) {
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 5227806aefc..b349cf9160d 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
@@ -67,7 +67,9 @@ import org.apache.doris.metric.MetricRepo;
 import org.apache.doris.mysql.FieldInfo;
 import org.apache.doris.mysql.MysqlChannel;
 import org.apache.doris.mysql.MysqlCommand;
+import org.apache.doris.mysql.MysqlCursorFetchCompatibility;
 import org.apache.doris.mysql.MysqlEofPacket;
+import org.apache.doris.mysql.MysqlResultSetEndPacket;
 import org.apache.doris.mysql.MysqlSerializer;
 import org.apache.doris.mysql.ProxyMysqlChannel;
 import org.apache.doris.nereids.NereidsPlanner;
@@ -1730,15 +1732,7 @@ public class StmtExecutor {
             }
             context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
         }
-        // When CLIENT_DEPRECATE_EOF is set, the server should not send the 
intermediate
-        // EOF packet after column definitions. The client will go directly 
from column
-        // definitions to reading data rows.
-        if (!context.getMysqlChannel().clientDeprecatedEOF()) {
-            serializer.reset();
-            MysqlEofPacket eofPacket = new MysqlEofPacket(context.getState());
-            eofPacket.writeTo(serializer);
-            context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
-        }
+        sendMetadataTerminatorIfNeeded(context.getMysqlChannel());
     }
 
     private List<PrimitiveType> exprToStringType(List<Expr> exprs) {
@@ -1864,17 +1858,30 @@ public class StmtExecutor {
                 
context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
             }
         }
-        // When CLIENT_DEPRECATE_EOF is set, the server should not send the 
intermediate
-        // EOF packet after column definitions. The client will go directly 
from column
-        // definitions to reading data rows.
-        if (!context.getMysqlChannel().clientDeprecatedEOF()) {
+        sendMetadataTerminatorIfNeeded(context.getMysqlChannel());
+    }
+
+    private void sendMetadataTerminatorIfNeeded(MysqlChannel channel) throws 
IOException {
+        if (!channel.clientDeprecatedEOF()) {
             serializer.reset();
-            MysqlEofPacket eofPacket = new MysqlEofPacket(context.getState());
-            eofPacket.writeTo(serializer);
-            context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+            new MysqlEofPacket(context.getState()).writeTo(serializer);
+            channel.sendOnePacket(serializer.toByteBuffer());
+        } else if (connectorJConsumesCursorMetadataTerminator()) {
+            // Connector/J before 9.5 consumes the first OK packet after 
column definitions
+            // while probing whether a requested cursor was created. Doris 
does not create a
+            // cursor, so an empty result would otherwise lose its only end 
marker and block.
+            serializer.reset();
+            new 
MysqlResultSetEndPacket(context.getState()).writeTo(serializer);
+            channel.sendOnePacket(serializer.toByteBuffer());
         }
     }
 
+    private boolean connectorJConsumesCursorMetadataTerminator() {
+        return context.isCursorFetchRequested()
+                && 
MysqlCursorFetchCompatibility.resolve(context.getConnectAttributes())
+                        != MysqlCursorFetchCompatibility.Behavior.STANDARD;
+    }
+
     public void sendResultSet(ResultSet resultSet) throws IOException {
         sendResultSet(resultSet, null);
     }
@@ -2377,6 +2384,7 @@ public class StmtExecutor {
         if (masterOpExecutor == null) {
             return;
         }
+        masterOpExecutor.prepareQueryResultForClient();
         List<ByteBuffer> queryResultBufList = 
masterOpExecutor.getQueryResultBufList();
         for (ByteBuffer byteBuffer : queryResultBufList) {
             context.getMysqlChannel().sendOnePacket(byteBuffer);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlCursorFetchCompatibilityTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlCursorFetchCompatibilityTest.java
new file mode 100644
index 00000000000..740b8a4bf13
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlCursorFetchCompatibilityTest.java
@@ -0,0 +1,59 @@
+// 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.mysql;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+public class MysqlCursorFetchCompatibilityTest {
+    @Test
+    public void testConnectorJBehaviorBoundaries() {
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.CONSUMES_METADATA_TERMINATOR,
+                resolve("MySQL Connector Java", "5.1.49"));
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.CONSUMES_METADATA_TERMINATOR,
+                resolve("MySQL Connector/J", "6.0.6"));
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.CONSUMES_METADATA_TERMINATOR,
+                resolve("MySQL Connector/J", "8.2.0"));
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.CONSUMES_METADATA_TERMINATOR,
+                resolve("MySQL Connector/J", "9.4.0"));
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.STANDARD,
+                resolve("MySQL Connector/J", "9.5.0"));
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.STANDARD,
+                resolve("MySQL Connector/J", "9.6.0"));
+    }
+
+    @Test
+    public void testUnknownAndOtherClients() {
+        Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.UNKNOWN,
+                MysqlCursorFetchCompatibility.resolve(Collections.emptyMap()));
+        Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.UNKNOWN,
+                
MysqlCursorFetchCompatibility.resolve(ImmutableMap.of("_client_name", "MySQL 
Connector/J")));
+        Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.UNKNOWN,
+                resolve("MySQL Connector/J", "custom"));
+        
Assertions.assertEquals(MysqlCursorFetchCompatibility.Behavior.STANDARD,
+                resolve("MariaDB Connector/J", "3.5.6"));
+    }
+
+    private MysqlCursorFetchCompatibility.Behavior resolve(String clientName, 
String clientVersion) {
+        return MysqlCursorFetchCompatibility.resolve(ImmutableMap.of(
+                "_client_name", clientName, "_client_version", clientVersion));
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlOkPacketTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlOkPacketTest.java
index 9fe47adbf5e..7ac371dac4e 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlOkPacketTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlOkPacketTest.java
@@ -56,27 +56,20 @@ public class MysqlOkPacketTest {
         // assert warnings, int2: 0
         Assert.assertEquals(0x00, MysqlProto.readInt2(buffer));
 
-        // When infoMessage is empty, an empty len-encoded string (0x00) 
should still be written.
-        // This is required because OkPacket.parse() in MySQL Connector/J 
unconditionally reads
-        // STRING_LENENC for info. Without this byte, the driver throws
-        // ArrayIndexOutOfBoundsException when CLIENT_DEPRECATE_EOF is 
negotiated.
-        Assert.assertEquals(0x00, MysqlProto.readVInt(buffer));
         Assert.assertEquals(0, buffer.remaining());
     }
 
     @Test
-    public void testWritePayloadSizeGreaterThan5() {
-        // When CLIENT_DEPRECATE_EOF is negotiated, the driver distinguishes 
between
-        // EOF packets (payload <= 5) and ResultSet OK packets (payload > 5).
-        // MysqlOkPacket payload must be > 5 to avoid being misidentified as 
EOF.
-        // Payload: 0x00(1) + affected_rows(1) + last_insert_id(1) + status(2) 
+ warnings(2) + info_len(1) = 8
+    public void testWriteEmptyInfoWithDeprecatedEof() {
+        capability = new 
MysqlCapability(MysqlCapability.Flag.CLIENT_PROTOCOL_41.getFlagBit()
+                | MysqlCapability.Flag.CLIENT_DEPRECATE_EOF.getFlagBit());
         MysqlOkPacket packet = new MysqlOkPacket(new QueryState());
         MysqlSerializer serializer = MysqlSerializer.newInstance(capability);
         packet.writeTo(serializer);
 
         ByteBuffer buffer = serializer.toByteBuffer();
-        int payloadLength = buffer.remaining();
-        Assert.assertTrue("OK packet payload should be > 5 for 
CLIENT_DEPRECATE_EOF compatibility, got: "
-                + payloadLength, payloadLength > 5);
+        buffer.position(7);
+        Assert.assertEquals(0x00, MysqlProto.readVInt(buffer));
+        Assert.assertEquals(0, buffer.remaining());
     }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlProtoTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlProtoTest.java
index f8fce33f9b1..607f5e39248 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlProtoTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlProtoTest.java
@@ -144,6 +144,10 @@ public class MysqlProtoTest {
     }
 
     private void mockChannel(String user, boolean sendOk) throws Exception {
+        mockChannel(user, sendOk, 
MysqlCapability.DEFAULT_CAPABILITY.getFlags());
+    }
+
+    private void mockChannel(String user, boolean sendOk, int 
clientCapabilityFlags) throws Exception {
         // mock channel
         new Expectations() {
             {
@@ -163,7 +167,7 @@ public class MysqlProtoTest {
         MysqlSerializer serializer = MysqlSerializer.newInstance();
 
         // capability
-        serializer.writeInt4(MysqlCapability.DEFAULT_CAPABILITY.getFlags());
+        serializer.writeInt4(clientCapabilityFlags);
         // max packet size
         serializer.writeInt4(1024000);
         // character set
@@ -285,6 +289,38 @@ public class MysqlProtoTest {
         Assert.assertTrue(MysqlProto.negotiate(context));
     }
 
+    @Test
+    public void testNegotiateUsesClientServerCapabilityIntersection() throws 
Exception {
+        int clientFlags = MysqlCapability.DEFAULT_CAPABILITY.getFlags()
+                & ~MysqlCapability.Flag.CLIENT_DEPRECATE_EOF.getFlagBit();
+        mockChannel("user", true, clientFlags);
+        MysqlSerializer serializer = MysqlSerializer.newInstance();
+        new Expectations() {
+            {
+                channel.getSerializer();
+                minTimes = 0;
+                result = serializer;
+            }
+        };
+        mockPassword(true);
+        mockAccess();
+        ConnectContext context = new ConnectContext(streamConnection);
+        context.setEnv(env);
+        context.setThreadLocalInfo();
+        Assert.assertTrue(MysqlProto.negotiate(context));
+        Assert.assertEquals(clientFlags, context.getCapability().getFlags());
+        Assert.assertEquals(clientFlags, 
serializer.getCapability().getFlags());
+        new Verifications() {
+            {
+                channel.setClientDeprecatedEOF();
+                times = 0;
+            }
+        };
+        serializer.reset();
+        new MysqlOkPacket(context.getState()).writeTo(serializer);
+        Assert.assertEquals(7, serializer.toByteBuffer().remaining());
+    }
+
     @Test
     public void testNegotiateInitCatalog(@Mocked CatalogMgr catalogMgr) throws 
Exception {
         mockChannel("user", true);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlResultSetEndPacketTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlResultSetEndPacketTest.java
index 8dc0d18877f..233811c93b4 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlResultSetEndPacketTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/MysqlResultSetEndPacketTest.java
@@ -117,4 +117,30 @@ public class MysqlResultSetEndPacketTest {
         Assert.assertTrue("ResultSet OK packet payload should be > 5, got: " + 
rsEndPayloadLength,
                 rsEndPayloadLength > 5);
     }
+
+    @Test
+    public void testPreservesMoreResultsStatus() {
+        QueryState state = new QueryState();
+        state.serverStatus = MysqlServerStatusFlag.SERVER_MORE_RESULTS_EXISTS;
+        MysqlSerializer serializer = MysqlSerializer.newInstance(capability);
+        new MysqlResultSetEndPacket(state).writeTo(serializer);
+
+        ByteBuffer buffer = serializer.toByteBuffer();
+        Assert.assertEquals(0xFE, MysqlProto.readInt1(buffer));
+        Assert.assertEquals(0, MysqlProto.readVInt(buffer));
+        Assert.assertEquals(0, MysqlProto.readVInt(buffer));
+        Assert.assertEquals(MysqlServerStatusFlag.SERVER_MORE_RESULTS_EXISTS, 
MysqlProto.readInt2(buffer));
+    }
+
+    @Test
+    public void testPreservesWarningCount() {
+        QueryState state = new QueryState();
+        state.setOk(0, 3, null);
+        MysqlSerializer serializer = MysqlSerializer.newInstance(capability);
+        new MysqlResultSetEndPacket(state).writeTo(serializer);
+
+        ByteBuffer buffer = serializer.toByteBuffer();
+        buffer.position(5);
+        Assert.assertEquals(3, MysqlProto.readInt2(buffer));
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/MysqlLoadCommandCapabilityTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/MysqlLoadCommandCapabilityTest.java
new file mode 100644
index 00000000000..af3322139db
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/MysqlLoadCommandCapabilityTest.java
@@ -0,0 +1,85 @@
+// 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.nereids.trees.plans.commands;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.load.EtlJobType;
+import org.apache.doris.load.LoadJobRowResult;
+import org.apache.doris.load.loadv2.LoadManager;
+import org.apache.doris.load.loadv2.MysqlLoadManager;
+import org.apache.doris.mysql.MysqlCapability;
+import org.apache.doris.nereids.load.NereidsDataDescription;
+import org.apache.doris.nereids.trees.plans.commands.load.MysqlDataDescription;
+import org.apache.doris.nereids.trees.plans.commands.load.MysqlLoadCommand;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.QueryState;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.HashMap;
+
+public class MysqlLoadCommandCapabilityTest {
+    @Test
+    public void testLocalFilesCapabilityOnlyRequiredForClientUploads() throws 
Exception {
+        for (boolean clientLocal : new boolean[] {false, true}) {
+            for (boolean supportsLocalFiles : new boolean[] {false, true}) {
+                ConnectContext context = new ConnectContext();
+                Env mockEnv = Mockito.mock(Env.class);
+                LoadManager loadManager = Mockito.mock(LoadManager.class);
+                MysqlLoadManager mysqlLoadManager = 
Mockito.mock(MysqlLoadManager.class);
+                
Mockito.when(mockEnv.getInternalCatalog()).thenReturn(Mockito.mock(InternalCatalog.class));
+                context.setEnv(mockEnv);
+                context.setCapability(new MysqlCapability(supportsLocalFiles
+                        ? MysqlCapability.Flag.CLIENT_LOCAL_FILES.getFlagBit() 
: 0));
+                Mockito.when(mockEnv.getLoadManager()).thenReturn(loadManager);
+                
Mockito.when(loadManager.getMysqlLoadManager()).thenReturn(mysqlLoadManager);
+                MysqlDataDescription description = 
Mockito.mock(MysqlDataDescription.class);
+                
Mockito.when(description.isClientLocal()).thenReturn(clientLocal);
+                
Mockito.when(mysqlLoadManager.executeMySqlLoadJob(Mockito.eq(context),
+                        Mockito.eq(description), 
Mockito.anyString())).thenReturn(new LoadJobRowResult());
+                MysqlLoadCommand command = new MysqlLoadCommand(description, 
new HashMap<>(), "test");
+                Deencapsulation.invoke(command, "handleMysqlLoadCommand", 
context);
+                boolean rejected = clientLocal && !supportsLocalFiles;
+                Assertions.assertEquals(rejected ? 
QueryState.MysqlStateType.ERR : QueryState.MysqlStateType.OK,
+                        context.getState().getStateType());
+                Mockito.verify(mysqlLoadManager, Mockito.times(rejected ? 0 : 
1))
+                        .executeMySqlLoadJob(Mockito.eq(context), 
Mockito.eq(description), Mockito.anyString());
+
+                context.getState().reset();
+                NereidsDataDescription nereidsDescription = 
Mockito.mock(NereidsDataDescription.class);
+                
Mockito.when(nereidsDescription.isClientLocal()).thenReturn(clientLocal);
+                
Mockito.when(mysqlLoadManager.executeMySqlLoadJobFromCommand(Mockito.eq(context),
+                        Mockito.eq(nereidsDescription), 
Mockito.anyString())).thenReturn(new LoadJobRowResult());
+                LoadCommand loadCommand = Mockito.mock(LoadCommand.class, 
Mockito.CALLS_REAL_METHODS);
+                Deencapsulation.setField(loadCommand, "etlJobType", 
EtlJobType.LOCAL_FILE);
+                
Mockito.doReturn(Collections.singletonList(nereidsDescription)).when(loadCommand).getDataDescriptions();
+                loadCommand.handleLoadCommand(context, null);
+                Assertions.assertEquals(rejected ? 
QueryState.MysqlStateType.ERR : QueryState.MysqlStateType.OK,
+                        context.getState().getStateType());
+                Mockito.verify(mysqlLoadManager, Mockito.times(rejected ? 0 : 
1))
+                        .executeMySqlLoadJobFromCommand(Mockito.eq(context), 
Mockito.eq(nereidsDescription),
+                                Mockito.anyString());
+            }
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectProcessorForwardProtocolTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectProcessorForwardProtocolTest.java
new file mode 100644
index 00000000000..554f2595afe
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/ConnectProcessorForwardProtocolTest.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.qe;
+
+import org.apache.doris.mysql.DummyMysqlChannel;
+import org.apache.doris.mysql.MysqlCapability;
+import org.apache.doris.mysql.MysqlProto;
+import org.apache.doris.mysql.MysqlSerializer;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class ConnectProcessorForwardProtocolTest {
+    @Test
+    public void testOldMasterSuccessfulEofDoesNotBecomeError() throws 
Exception {
+        TestContext context = new TestContext();
+        StmtExecutor executor = forwardedExecutor();
+        Mockito.when(executor.getProxyStatusCode()).thenReturn(1105);
+        ByteBuffer packet = ByteBuffer.wrap(new byte[] {(byte) 0xFE, 0, 0, 2, 
0, 3, 0, 0});
+        Mockito.when(executor.getOutputPacket()).thenReturn(packet);
+
+        new TestProcessor(context, executor).finalizeCommand();
+
+        Assertions.assertEquals(packet, context.channel.packet);
+        Mockito.verify(executor).sendProxyQueryResult();
+    }
+
+    @Test
+    public void testOldMasterDmlPreservesCompleteOk() throws Exception {
+        TestContext context = new TestContext();
+        StmtExecutor executor = forwardedExecutor();
+        QueryState state = new QueryState();
+        state.setOk(7, 3, "label=load_1,txnId=123,status=VISIBLE");
+        state.serverStatus = 2;
+        MysqlSerializer serializer = MysqlSerializer.newInstance();
+        state.toResponsePacket().writeTo(serializer);
+        ByteBuffer packet = serializer.toByteBuffer();
+        Mockito.when(executor.getOutputPacket()).thenReturn(packet);
+
+        new TestProcessor(context, executor).finalizeCommand();
+
+        Assertions.assertEquals(packet, context.channel.packet);
+        Mockito.verify(executor).sendProxyQueryResult();
+    }
+
+    @Test
+    public void testRemoteErrorsRemainUnchanged() throws Exception {
+        TestContext context = new TestContext();
+        StmtExecutor executor = forwardedExecutor();
+        Mockito.when(executor.getProxyStatusCode()).thenReturn(1064);
+        
Mockito.when(executor.getOutputPacket()).thenReturn(ByteBuffer.wrap(new byte[] 
{(byte) 0xFF, 1}));
+
+        new TestProcessor(context, executor).finalizeCommand();
+
+        Assertions.assertEquals(0xFF, 
MysqlProto.readInt1(context.channel.packet));
+        Mockito.verify(executor).sendProxyQueryResult();
+    }
+
+    @Test
+    public void testNewMasterPacketsRemainUnchanged() throws Exception {
+        TestContext context = new TestContext();
+        StmtExecutor executor = forwardedExecutor();
+
+        new TestProcessor(context, executor).finalizeCommand();
+
+        Mockito.verify(executor).sendProxyQueryResult();
+    }
+
+    @Test
+    public void testLegacyEofClientDoesNotRequireConfirmation() throws 
Exception {
+        TestContext context = new TestContext(false);
+        StmtExecutor executor = forwardedExecutor();
+
+        new TestProcessor(context, executor).finalizeCommand();
+
+        Mockito.verify(executor).sendProxyQueryResult();
+    }
+
+    private StmtExecutor forwardedExecutor() {
+        StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+        Mockito.when(executor.hasForwardedToMaster()).thenReturn(true);
+        Mockito.when(executor.getProxyStatusCode()).thenReturn(0);
+        return executor;
+    }
+
+    private static class TestProcessor extends MysqlConnectProcessor {
+        private TestProcessor(ConnectContext context, StmtExecutor executor) {
+            super(context);
+            this.executor = executor;
+        }
+    }
+
+    private static class TestContext extends ConnectContext {
+        private final RecordingChannel channel;
+
+        private TestContext() {
+            this(true);
+        }
+
+        private TestContext(boolean clientDeprecatedEof) {
+            channel = new RecordingChannel(clientDeprecatedEof);
+        }
+
+        @Override
+        public RecordingChannel getMysqlChannel() {
+            return channel;
+        }
+    }
+
+    private static class RecordingChannel extends DummyMysqlChannel {
+        private ByteBuffer packet;
+
+        private RecordingChannel(boolean clientDeprecatedEof) {
+            int flags = MysqlCapability.Flag.CLIENT_PROTOCOL_41.getFlagBit();
+            if (clientDeprecatedEof) {
+                flags |= 
MysqlCapability.Flag.CLIENT_DEPRECATE_EOF.getFlagBit();
+            }
+            serializer = MysqlSerializer.newInstance(new 
MysqlCapability(flags));
+            if (clientDeprecatedEof) {
+                setClientDeprecatedEOF();
+            }
+        }
+
+        @Override
+        public void sendAndFlush(ByteBuffer packet) throws IOException {
+            this.packet = packet.duplicate();
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/FEOpExecutorMysqlProtocolTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/FEOpExecutorMysqlProtocolTest.java
new file mode 100644
index 00000000000..a3e235f56f5
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/FEOpExecutorMysqlProtocolTest.java
@@ -0,0 +1,229 @@
+// 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.mysql.MysqlCapability;
+import org.apache.doris.mysql.MysqlCommand;
+import org.apache.doris.mysql.MysqlProto;
+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.TMasterOpResult;
+import org.apache.doris.thrift.TNetworkAddress;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.thrift.TDeserializer;
+import org.apache.thrift.TSerializer;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class FEOpExecutorMysqlProtocolTest {
+    @Test
+    public void testForwardRequestCarriesMysqlProtocolContext() throws 
Exception {
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getSelfNode()).thenReturn(new 
SystemInfoService.HostInfo("127.0.0.1", 9010));
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            ConnectContext context = createContext();
+            context.getMysqlChannel().setClientDeprecatedEOF();
+            context.setCommand(MysqlCommand.COM_STMT_EXECUTE);
+            context.setCursorFetchRequested(true);
+            context.setConnectAttributes(ImmutableMap.of(
+                    "_client_name", "MySQL Connector/J", "_client_version", 
"8.2.0"));
+
+            TMasterOpRequest request = new TestFEOpExecutor(context).build();
+
+            Assertions.assertTrue(request.isClientDeprecatedEOF());
+            Assertions.assertTrue(request.isCursorFetchRequested());
+            Assertions.assertEquals("8.2.0", 
request.getConnectAttributes().get("_client_version"));
+
+            for (byte[] payload : Arrays.asList(new byte[0], new byte[] {0, 1, 
3, 0, 42, 0, 0, 0})) {
+                context.setPrepareExecuteBuffer(ByteBuffer.wrap(payload));
+                request = new TestFEOpExecutor(context).build();
+                TMasterOpRequest restored = new TMasterOpRequest();
+                new TDeserializer().deserialize(restored, new 
TSerializer().serialize(request));
+                // Presence, including a zero-length payload, selects 
COM_STMT_EXECUTE on the master.
+                Assertions.assertTrue(restored.isSetPrepareExecuteBuffer());
+                Assertions.assertArrayEquals(payload, 
restored.getPrepareExecuteBuffer());
+                Assertions.assertTrue(restored.isCursorFetchRequested());
+            }
+        }
+    }
+
+    @Test
+    public void testForwardResponseRequiresExplicitProtocolConfirmation() {
+        TestFEOpExecutor executor = new TestFEOpExecutor(createContext());
+        executor.setResult(new TMasterOpResult());
+        Assertions.assertFalse(executor.isClientDeprecatedEofApplied());
+        Assertions.assertFalse(executor.hasQueryResultPackets());
+
+        TMasterOpResult confirmed = new TMasterOpResult();
+        confirmed.setClientDeprecatedEofApplied(true);
+        
confirmed.setQueryResultBufList(Collections.singletonList(ByteBuffer.wrap(new 
byte[] {1})));
+        confirmed.setAffectedRows(7);
+        executor.setResult(confirmed);
+        Assertions.assertTrue(executor.isClientDeprecatedEofApplied());
+        Assertions.assertTrue(executor.hasQueryResultPackets());
+    }
+
+    @Test
+    public void testArrowForwardRequestDoesNotAccessMysqlChannel() throws 
Exception {
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getSelfNode()).thenReturn(new 
SystemInfoService.HostInfo("127.0.0.1", 9010));
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            FlightSqlConnectContext context = new 
FlightSqlConnectContext("alice");
+            
context.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("alice",
 "%"));
+            context.setRemoteIP("127.0.0.1");
+            TMasterOpRequest request = new TestFEOpExecutor(context).build();
+            Assertions.assertFalse(request.isSetClientDeprecatedEOF());
+            Assertions.assertFalse(request.isSetMysqlCapability());
+        }
+    }
+
+    @Test
+    public void testForwardedCapabilityAndMissingCursorFlag() throws Exception 
{
+        int legacyFlags = MysqlCapability.DEFAULT_CAPABILITY.getFlags()
+                & ~MysqlCapability.Flag.CLIENT_DEPRECATE_EOF.getFlagBit();
+        TMasterOpRequest request = new TMasterOpRequest();
+        request.setMysqlCapability(legacyFlags);
+        ConnectContext context = createContext();
+        ConnectProcessor.restoreForwardedMysqlContext(context, request);
+        Assertions.assertEquals(legacyFlags, 
context.getCapability().getFlags());
+        
Assertions.assertFalse(context.getMysqlChannel().getSerializer().getCapability().isDeprecatedEOF());
+
+        request = new TMasterOpRequest();
+        request.setClientDeprecatedEOF(true);
+        request.setPrepareExecuteBuffer(new byte[] {0});
+        context = createContext();
+        context.setConnectAttributes(ImmutableMap.of("_client_name", "MySQL 
Connector/J", "_client_version", "8.2.0"));
+        ConnectProcessor.restoreForwardedMysqlContext(context, request);
+        Assertions.assertFalse(context.isCursorFetchRequested());
+        Assertions.assertTrue(context.getCapability().isDeprecatedEOF());
+        request.setCursorFetchRequested(true);
+        ConnectProcessor.restoreForwardedMysqlContext(context, request);
+        Assertions.assertTrue(context.isCursorFetchRequested());
+        request.setCursorFetchRequested(false);
+        ConnectProcessor.restoreForwardedMysqlContext(context, request);
+        Assertions.assertFalse(context.isCursorFetchRequested());
+    }
+
+    @Test
+    public void testOldMasterPacketMatrix() {
+        for (boolean legacyMaster : new boolean[] {false, true}) {
+            for (boolean cursor : new boolean[] {false, true}) {
+                for (String version : new String[] {"8.2.0", "9.4.0", 
"9.5.0"}) {
+                    for (boolean rows : new boolean[] {false, true}) {
+                        ConnectContext context = createContext();
+                        context.getMysqlChannel().setClientDeprecatedEOF();
+                        context.setCursorFetchRequested(cursor);
+                        context.setConnectAttributes(ImmutableMap.of(
+                                "_client_name", "MySQL Connector/J", 
"_client_version", version));
+                        TestFEOpExecutor executor = new 
TestFEOpExecutor(context);
+                        List<ByteBuffer> packets = new ArrayList<>();
+                        packets.add(ByteBuffer.wrap(new byte[] {1})); // 
column count
+                        packets.add(ByteBuffer.wrap(new byte[] {3, 'd', 'e', 
'f'})); // opaque column definition
+                        if (legacyMaster) {
+                            packets.add(ByteBuffer.wrap(new byte[] {(byte) 
0xFE, 3, 0, 2, 0}));
+                        }
+                        ByteBuffer row = ByteBuffer.wrap(new byte[] {0, 0, 
42});
+                        if (rows) {
+                            packets.add(row);
+                        }
+                        TMasterOpResult result = new TMasterOpResult();
+                        result.setQueryResultBufList(packets);
+                        result.setStatus("EOF");
+                        result.setStatusCode(1105); // production successful 
SELECT mapping
+                        result.setPacket(ByteBuffer.wrap(legacyMaster
+                                ? new byte[] {(byte) 0xFE, 3, 0, 2, 0}
+                                : new byte[] {(byte) 0xFE, 0, 0, 2, 0, 3, 0, 
0}));
+                        executor.setResult(result);
+                        executor.prepareQueryResultForClient();
+                        boolean shim = cursor && !version.equals("9.5.0");
+                        Assertions.assertEquals(2 + (shim ? 1 : 0) + (rows ? 1 
: 0),
+                                executor.getQueryResultBufList().size());
+                        if (shim) {
+                            Assertions.assertEquals(8, 
executor.getQueryResultBufList().get(2).remaining());
+                        }
+                        if (rows) {
+                            Assertions.assertSame(row, 
executor.getQueryResultBufList().get(shim ? 3 : 2));
+                        }
+                        ByteBuffer end = 
executor.getOutputPacket().duplicate();
+                        Assertions.assertEquals(0xFE, 
MysqlProto.readInt1(end));
+                        Assertions.assertEquals(0, MysqlProto.readVInt(end));
+                        Assertions.assertEquals(0, MysqlProto.readVInt(end));
+                        Assertions.assertEquals(2, MysqlProto.readInt2(end));
+                        Assertions.assertEquals(3, MysqlProto.readInt2(end));
+                        List<ByteBuffer> normalized = 
executor.getQueryResultBufList();
+                        executor.prepareQueryResultForClient();
+                        Assertions.assertSame(normalized, 
executor.getQueryResultBufList());
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testOkAndErrorAreNotRebuilt() {
+        ConnectContext context = createContext();
+        context.getMysqlChannel().setClientDeprecatedEOF();
+        TestFEOpExecutor executor = new TestFEOpExecutor(context);
+        for (byte[] bytes : Arrays.asList(new byte[] {0, 7, 0, 2, 0, 3, 0, 4, 
'i', 'n', 'f', 'o'},
+                new byte[] {(byte) 0xFF, 1, 2})) {
+            TMasterOpResult result = new TMasterOpResult();
+            ByteBuffer packet = ByteBuffer.wrap(bytes);
+            result.setPacket(packet);
+            executor.setResult(result);
+            executor.prepareQueryResultForClient();
+            Assertions.assertEquals(packet, executor.getOutputPacket());
+        }
+    }
+
+    private ConnectContext createContext() {
+        ConnectContext context = new ConnectContext();
+        
context.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("alice",
 "%"));
+        context.setRemoteIP("127.0.0.1");
+        context.setCapability(MysqlCapability.DEFAULT_CAPABILITY);
+        return context;
+    }
+
+    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();
+        }
+
+        private void setResult(TMasterOpResult result) {
+            this.result = result;
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlConnectProcessorCursorFetchTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlConnectProcessorCursorFetchTest.java
new file mode 100644
index 00000000000..8f788d75ad4
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlConnectProcessorCursorFetchTest.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.qe;
+
+import org.apache.doris.mysql.MysqlCommand;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.trees.plans.commands.PrepareCommand;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+public class MysqlConnectProcessorCursorFetchTest {
+    private static final int CURSOR_TYPE_READ_ONLY = 1;
+
+    @Test
+    public void testZeroParameterExecutePreservesForwardingBuffer() throws 
Exception {
+        for (boolean proxy : new boolean[] {false, true}) {
+            ConnectContext context = new ConnectContext(null, proxy);
+            context.setCommand(MysqlCommand.COM_STMT_EXECUTE);
+            PrepareCommand command = Mockito.mock(PrepareCommand.class);
+            Mockito.when(command.getOriginalStmt()).thenReturn(new 
OriginStatement("select 1", 0));
+            StatementContext statementContext = new StatementContext();
+            PreparedStatementContext prepared = new PreparedStatementContext(
+                    command, context, statementContext, "select 1");
+            ByteBuffer packet = ByteBuffer.allocate(9);
+            packet.position(packet.limit()); // COM_STMT_EXECUTE header 
consumed, no parameter payload.
+            try (MockedConstruction<StmtExecutor> executors = 
Mockito.mockConstruction(StmtExecutor.class);
+                    MockedStatic<AuditLogHelper> audit = 
Mockito.mockStatic(AuditLogHelper.class)) {
+                new MysqlConnectProcessor(context).handleExecute(command, 7, 
prepared, packet, null);
+                Assertions.assertEquals(1, executors.constructed().size());
+                Mockito.verify(executors.constructed().get(0)).execute();
+                if (proxy) {
+                    Assertions.assertNull(context.getPrepareExecuteBuffer());
+                } else {
+                    
Assertions.assertNotNull(context.getPrepareExecuteBuffer());
+                    Assertions.assertNotSame(packet, 
context.getPrepareExecuteBuffer());
+                    Assertions.assertEquals(0, 
context.getPrepareExecuteBuffer().remaining());
+                    Assertions.assertEquals(9, packet.position());
+                }
+            }
+        }
+    }
+
+    @Test
+    public void 
testUnidentifiedDeprecatedEofCursorReachesPreparedStatementLookup() throws 
Exception {
+        ConnectContext context = execute(true, true, false);
+        Assertions.assertTrue(context.getState().getErrorMessage().contains(
+                "Unknown prepared statement handler"));
+    }
+
+    @Test
+    public void testCompatibilityGateOnlyAppliesToAmbiguousProtocol() throws 
Exception {
+        Assertions.assertTrue(execute(false, true, 
false).getState().getErrorMessage().contains(
+                "Unknown prepared statement handler"));
+        Assertions.assertTrue(execute(true, false, 
false).getState().getErrorMessage().contains(
+                "Unknown prepared statement handler"));
+        Assertions.assertTrue(execute(true, true, 
true).getState().getErrorMessage().contains(
+                "Unknown prepared statement handler"));
+    }
+
+    private ConnectContext execute(boolean cursorRequested, boolean 
clientDeprecatedEof,
+            boolean identifiedClient) throws Exception {
+        ConnectContext context = new ConnectContext();
+        context.setCommand(MysqlCommand.COM_STMT_EXECUTE);
+        if (clientDeprecatedEof) {
+            context.getMysqlChannel().setClientDeprecatedEOF();
+        }
+        if (identifiedClient) {
+            context.setConnectAttributes(ImmutableMap.of(
+                    "_client_name", "MySQL Connector/J", "_client_version", 
"8.2.0"));
+        }
+
+        ByteBuffer packet = 
ByteBuffer.allocate(9).order(ByteOrder.LITTLE_ENDIAN);
+        packet.putInt(7);
+        packet.put((byte) (cursorRequested ? CURSOR_TYPE_READ_ONLY : 0));
+        packet.putInt(1);
+        packet.flip();
+
+        MysqlConnectProcessor processor = new MysqlConnectProcessor(context);
+        Field packetField = 
MysqlConnectProcessor.class.getDeclaredField("packetBuf");
+        packetField.setAccessible(true);
+        packetField.set(processor, packet);
+        Method handleExecute = 
MysqlConnectProcessor.class.getDeclaredMethod("handleExecute");
+        handleExecute.setAccessible(true);
+        handleExecute.invoke(processor);
+        return context;
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java
index 10eb1c0baef..ba51bbee34a 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/StmtExecutorTest.java
@@ -34,6 +34,7 @@ import org.apache.doris.thrift.TQueryOptions;
 import org.apache.doris.thrift.TUniqueId;
 import org.apache.doris.utframe.TestWithFeService;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import org.junit.Assert;
 import org.junit.jupiter.api.Assertions;
@@ -46,7 +47,10 @@ import java.io.IOException;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 
 public class StmtExecutorTest extends TestWithFeService {
@@ -385,6 +389,100 @@ public class StmtExecutorTest extends TestWithFeService {
         executor.sendBinaryResultRow(resultSet);
     }
 
+    @Test
+    public void testCursorFetchMetadataTerminatorDependsOnConnectorJVersion() 
throws IOException {
+        List<byte[]> connector82Packets = sendEmptyResultSet(true, "MySQL 
Connector/J", "8.2.0");
+        Assertions.assertEquals(3, connector82Packets.size());
+        Assertions.assertEquals(0xFE, 
Byte.toUnsignedInt(connector82Packets.get(2)[0]));
+        Assertions.assertTrue(connector82Packets.get(2).length > 5);
+
+        Assertions.assertEquals(3, sendEmptyResultSet(true, "MySQL Connector 
Java", "5.1.49").size());
+        Assertions.assertEquals(3, sendEmptyResultSet(true, "MySQL 
Connector/J", "6.0.6").size());
+        Assertions.assertEquals(3, sendEmptyResultSet(true, "MySQL 
Connector/J", "9.4.0").size());
+        Assertions.assertEquals(2, sendEmptyResultSet(true, "MySQL 
Connector/J", "9.5.0").size());
+        Assertions.assertEquals(2, sendEmptyResultSet(false, "MySQL 
Connector/J", "8.2.0").size());
+        Assertions.assertEquals(2, sendEmptyResultSet(true, "MariaDB 
Connector/J", "3.5.6").size());
+        Assertions.assertEquals(3, sendEmptyResultSet(true, 
Collections.emptyMap()).size());
+
+        List<byte[]> legacyEofPackets = sendEmptyResultSet(true, "MySQL 
Connector/J", "8.2.0", false);
+        Assertions.assertEquals(3, legacyEofPackets.size());
+        Assertions.assertEquals(5, legacyEofPackets.get(2).length);
+    }
+
+    @Test
+    public void testPrepareMetadataTerminatorsFollowNegotiatedCapability() 
throws IOException {
+        Assertions.assertEquals(3, sendPrepareMetadata(false).size());
+        Assertions.assertEquals(2, sendPrepareMetadata(true).size());
+    }
+
+    private List<byte[]> sendPrepareMetadata(boolean clientDeprecatedEof) 
throws IOException {
+        ConnectContext mockCtx = Mockito.mock(ConnectContext.class);
+        MysqlChannel channel = Mockito.mock(MysqlChannel.class);
+        Mockito.when(mockCtx.getConnectType()).thenReturn(ConnectType.MYSQL);
+        Mockito.when(mockCtx.getMysqlChannel()).thenReturn(channel);
+        Mockito.when(mockCtx.getState()).thenReturn(new QueryState());
+        Mockito.when(mockCtx.getSessionVariable()).thenReturn(new 
SessionVariable());
+        
Mockito.when(channel.clientDeprecatedEOF()).thenReturn(clientDeprecatedEof);
+        
Mockito.when(channel.getSerializer()).thenReturn(MysqlSerializer.newInstance());
+
+        List<byte[]> packets = new ArrayList<>();
+        Mockito.doAnswer(invocation -> {
+            ByteBuffer packet = invocation.getArgument(0);
+            byte[] copy = new byte[packet.remaining()];
+            packet.duplicate().get(copy);
+            packets.add(copy);
+            return null;
+        }).when(channel).sendOnePacket(Mockito.any(ByteBuffer.class));
+
+        new StmtExecutor(mockCtx, new OriginStatement("", 0), 
true).sendStmtPrepareOK(
+                1, Collections.singletonList("p"), Collections.emptyList());
+        return packets;
+    }
+
+    private List<byte[]> sendEmptyResultSet(boolean cursorFetchRequested, 
String clientName,
+            String clientVersion) throws IOException {
+        return sendEmptyResultSet(cursorFetchRequested, clientName, 
clientVersion, true);
+    }
+
+    private List<byte[]> sendEmptyResultSet(boolean cursorFetchRequested, 
String clientName,
+            String clientVersion, boolean clientDeprecatedEof) throws 
IOException {
+        return sendEmptyResultSet(cursorFetchRequested, ImmutableMap.of(
+                "_client_name", clientName, "_client_version", clientVersion), 
clientDeprecatedEof);
+    }
+
+    private List<byte[]> sendEmptyResultSet(boolean cursorFetchRequested,
+            Map<String, String> connectAttributes) throws IOException {
+        return sendEmptyResultSet(cursorFetchRequested, connectAttributes, 
true);
+    }
+
+    private List<byte[]> sendEmptyResultSet(boolean cursorFetchRequested,
+            Map<String, String> connectAttributes, boolean 
clientDeprecatedEof) throws IOException {
+        ConnectContext mockCtx = Mockito.mock(ConnectContext.class);
+        MysqlChannel channel = Mockito.mock(MysqlChannel.class);
+        Mockito.when(mockCtx.getConnectType()).thenReturn(ConnectType.MYSQL);
+        Mockito.when(mockCtx.getMysqlChannel()).thenReturn(channel);
+        Mockito.when(mockCtx.getState()).thenReturn(new QueryState());
+        
Mockito.when(mockCtx.getSessionVariable()).thenReturn(VariableMgr.newSessionVariable());
+        
Mockito.when(mockCtx.isCursorFetchRequested()).thenReturn(cursorFetchRequested);
+        
Mockito.when(mockCtx.getConnectAttributes()).thenReturn(connectAttributes);
+        
Mockito.when(channel.clientDeprecatedEOF()).thenReturn(clientDeprecatedEof);
+        
Mockito.when(channel.getSerializer()).thenReturn(MysqlSerializer.newInstance());
+
+        List<byte[]> packets = new ArrayList<>();
+        Mockito.doAnswer(invocation -> {
+            ByteBuffer packet = invocation.getArgument(0);
+            byte[] copy = new byte[packet.remaining()];
+            packet.duplicate().get(copy);
+            packets.add(copy);
+            return null;
+        }).when(channel).sendOnePacket(Mockito.any(ByteBuffer.class));
+
+        List<Column> columns = Collections.singletonList(new Column("c", 
PrimitiveType.INT));
+        ResultSet resultSet = new CommonResultSet(new 
CommonResultSetMetaData(columns), Collections.emptyList());
+        new StmtExecutor(mockCtx, new OriginStatement("", 0), 
true).sendResultSet(resultSet);
+        return packets;
+    }
+
     @Test
     public void testSendBinaryBooleanResultRow() throws IOException {
         ConnectContext mockCtx = Mockito.mock(ConnectContext.class);
diff --git a/gensrc/thrift/FrontendService.thrift 
b/gensrc/thrift/FrontendService.thrift
index 2fe8e3e1ffe..24889633d3d 100644
--- a/gensrc/thrift/FrontendService.thrift
+++ b/gensrc/thrift/FrontendService.thrift
@@ -433,6 +433,10 @@ struct TMasterOpRequest {
     1002: optional string sessionId
     // propagate client's CLIENT_DEPRECATE_EOF capability for proxy forwarding
     1003: optional bool clientDeprecatedEOF
+    // Whether COM_STMT_EXECUTE requested CURSOR_TYPE_READ_ONLY.
+    1008: optional bool cursor_fetch_requested
+    // Capabilities negotiated with the original MySQL client.
+    1009: optional i32 mysql_capability
 }
 
 struct TColumnDefinition {
@@ -464,6 +468,8 @@ struct TMasterOpResult {
     9: optional TTxnLoadInfo txnLoadInfo;
     10: optional i64 groupCommitLoadBeId;
     11: optional i64 affectedRows;
+    // Confirms that the executing FE serialized raw MySQL packets with 
CLIENT_DEPRECATE_EOF.
+    13: optional bool clientDeprecatedEofApplied;
 }
 
 // Certificate-based authentication info forwarded from BE to FE
diff --git 
a/regression-test/data/prepared_stmt_p0/cursor_fetch_empty_result.out 
b/regression-test/data/prepared_stmt_p0/cursor_fetch_empty_result.out
new file mode 100644
index 00000000000..983864abdaf
--- /dev/null
+++ b/regression-test/data/prepared_stmt_p0/cursor_fetch_empty_result.out
@@ -0,0 +1,11 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !empty_result --
+
+-- !non_empty_result --
+1
+
+-- !anonymous_empty --
+
+-- !anonymous_non_empty --
+1
+
diff --git a/regression-test/suites/arrow_flight_sql_p0/test_ddl.groovy 
b/regression-test/suites/arrow_flight_sql_p0/test_ddl.groovy
new file mode 100644
index 00000000000..c9dfa13cb3c
--- /dev/null
+++ b/regression-test/suites/arrow_flight_sql_p0/test_ddl.groovy
@@ -0,0 +1,25 @@
+// 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.
+
+suite("test_ddl", "arrow_flight_sql") {
+    sql "DROP DATABASE IF EXISTS test_arrow_flight_sql_ddl"
+
+    context.getArrowFlightSqlConnection().createStatement().withCloseable { 
statement ->
+        statement.execute("CREATE DATABASE test_arrow_flight_sql_ddl")
+        statement.execute("DROP DATABASE test_arrow_flight_sql_ddl")
+    }
+}
diff --git 
a/regression-test/suites/prepared_stmt_p0/cursor_fetch_empty_result.groovy 
b/regression-test/suites/prepared_stmt_p0/cursor_fetch_empty_result.groovy
new file mode 100644
index 00000000000..82e37dffb99
--- /dev/null
+++ b/regression-test/suites/prepared_stmt_p0/cursor_fetch_empty_result.groovy
@@ -0,0 +1,107 @@
+// 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.
+
+suite("cursor_fetch_empty_result") {
+    String url = getServerPrepareJdbcUrl(context.config.jdbcUrl, 
"regression_test_prepared_stmt_p0") +
+            
"&useCursorFetch=true&defaultFetchSize=10000&socketTimeout=10000&emulateUnsupportedPstmts=false"
+
+    connect(context.config.jdbcUser, context.config.jdbcPassword, url) {
+        // With a positive defaultFetchSize Connector/J also converts a plain 
Statement into a
+        // server-prepared cursor execution, which is how BI tools commonly 
enter this path.
+        context.getConnection().createStatement().withCloseable { statement ->
+            ["SELECT 1 AS c WHERE 1 = 2", "SELECT 1 AS c WHERE 1 = 1"].each { 
query ->
+                statement.executeQuery(query).withCloseable { result ->
+                    while (result.next()) {
+                        result.getInt(1)
+                    }
+                }
+            }
+        }
+
+        def emptyResult = prepareStatement "SELECT 1 AS c WHERE 1 = 2"
+        assertEquals(com.mysql.cj.jdbc.ServerPreparedStatement, 
emptyResult.class)
+        qe_empty_result emptyResult
+        emptyResult.close()
+
+        def nonEmptyResult = prepareStatement "SELECT 1 AS c WHERE 1 = 1"
+        assertEquals(com.mysql.cj.jdbc.ServerPreparedStatement, 
nonEmptyResult.class)
+        qe_non_empty_result nonEmptyResult
+        nonEmptyResult.close()
+    }
+
+    String unidentifiedClientUrl = getServerPrepareJdbcUrl(
+            context.config.jdbcUrl, "regression_test_prepared_stmt_p0") +
+            
"&useCursorFetch=true&defaultFetchSize=10000&connectionAttributes=none&socketTimeout=10000"
 +
+            "&emulateUnsupportedPstmts=false"
+    connect(context.config.jdbcUser, context.config.jdbcPassword, 
unidentifiedClientUrl) {
+        qt_anonymous_empty "SELECT 1 AS c WHERE 1 = 2"
+        qt_anonymous_non_empty "SELECT 1 AS c WHERE 1 = 1"
+    }
+
+    def followers = sql_return_maparray("SHOW FRONTENDS").findAll {
+        it.IsMaster == "false" && it.Alive == "true"
+    }
+    if (followers.isEmpty()) {
+        logger.info("Skip prepared forwarding coverage: no live non-master FE")
+    } else {
+        sql "DROP TABLE IF EXISTS cursor_fetch_forwarding"
+        sql """CREATE TABLE cursor_fetch_forwarding (k INT)
+               DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1
+               PROPERTIES ("replication_num" = "1")"""
+        sql "INSERT INTO cursor_fetch_forwarding VALUES (1), (2), (3)"
+        followers.each { fe ->
+            String followerUrl = getServerPrepareJdbcUrl(
+                    "jdbc:mysql://${fe.Host}:${fe.QueryPort}/", 
"regression_test_prepared_stmt_p0", false) +
+                    
"&useCursorFetch=true&emulateUnsupportedPstmts=false&socketTimeout=10000"
+            connect(context.config.jdbcUser, context.config.jdbcPassword, 
followerUrl) {
+                def connection = context.getConnection()
+                connection.createStatement().withCloseable { control ->
+                    // SET must use COM_QUERY: enabling forwarding before 
PREPARE rejects server prepare.
+                    control.execute("SET force_forward_all_queries=false")
+                    control.execute("SYNC")
+                    [0, 1, 10000].each { fetchSize ->
+                        ["SELECT k FROM cursor_fetch_forwarding ORDER BY k",
+                         "SELECT k FROM cursor_fetch_forwarding WHERE k < 0 
ORDER BY k"].each { query ->
+                            connection.prepareStatement(query).withCloseable { 
prepared ->
+                                
assertEquals(com.mysql.cj.jdbc.ServerPreparedStatement, prepared.class)
+                                prepared.setFetchSize(fetchSize)
+                                def readRows = {
+                                    def rows = []
+                                    prepared.executeQuery().withCloseable { 
result ->
+                                        while (result.next()) {
+                                            rows.add(result.getInt(1))
+                                        }
+                                    }
+                                    return rows
+                                }
+                                def directRows = readRows()
+                                control.execute("SET 
force_forward_all_queries=true")
+                                try {
+                                    // Compare execution modes using the same 
server-prepared statement.
+                                    3.times { assertEquals(directRows, 
readRows()) }
+                                } finally {
+                                    control.execute("SET 
force_forward_all_queries=false")
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+}


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

Reply via email to