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

CalvinKirs 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 6fafa3195ee [fix](fe) Fix prepared statement streaming, forwarding and 
cursor framing (#69314)
6fafa3195ee is described below

commit 6fafa3195ee4e90ddfb4d2b9c8e424e693d60512
Author: Calvin Kirs <[email protected]>
AuthorDate: Tue Oct 20 15:32:30 2026 +0800

    [fix](fe) Fix prepared statement streaming, forwarding and cursor framing 
(#69314)
    
    ### What problem does this PR solve?
    
    
    
    Problem Summary:
    
    - `COM_STMT_SEND_LONG_DATA` incorrectly returns an error packet, leaving
    streaming clients out of sync. Accept parameter chunks without replying,
    defer upload errors until EXECUTE, preserve binary bytes, and clear
    upload state on EXECUTE/RESET/CLOSE.
    - Reusing server-side prepared statements through a non-master FE can
    fail with `NoSuchElementException: No value present`: the driver omits
    unchanged parameter types, while the master prepares each forwarded
    execution independently. Include cached types, the NULL bitmap and
    complete values in forwarded EXECUTE payloads.
    - Forwarded PREPARE can use a stale statement context when planning
    placeholders. Use the parser's current prepare-stage context.
    - Empty results with `useCursorFetch=true` can hang or leave unread
    packets when client connection attributes are disabled or rewritten.
    Return a valid empty cursor, complete FETCH with `LAST_ROW_SENT`, retain
    cursor state on the client-facing FE, and preserve existing framing for
    older FEs. Nonempty results continue to use buffered delivery; nonempty
    cursor pagination is not added.
    - Normalized streamed values can remain attached to idle pooled
    connections after statement close/reset. Release the command-only
    execution buffer after response finalization and forwarding complete.
    
    ### Release note
    
    - Fix streamed parameters, repeated prepared-statement forwarding and
    empty cursor results in the MySQL protocol, and reduce retained
    parameter-buffer memory on idle connections.
    
    ### Check List (For Author)
    
    - Test:
    - [x] Unit Test: 32 focused long-data, protocol adapter and forwarding
    tests passed on the final source, including the new buffer-lifecycle
    test.
    - [x] Regression test: `prepared_stmt_p0/send_long_data` and
    `prepared_stmt_p0/cursor_fetch_empty_result` passed during
    implementation.
    - [x] Manual test: raw-protocol upload/cursor lifecycle and connection
    reuse; rewritten client-attribute proxy reproduction passed after the
    fix.
        - [x] Checkstyle: zero violations in the latest modified Java files.
    - Behavior changed:
    - [x] Yes. Long-data uploads no longer produce responses; forwarded
    parameters are self-contained; empty cursors finish through FETCH;
    completed commands release execution buffers.
    - Does this need documentation?
        - [x] No.
---
 .../doris/mysql/protocol/MysqlProtocolAdapter.java |  47 ++-
 .../doris/mysql/protocol/MysqlResultSender.java    |  15 +-
 .../nereids/trees/expressions/literal/Literal.java |   6 +
 .../java/org/apache/doris/qe/ConnectProcessor.java |   5 +-
 .../org/apache/doris/qe/MysqlConnectProcessor.java | 108 +++++-
 .../apache/doris/qe/PreparedStatementContext.java  |  62 ++++
 .../java/org/apache/doris/qe/StmtExecutor.java     |   4 +-
 .../mysql/protocol/MysqlProtocolAdapterTest.java   |  42 +++
 .../mysql/protocol/MysqlResultSenderTest.java      |  37 ++-
 .../doris/qe/FEOpExecutorMysqlProtocolTest.java    |   2 +
 .../qe/MysqlConnectProcessorCursorFetchTest.java   |  24 ++
 .../qe/MysqlConnectProcessorLongDataTest.java      | 364 +++++++++++++++++++++
 gensrc/thrift/FrontendService.thrift               |   2 +
 .../data/prepared_stmt_p0/send_long_data.out       |  40 +++
 .../suites/prepared_stmt_p0/send_long_data.groovy  |  84 +++++
 15 files changed, 808 insertions(+), 34 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
index a65bb00d95a..ad044cc7d19 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapter.java
@@ -35,6 +35,7 @@ import org.apache.doris.nereids.stats.StatsErrorEstimator;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.ConnectContext.ConnectType;
 import org.apache.doris.qe.ConnectProcessor;
+import org.apache.doris.qe.PreparedStatementContext;
 import org.apache.doris.qe.QueryState.MysqlStateType;
 import org.apache.doris.qe.ShowResultSet;
 import org.apache.doris.qe.StmtExecutor;
@@ -74,6 +75,9 @@ public class MysqlProtocolAdapter implements ProtocolAdapter {
     private ByteBuffer prepareExecuteBuffer;
     // Whether the current COM_STMT_EXECUTE requested a server-side read-only 
cursor.
     private boolean cursorFetchRequested;
+    private boolean resultHasRows;
+    private boolean emptyCursorSupported = true;
+    private PreparedStatementContext executingPreparedStatement;
 
     public MysqlProtocolAdapter(MysqlChannel channel) {
         this.channel = channel;
@@ -146,7 +150,7 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
      */
     @Override
     public void beforeStatement(ConnectContext ctx) {
-        channel.reset();
+        resetResult();
     }
 
     @Override
@@ -183,6 +187,7 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
                 request.setPrepareExecuteBuffer(prepareExecuteBuffer);
             }
             request.setCursorFetchRequested(cursorFetchRequested);
+            request.setEmptyCursorSupported(true);
         }
         request.setClientDeprecatedEOF(channel.clientDeprecatedEOF());
         request.setMysqlCapability(capability.getFlags());
@@ -212,6 +217,7 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
             channel.setClientDeprecatedEOF();
         }
         cursorFetchRequested = request.isSetCursorFetchRequested() && 
request.isCursorFetchRequested();
+        emptyCursorSupported = request.isSetEmptyCursorSupported() && 
request.isEmptyCursorSupported();
     }
 
     /**
@@ -277,6 +283,13 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
             return;
         }
 
+        if (executingPreparedStatement != null) {
+            // A forwarded EXECUTE's final packet was encoded by the master. 
Retain the cursor on
+            // the client-facing FE so its later FETCH can complete locally 
without another RPC.
+            boolean cursorOpen = 
Byte.toUnsignedInt(packet.get(packet.position())) == 0xFE
+                    && (packet.get(packet.position() + 3) & 
MysqlServerStatusFlag.SERVER_STATUS_CURSOR_EXISTS) != 0;
+            executingPreparedStatement.setEmptyCursorOpen(cursorOpen);
+        }
         LOG.debug("Send to mysql channel for query {}", 
DebugUtil.printId(ctx.queryId()));
         channel.sendAndFlush(packet);
         // note(wb) we should write profile after return result to mysql client
@@ -304,6 +317,11 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
         // instead of the traditional EOF packet. This is required by the 
MySQL protocol
         // and expected by MySQL Connector/J 9.5.0+.
         if (ctx.getState().getStateType() == MysqlStateType.EOF && 
channel.clientDeprecatedEOF()) {
+            if (emptyCursorSupported && cursorFetchRequested && 
!resultHasRows) {
+                // Both old and new drivers consume a cursor's opening 
terminator. An empty
+                // cursor needs no retained rows and its first FETCH sends 
LAST_ROW_SENT.
+                ctx.getState().serverStatus |= 
MysqlServerStatusFlag.SERVER_STATUS_CURSOR_EXISTS;
+            }
             packet = new MysqlResultSetEndPacket(ctx.getState());
         } else {
             packet = ctx.getState().toResponsePacket();
@@ -323,7 +341,9 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
 
     @Override
     public void afterStatement(ConnectContext ctx) {
+        prepareExecuteBuffer = null;
         cursorFetchRequested = false;
+        executingPreparedStatement = null;
     }
 
     @Override
@@ -375,18 +395,31 @@ public class MysqlProtocolAdapter implements 
ProtocolAdapter {
         this.cursorFetchRequested = cursorFetchRequested;
     }
 
-    /**
-     * Whether the client will consume the first OK packet after the column 
definitions of the
-     * result it asked a cursor for. Connector/J before 9.5 does, while 
probing whether the cursor
-     * was created; Doris never creates one, so the result must carry one more 
end marker or an
-     * empty result would leave the client waiting.
-     */
+    /** Whether legacy buffered results need an extra terminator for this 
client. */
     public boolean clientConsumesCursorMetadataTerminator(ConnectContext ctx) {
         return cursorFetchRequested
                 && 
MysqlCursorFetchCompatibility.resolve(ctx.getConnectAttributes())
                         != MysqlCursorFetchCompatibility.Behavior.STANDARD;
     }
 
+    public boolean isEmptyCursorSupported() {
+        return emptyCursorSupported;
+    }
+
+    public void beginPreparedExecute(PreparedStatementContext prepared) {
+        executingPreparedStatement = prepared;
+        prepared.setEmptyCursorOpen(false);
+    }
+
+    void recordResultRow() {
+        resultHasRows = true;
+    }
+
+    void resetResult() {
+        resultHasRows = false;
+        channel.reset();
+    }
+
     // The xnio read loop. A command is read and processed while reads are 
suspended, which is what
     // serializes the commands of one connection.
     public void startAcceptQuery(ConnectContext ctx, ConnectProcessor 
connectProcessor) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
index d4327d117a9..66131c5e24a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/protocol/MysqlResultSender.java
@@ -135,13 +135,14 @@ public class MysqlResultSender implements ResultSender {
 
     @Override
     public void sendRow(ByteBuffer row) throws IOException {
+        adapter.recordResultRow();
         channel().sendOnePacket(row);
     }
 
     /** Clears the send flag and whatever a failed attempt of the query left 
in the send buffer. */
     @Override
     public void reset() {
-        channel().reset();
+        adapter.resetResult();
     }
 
     /**
@@ -270,10 +271,8 @@ public class MysqlResultSender implements ResultSender {
             serializer.reset();
             new MysqlEofPacket(ctx.getState()).writeTo(serializer);
             channel.sendOnePacket(serializer.toByteBuffer());
-        } else if (adapter.clientConsumesCursorMetadataTerminator(ctx)) {
-            // 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.
+        } else if (!adapter.isEmptyCursorSupported() && 
adapter.clientConsumesCursorMetadataTerminator(ctx)) {
+            // Preserve framing for older client-facing FEs during rolling 
upgrades.
             serializer.reset();
             new MysqlResultSetEndPacket(ctx.getState()).writeTo(serializer);
             channel.sendOnePacket(serializer.toByteBuffer());
@@ -282,7 +281,6 @@ public class MysqlResultSender implements ResultSender {
 
     private void sendTextResultRow(ResultSet resultSet) throws IOException {
         MysqlSerializer serializer = serializer();
-        MysqlChannel channel = channel();
         for (List<String> row : resultSet.getResultRows()) {
             serializer.reset();
             for (String item : row) {
@@ -292,13 +290,12 @@ public class MysqlResultSender implements ResultSender {
                     serializer.writeLenEncodedString(item);
                 }
             }
-            channel.sendOnePacket(serializer.toByteBuffer());
+            sendRow(serializer.toByteBuffer());
         }
     }
 
     private void sendBinaryResultRow(ResultSet resultSet) throws IOException {
         MysqlSerializer serializer = serializer();
-        MysqlChannel channel = channel();
         // 
https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_binary_resultset.html#sect_protocol_binary_resultset_row_value
         ResultSetMetaData metaData = resultSet.getMetaData();
         int nullBitmapLength = (metaData.getColumnCount() + 7 + 2) / 8;
@@ -361,7 +358,7 @@ public class MysqlResultSender implements ResultSender {
                     }
                 }
             }
-            channel.sendOnePacket(serializer.toByteBuffer());
+            sendRow(serializer.toByteBuffer());
         }
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/Literal.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/Literal.java
index 92de0c55f1f..dfeb493fcaf 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/Literal.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/Literal.java
@@ -591,6 +591,12 @@ public abstract class Literal extends Expression 
implements LeafExpression {
             case MYSQL_TYPE_VARCHAR:
                 literal = handleVarcharLiteral(data);
                 break;
+            case MYSQL_TYPE_TINY_BLOB:
+            case MYSQL_TYPE_MEDIUM_BLOB:
+            case MYSQL_TYPE_LONG_BLOB:
+            case MYSQL_TYPE_BLOB:
+                literal = new 
VarBinaryLiteral(MysqlProto.readLenEncodedString(data));
+                break;
             default:
                 throw new AnalysisException("Unsupported MySQL type: " + 
mysqlType);
         }
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 c1ad7ffd03f..f299bb6a677 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
@@ -156,11 +156,14 @@ public abstract class ConnectProcessor {
     }
 
     protected void handleStmtResetById(int stmtId) {
-        if (ctx.getPreparedStementContext(String.valueOf(stmtId)) == null) {
+        PreparedStatementContext prepared = 
ctx.getPreparedStementContext(String.valueOf(stmtId));
+        if (prepared == null) {
             ctx.getState().setError(ErrorCode.ERR_UNKNOWN_STMT_HANDLER,
                     String.format("Unknown prepared statement handler (%s) 
given to mysqld_stmt_reset", stmtId));
             return;
         }
+        prepared.clearLongData();
+        prepared.setEmptyCursorOpen(false);
         ctx.getState().setOk();
     }
 
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 af8c804259c..1486d9c210e 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
@@ -23,6 +23,7 @@ import org.apache.doris.catalog.DatabaseIf;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.MysqlColType;
 import org.apache.doris.catalog.TableIf;
+import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.AuthenticationException;
 import org.apache.doris.common.ConnectionException;
 import org.apache.doris.common.ErrorCode;
@@ -35,6 +36,7 @@ import org.apache.doris.mysql.MysqlCommand;
 import org.apache.doris.mysql.MysqlHandshakePacket;
 import org.apache.doris.mysql.MysqlProto;
 import org.apache.doris.mysql.MysqlSerializer;
+import org.apache.doris.mysql.MysqlServerStatusFlag;
 import org.apache.doris.mysql.authenticate.password.NativePasswordResolver;
 import org.apache.doris.mysql.privilege.Auth;
 import org.apache.doris.mysql.protocol.MysqlProtocolAdapter;
@@ -93,6 +95,27 @@ public class MysqlConnectProcessor extends ConnectProcessor {
         handleStmtResetById(stmtId);
     }
 
+    private void handleSendLongData() {
+        // This command never has a response, including when the upload fails. 
Report a known
+        // statement's upload error on EXECUTE instead, otherwise the 
connection gets out of sync.
+        ctx.getState().setNoop();
+        packetBuf.order(ByteOrder.LITTLE_ENDIAN);
+        if (packetBuf.remaining() < Integer.BYTES) {
+            return;
+        }
+        int stmtId = packetBuf.getInt();
+        PreparedStatementContext prepared = 
ctx.getPreparedStementContext(String.valueOf(stmtId));
+        if (prepared == null) {
+            return;
+        }
+        if (packetBuf.remaining() < Short.BYTES) {
+            prepared.setLongDataError("Missing parameter index in 
COM_STMT_SEND_LONG_DATA");
+            return;
+        }
+        int parameter = Short.toUnsignedInt(packetBuf.getShort());
+        prepared.appendLongData(parameter, packetBuf, 
ctx.getSessionVariable().getMaxAllowedPacket());
+    }
+
     private String getPacket() {
         byte[] bytes = packetBuf.array();
         StringBuilder printB = new StringBuilder();
@@ -126,11 +149,16 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
     @Override
     protected void handleExecute(PrepareCommand prepareCommand, long stmtId, 
PreparedStatementContext prepCtx,
             ByteBuffer packetBuf, TUniqueId queryId) {
+        MysqlProtocolAdapter.of(ctx).beginPreparedExecute(prepCtx);
         int paramCount = prepareCommand.placeholderCount();
         LOG.debug("execute prepared statement {}, paramCount {}", stmtId, 
paramCount);
         // null bitmap
         String stmtStr = "";
         try {
+            if (prepCtx.getLongDataError() != null) {
+                ctx.getState().setError(ErrorCode.ERR_UNKNOWN_ERROR, 
prepCtx.getLongDataError());
+                return;
+            }
             ctx.getProtocolAdapter().beforeStatement(ctx);
             StatementContext statementContext = prepCtx.getStatementContext();
             if (!ctx.isProxy()) {
@@ -144,7 +172,8 @@ public class MysqlConnectProcessor extends ConnectProcessor 
{
                 byte[] nullbitmapData = new byte[(paramCount + 7) / 8];
                 packetBuf.get(nullbitmapData);
                 // new_params_bind_flag
-                if ((int) packetBuf.get() != 0) {
+                boolean hasNewParameterTypes = packetBuf.get() != 0;
+                if (hasNewParameterTypes) {
                     List<Placeholder> typedPlaceholders = new ArrayList<>();
                     // parse params's types
                     for (int i = 0; i < paramCount; ++i) {
@@ -158,6 +187,22 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
                     prepCtx.command = 
prepareCommand.withPlaceholders(typedPlaceholders);
                     prepareCommand = (PrepareCommand) prepCtx.command;
                 }
+                // The master prepares each forwarded EXECUTE again without 
connection-local state.
+                // Include cached parameter types and inline uploaded values 
so it can bind independently.
+                MysqlSerializer forwarded = prepCtx.hasLongData() || 
!hasNewParameterTypes
+                        ? MysqlSerializer.newInstance() : null;
+                if (forwarded != null) {
+                    for (int i = 0; i < paramCount; ++i) {
+                        if (prepCtx.hasLongData(i)) {
+                            nullbitmapData[i / 8] &= ~(1 << (i & 7));
+                        }
+                    }
+                    forwarded.writeBytes(nullbitmapData);
+                    forwarded.writeInt1(1);
+                    for (Placeholder placeholder : 
prepareCommand.getPlaceholders()) {
+                        forwarded.writeInt2(placeholder.getMysqlTypeCode());
+                    }
+                }
                 // parse param data
                 for (int i = 0; i < paramCount; ++i) {
                     PlaceholderId exprId = 
prepareCommand.getPlaceholders().get(i).getPlaceholderId();
@@ -168,9 +213,38 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
                     }
                     MysqlColType type = 
prepareCommand.getPlaceholders().get(i).getMysqlColType();
                     boolean isUnsigned = 
prepareCommand.getPlaceholders().get(i).isUnsigned();
-                    Literal l = Literal.getLiteralByMysqlType(type, 
isUnsigned, packetBuf);
+                    byte[] longData = prepCtx.getLongData(i);
+                    Literal l;
+                    if (longData != null) {
+                        switch (type) {
+                            case MYSQL_TYPE_STRING:
+                            case MYSQL_TYPE_VARSTRING:
+                            case MYSQL_TYPE_VARCHAR:
+                            case MYSQL_TYPE_TINY_BLOB:
+                            case MYSQL_TYPE_MEDIUM_BLOB:
+                            case MYSQL_TYPE_LONG_BLOB:
+                            case MYSQL_TYPE_BLOB:
+                                break;
+                            default:
+                                throw new AnalysisException("Invalid MySQL 
type for long data: " + type);
+                        }
+                        MysqlSerializer value = MysqlSerializer.newInstance();
+                        value.writeLenEncodedBytes(longData);
+                        l = Literal.getLiteralByMysqlType(type, isUnsigned, 
value.toByteBuffer());
+                        forwarded.writeBytes(value.toArray());
+                    } else {
+                        int start = packetBuf.position();
+                        l = Literal.getLiteralByMysqlType(type, isUnsigned, 
packetBuf);
+                        if (forwarded != null) {
+                            forwarded.writeBytes(packetBuf.array(), 
packetBuf.arrayOffset() + start,
+                                    packetBuf.position() - start);
+                        }
+                    }
                     statementContext.getIdToPlaceholderRealExpr().put(exprId, 
l);
                 }
+                if (forwarded != null && !ctx.isProxy()) {
+                    ctx.setPrepareExecuteBuffer(forwarded.toByteBuffer());
+                }
             }
             ExecuteCommand executeStmt = new 
ExecuteCommand(String.valueOf(stmtId), prepareCommand, statementContext);
             // TODO set real origin statement
@@ -196,8 +270,10 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
             LOG.warn("Process one query failed because unknown reason: ", e);
             ctx.getState().setError(ErrorCode.ERR_UNKNOWN_ERROR,
                     e.getClass().getSimpleName() + ", msg: " + e.getMessage());
+        } finally {
+            prepCtx.clearLongData();
         }
-        if (ctx.getSessionVariable().isEnablePreparedStmtAuditLog()) {
+        if (ctx.getSessionVariable().isEnablePreparedStmtAuditLog() && 
executor != null) {
             auditAfterExec(stmtStr, executor.getParsedStmt(), 
executor.getQueryStatisticsForAuditLog(), true);
         } else {
             // When audit log is disabled for prepared statements, still 
update QPS metrics.
@@ -234,6 +310,23 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
         handleExecute(preparedStatementContext.command, stmtId, 
preparedStatementContext, packetBuf, null);
     }
 
+    protected void handleStmtFetch(int stmtId) {
+        PreparedStatementContext prepared = 
ctx.getPreparedStementContext(String.valueOf(stmtId));
+        if (prepared == null) {
+            ctx.getState().setError(ErrorCode.ERR_UNKNOWN_STMT_HANDLER,
+                    String.format("Unknown prepared statement handler (%s) 
given to mysqld_stmt_fetch", stmtId));
+            return;
+        }
+        if (!prepared.isEmptyCursorOpen()) {
+            ctx.getState().setError(ErrorCode.ERR_STMT_HAS_NO_OPEN_CURSOR,
+                    String.format("The statement (%d) has no open cursor.", 
stmtId));
+            return;
+        }
+        prepared.setEmptyCursorOpen(false);
+        ctx.getState().setEof();
+        ctx.getState().serverStatus |= 
MysqlServerStatusFlag.SERVER_STATUS_LAST_ROW_SENT;
+    }
+
     // Process COM_QUERY statement,
     private void handleQuery() throws ConnectionException {
         // convert statement to Java string
@@ -278,6 +371,15 @@ public class MysqlConnectProcessor extends 
ConnectProcessor {
             case COM_STMT_EXECUTE:
                 handleExecute();
                 break;
+            case COM_STMT_FETCH:
+                packetBuf.order(ByteOrder.LITTLE_ENDIAN);
+                int fetchStmtId = packetBuf.getInt();
+                packetBuf.getInt(); // Requested row count; an empty cursor 
has no rows to return.
+                handleStmtFetch(fetchStmtId);
+                break;
+            case COM_STMT_SEND_LONG_DATA:
+                handleSendLongData();
+                break;
             case COM_FIELD_LIST:
                 handleFieldList();
                 break;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/qe/PreparedStatementContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/PreparedStatementContext.java
index f28f6bd1c53..e16dd5fb7ab 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/PreparedStatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/PreparedStatementContext.java
@@ -21,6 +21,10 @@ import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.trees.plans.commands.PrepareCommand;
 import org.apache.doris.planner.GroupCommitPlanner;
 
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Optional;
 
 public class PreparedStatementContext {
@@ -30,6 +34,10 @@ public class PreparedStatementContext {
     public String stmtString;
     public Optional<ShortCircuitQueryContext> shortCircuitQueryContext = 
Optional.empty();
     public Optional<GroupCommitPlanner> groupCommitPlanner = Optional.empty();
+    // Long data belongs to a prepared statement and survives individual 
upload commands.
+    private final Map<Integer, ByteArrayOutputStream> longData = new 
HashMap<>();
+    private String longDataError;
+    private boolean emptyCursorOpen;
 
     // Timestamp in millisecond last command starts at
     protected volatile long startTime;
@@ -42,6 +50,14 @@ public class PreparedStatementContext {
         this.stmtString = stmtString;
     }
 
+    public boolean isEmptyCursorOpen() {
+        return emptyCursorOpen;
+    }
+
+    public void setEmptyCursorOpen(boolean emptyCursorOpen) {
+        this.emptyCursorOpen = emptyCursorOpen;
+    }
+
     public long getStartTime() {
         return startTime;
     }
@@ -81,4 +97,50 @@ public class PreparedStatementContext {
     public void setStartTime() {
         startTime = System.currentTimeMillis();
     }
+
+    public void appendLongData(int parameter, ByteBuffer data, int 
maxAllowedPacket) {
+        if (longDataError != null) {
+            return;
+        }
+        if (parameter >= command.placeholderCount()) {
+            setLongDataError("Invalid parameter index " + parameter + " in 
COM_STMT_SEND_LONG_DATA");
+            return;
+        }
+        ByteArrayOutputStream value = longData.computeIfAbsent(parameter, key 
-> new ByteArrayOutputStream());
+        if ((long) value.size() + data.remaining() > maxAllowedPacket) {
+            setLongDataError("Parameter data in COM_STMT_SEND_LONG_DATA 
exceeds max_allowed_packet ("
+                    + maxAllowedPacket + ")");
+            return;
+        }
+        byte[] chunk = new byte[data.remaining()];
+        data.get(chunk);
+        value.write(chunk, 0, chunk.length);
+    }
+
+    public boolean hasLongData() {
+        return !longData.isEmpty();
+    }
+
+    public boolean hasLongData(int parameter) {
+        return longData.containsKey(parameter);
+    }
+
+    public byte[] getLongData(int parameter) {
+        ByteArrayOutputStream value = longData.get(parameter);
+        return value == null ? null : value.toByteArray();
+    }
+
+    public String getLongDataError() {
+        return longDataError;
+    }
+
+    public void setLongDataError(String error) {
+        longData.clear();
+        longDataError = error;
+    }
+
+    public void clearLongData() {
+        longData.clear();
+        longDataError = null;
+    }
 }
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 c2a0f689b4b..2506e065906 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
@@ -2170,7 +2170,9 @@ public class StmtExecutor {
         Preconditions.checkState(parsedStmt instanceof LogicalPlanAdapter,
                 "Nereids only process LogicalPlanAdapter,"
                         + " but parsedStmt is " + 
parsedStmt.getClass().getName());
-        NereidsPlanner nereidsPlanner = new 
PrepareCommandPlanner(statementContext);
+        // Proxy parsing creates a new context. Use the context marked as the 
prepare stage
+        // by PrepareCommand, rather than the one allocated before parsing in 
the constructor.
+        NereidsPlanner nereidsPlanner = new 
PrepareCommandPlanner(context.getStatementContext());
         nereidsPlanner.plan(parsedStmt, 
context.getSessionVariable().toThrift());
         return 
nereidsPlanner.getCascadesContext().getRewritePlan().getOutput();
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
index 22886f63941..69fdc66213f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlProtocolAdapterTest.java
@@ -28,10 +28,13 @@ import org.apache.doris.mysql.MysqlCommand;
 import org.apache.doris.mysql.MysqlProto;
 import org.apache.doris.mysql.MysqlServerStatusFlag;
 import org.apache.doris.mysql.ProxyMysqlChannel;
+import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.ConnectContext.ConnectType;
 import org.apache.doris.qe.ConnectProcessor;
 import org.apache.doris.qe.ConnectScheduler;
+import org.apache.doris.qe.PreparedStatementContext;
+import org.apache.doris.qe.StmtExecutor;
 import org.apache.doris.qe.protocol.RecordingMysqlChannel;
 import org.apache.doris.thrift.TMasterOpRequest;
 import org.apache.doris.thrift.TResultSinkType;
@@ -137,6 +140,8 @@ public class MysqlProtocolAdapterTest {
         MysqlProtocolAdapter protocol = MysqlProtocolAdapter.of(ctx);
         ctx.setConnectAttributes(ImmutableMap.of("_client_name", "MySQL 
Connector/J", "_client_version", "8.2.0"));
 
+        // An older FE cannot handle FETCH. Preserve its legacy framing during 
an upgrade.
+        protocol.restoreFromForwardRequest(ctx, new TMasterOpRequest());
         // Only a COM_STMT_EXECUTE that asked for a cursor ...
         
Assertions.assertFalse(protocol.clientConsumesCursorMetadataTerminator(ctx));
         ctx.setCursorFetchRequested(true);
@@ -151,6 +156,42 @@ public class MysqlProtocolAdapterTest {
         Assertions.assertFalse(ctx.isCursorFetchRequested());
     }
 
+    @Test
+    public void testEmptyCursorOpeningAndForwardedCursorState() throws 
Exception {
+        RecordingMysqlChannel channel = new RecordingMysqlChannel();
+        channel.setClientDeprecatedEOF();
+        ConnectContext ctx = new ConnectContext(new 
MysqlProtocolAdapter(channel));
+        MysqlProtocolAdapter protocol = MysqlProtocolAdapter.of(ctx);
+        ctx.setCursorFetchRequested(true);
+        ctx.getState().setEof();
+        PreparedStatementContext prepared = new PreparedStatementContext(
+                null, ctx, new StatementContext(), "7");
+        protocol.beginPreparedExecute(prepared);
+        protocol.finishCommand(ctx, null);
+        Assertions.assertTrue(prepared.isEmptyCursorOpen());
+        
Assertions.assertEquals(MysqlServerStatusFlag.SERVER_STATUS_CURSOR_EXISTS,
+                ctx.getState().serverStatus & 
MysqlServerStatusFlag.SERVER_STATUS_CURSOR_EXISTS);
+
+        // A row, including a binary NULL row, means this is an ordinary 
result, not an empty cursor.
+        protocol.beforeStatement(ctx);
+        ctx.getState().reset();
+        ctx.getState().setEof();
+        protocol.beginPreparedExecute(prepared);
+        ctx.getResultSender().sendRow(ByteBuffer.wrap(new byte[] {0, 4}));
+        protocol.finishCommand(ctx, null);
+        Assertions.assertFalse(prepared.isEmptyCursorOpen());
+
+        // The master sends CURSOR_EXISTS in its final packet; retain it on 
the client-facing FE.
+        StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+        Mockito.when(executor.hasForwardedToMaster()).thenReturn(true);
+        Mockito.when(executor.getOutputPacket()).thenReturn(ByteBuffer.wrap(
+                new byte[] {(byte) 0xFE, 0, 0, 0x40, 0, 0, 0, 0}));
+        protocol.finishCommand(ctx, executor);
+        Assertions.assertTrue(prepared.isEmptyCursorOpen());
+        protocol.beginPreparedExecute(prepared);
+        Assertions.assertFalse(prepared.isEmptyCursorOpen());
+    }
+
     @Test
     public void testClientAddressComesFromTheChannel() {
         MysqlChannel channel = Mockito.mock(MysqlChannel.class);
@@ -329,6 +370,7 @@ public class MysqlProtocolAdapterTest {
         Assertions.assertEquals(flags, request.getMysqlCapability());
         Assertions.assertArrayEquals(new byte[] {7, 0, 0, 0}, 
request.getPrepareExecuteBuffer());
         Assertions.assertTrue(request.isCursorFetchRequested());
+        Assertions.assertTrue(request.isEmptyCursorSupported());
 
         // The master's proxy context takes them over.
         ConnectContext proxy = ConnectContext.forMysqlProxy("session-1");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
index 2a91ae1feb8..c9a333c5c7f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mysql/protocol/MysqlResultSenderTest.java
@@ -33,6 +33,7 @@ import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.ResultSet;
 import org.apache.doris.qe.protocol.RecordingMysqlChannel;
 import org.apache.doris.qe.protocol.RecordingMysqlChannel.RecordedPacket;
+import org.apache.doris.thrift.TMasterOpRequest;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
@@ -154,25 +155,35 @@ public class MysqlResultSenderTest {
     }
 
     @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());
+    public void testCursorFetchMetadataHasNoExtraTerminator() throws 
IOException {
+        for (String version : new String[] {"8.0.27", "8.4.0", "9.4.0", 
"9.7.0"}) {
+            Assertions.assertEquals(2, sendEmptyResultSet(true, "MySQL 
Connector/J", version).size());
+        }
+        Assertions.assertEquals(2, sendEmptyResultSet(true, 
Collections.emptyMap(), true).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(), true).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 testOlderFeKeepsLegacyCursorMetadataFraming() throws 
IOException {
+        RecordingMysqlChannel channel = newChannel(true);
+        ConnectContext ctx = newContext(channel);
+        TMasterOpRequest request = new TMasterOpRequest();
+        request.setClientDeprecatedEOF(true);
+        request.setCursorFetchRequested(true);
+        MysqlProtocolAdapter.of(ctx).restoreFromForwardRequest(ctx, request);
+        ctx.setConnectAttributes(ImmutableMap.of(
+                "_client_name", "MySQL Connector/J", "_client_version", 
"8.0.27"));
+        ctx.getResultSender().sendResultSet(new CommonResultSet(
+                new CommonResultSetMetaData(Collections.singletonList(new 
Column("c", PrimitiveType.INT))),
+                Collections.emptyList()), null, true);
+        List<byte[]> packets = payloads(channel);
+        Assertions.assertEquals(3, packets.size());
+        Assertions.assertEquals(0xFE, Byte.toUnsignedInt(packets.get(2)[0]));
+    }
+
     @Test
     public void testPrepareMetadataTerminatorsFollowNegotiatedCapability() 
throws IOException {
         Assertions.assertEquals(3, sendPrepareMetadata(false).size());
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
index f3a019badb3..fc2d0a26424 100644
--- 
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
@@ -61,6 +61,7 @@ public class FEOpExecutorMysqlProtocolTest {
 
             Assertions.assertTrue(request.isClientDeprecatedEOF());
             Assertions.assertTrue(request.isCursorFetchRequested());
+            Assertions.assertTrue(request.isEmptyCursorSupported());
             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})) {
@@ -72,6 +73,7 @@ public class FEOpExecutorMysqlProtocolTest {
                 Assertions.assertTrue(restored.isSetPrepareExecuteBuffer());
                 Assertions.assertArrayEquals(payload, 
restored.getPrepareExecuteBuffer());
                 Assertions.assertTrue(restored.isCursorFetchRequested());
+                Assertions.assertTrue(restored.isEmptyCursorSupported());
             }
         }
     }
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
index 8f788d75ad4..1694ccb3185 100644
--- 
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
@@ -82,6 +82,30 @@ public class MysqlConnectProcessorCursorFetchTest {
                 "Unknown prepared statement handler"));
     }
 
+    @Test
+    public void testEmptyCursorFetchResetAndClose() throws Exception {
+        ConnectContext context = new ConnectContext();
+        PreparedStatementContext prepared = new PreparedStatementContext(
+                Mockito.mock(PrepareCommand.class), context, new 
StatementContext(), "7");
+        context.addPreparedStatementContext("7", prepared);
+        MysqlConnectProcessor processor = new MysqlConnectProcessor(context);
+        prepared.setEmptyCursorOpen(true);
+        processor.handleStmtFetch(7);
+        Assertions.assertEquals(QueryState.MysqlStateType.EOF, 
context.getState().getStateType());
+        Assertions.assertFalse(prepared.isEmptyCursorOpen());
+        Assertions.assertEquals(0x80, context.getState().serverStatus & 0xC0);
+
+        context.getState().reset();
+        processor.handleStmtFetch(7);
+        Assertions.assertEquals(1421, 
context.getState().getErrorCode().getCode());
+        prepared.setEmptyCursorOpen(true);
+        processor.handleStmtResetById(7);
+        Assertions.assertFalse(prepared.isEmptyCursorOpen());
+        context.removePrepareStmt("7");
+        processor.handleStmtFetch(7);
+        Assertions.assertEquals(1243, 
context.getState().getErrorCode().getCode());
+    }
+
     private ConnectContext execute(boolean cursorRequested, boolean 
clientDeprecatedEof,
             boolean identifiedClient) throws Exception {
         ConnectContext context = new ConnectContext();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlConnectProcessorLongDataTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlConnectProcessorLongDataTest.java
new file mode 100644
index 00000000000..d649f469a7d
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlConnectProcessorLongDataTest.java
@@ -0,0 +1,364 @@
+// 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.MysqlColType;
+import org.apache.doris.mysql.MysqlChannel;
+import org.apache.doris.mysql.MysqlSerializer;
+import org.apache.doris.mysql.protocol.MysqlProtocolAdapter;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.glue.LogicalPlanAdapter;
+import org.apache.doris.nereids.trees.expressions.Placeholder;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.VarBinaryLiteral;
+import org.apache.doris.nereids.trees.plans.PlaceholderId;
+import org.apache.doris.nereids.trees.plans.PrepareCommandPlanner;
+import org.apache.doris.nereids.trees.plans.commands.PrepareCommand;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+
+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.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.stream.IntStream;
+
+public class MysqlConnectProcessorLongDataTest {
+    @Test
+    public void testUploadHasNoResponseAndPingStillWorks() throws Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_BLOB);
+        h.upload(7, 0, new byte[] {0, (byte) 0xff});
+        h.upload(7, 0, new byte[] {1});
+        h.upload(123, 0, new byte[] {2}); // Unknown statements must not 
produce a response either.
+        Assertions.assertArrayEquals(new byte[] {0, (byte) 0xff, 1}, 
h.prepared.getLongData(0));
+        Mockito.verify(h.channel, Mockito.never()).sendAndFlush(Mockito.any());
+        h.command(ByteBuffer.wrap(new byte[] {(byte) 0x0e}));
+        Mockito.verify(h.channel).sendAndFlush(Mockito.any());
+    }
+
+    @Test
+    public void testExecuteReleasesNormalizedUploadAfterResponse() throws 
Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_BLOB);
+        h.upload(7, 0, new byte[] {0, (byte) 0xff, (byte) 0x80, 65});
+        try (MockedConstruction<StmtExecutor> executors = 
Mockito.mockConstruction(StmtExecutor.class,
+                (executor, construction) -> Mockito.doAnswer(invocation -> {
+                    // The complete upload must remain available while 
execution can forward it.
+                    Assertions.assertArrayEquals(new byte[] {0, 1, (byte) 252, 
0, 4, 0, (byte) 0xff, (byte) 0x80, 65},
+                            h.context.getPrepareExecuteBuffer().array());
+                    return null;
+                }).when(executor).execute());
+                MockedStatic<AuditLogHelper> audit = 
Mockito.mockStatic(AuditLogHelper.class)) {
+            h.command(ByteBuffer.wrap(new byte[] {0x17, 7, 0, 0, 0, 0, 1, 0, 
0, 0, 0, 1, (byte) 252, 0}));
+            Assertions.assertEquals(QueryState.MysqlStateType.OK, 
h.context.getState().getStateType(),
+                    h.context.getState().getErrorMessage());
+            Mockito.verify(executors.constructed().get(0)).execute();
+            Mockito.verify(h.channel).sendAndFlush(Mockito.any());
+            Assertions.assertFalse(h.prepared.hasLongData());
+            Assertions.assertNull(h.context.getPrepareExecuteBuffer());
+        }
+    }
+
+    @Test
+    public void testMixedParametersAndForwardingWithCachedTypes() throws 
Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_BLOB, 
MysqlColType.MYSQL_TYPE_LONG,
+                MysqlColType.MYSQL_TYPE_VARSTRING, 
MysqlColType.MYSQL_TYPE_VARSTRING);
+        h.upload(7, 0, new byte[] {0, (byte) 0xff});
+        h.upload(7, 0, new byte[] {(byte) 0x80});
+        // Split one UTF-8 character across upload packets.
+        byte[] text = "中文".getBytes(StandardCharsets.UTF_8);
+        h.upload(7, 2, new byte[] {text[0]});
+        h.upload(7, 2, java.util.Arrays.copyOfRange(text, 1, text.length));
+        // The fourth parameter is NULL. Uploaded parameters have no inline 
values.
+        h.execute(new byte[] {8, 0, 42, 0, 0, 0});
+        Assertions.assertEquals(new VarBinaryLiteral(new byte[] {0, (byte) 
0xff, (byte) 0x80}), h.bound(0));
+        Assertions.assertEquals(new IntegerLiteral(42), h.bound(1));
+        Assertions.assertEquals(new StringLiteral("中文"), h.bound(2));
+        Assertions.assertInstanceOf(NullLiteral.class, h.bound(3));
+        Assertions.assertFalse(h.prepared.hasLongData());
+
+        assertForwardedParameters(h);
+    }
+
+    @Test
+    public void testForwardInlineParametersWithCachedTypes() throws Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_VARSTRING, 
MysqlColType.MYSQL_TYPE_LONG,
+                MysqlColType.MYSQL_TYPE_VARSTRING);
+        MysqlSerializer initial = MysqlSerializer.newInstance();
+        initial.writeInt1(4); // Third parameter is NULL, but still has a 
cached type.
+        initial.writeInt1(1);
+        initial.writeInt2(MysqlColType.MYSQL_TYPE_VARSTRING.getCode());
+        initial.writeInt2(MysqlColType.MYSQL_TYPE_LONG.getCode() | 0x8000);
+        initial.writeInt2(MysqlColType.MYSQL_TYPE_VARSTRING.getCode());
+        initial.writeLenEncodedString("first");
+        initial.writeInt4(-1);
+        h.execute(initial.toArray());
+        assertForwardedParameters(h);
+
+        MysqlSerializer cached = MysqlSerializer.newInstance();
+        cached.writeInt1(1); // First parameter becomes NULL; third becomes 
non-NULL.
+        cached.writeInt1(0);
+        cached.writeInt4(-2);
+        cached.writeLenEncodedString("second");
+        h.execute(cached.toArray());
+        Assertions.assertInstanceOf(NullLiteral.class, h.bound(0));
+        Assertions.assertEquals(new StringLiteral("second"), h.bound(2));
+        assertForwardedParameters(h);
+    }
+
+    @Test
+    public void testForwardUpdatedTypesAndAllNullParameters() throws Exception 
{
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_LONG);
+        h.execute(new byte[] {0, 1, 3, 0, 42, 0, 0, 0});
+        assertForwardedParameters(h);
+        h.execute(new byte[] {0, 0, 43, 0, 0, 0});
+        assertForwardedParameters(h);
+        h.execute(new byte[] {0, 1, (byte) 253, 0, 1, 65});
+        assertForwardedParameters(h);
+        h.execute(new byte[] {1, 0});
+        Assertions.assertInstanceOf(NullLiteral.class, h.bound(0));
+        assertForwardedParameters(h);
+        h.execute(new byte[] {0, 0, 1, 66});
+        Assertions.assertEquals(new StringLiteral("B"), h.bound(0));
+        assertForwardedParameters(h);
+    }
+
+    private static void assertForwardedParameters(Harness follower) throws 
Exception {
+        ByteBuffer forwarded = 
follower.context.getPrepareExecuteBuffer().duplicate();
+        byte[] payload = new byte[forwarded.remaining()];
+        forwarded.get(payload);
+        // A proxy EXECUTE starts with a fresh PREPARE, without the client's 
cached parameter types.
+        Harness master = new 
Harness(follower.prepared.command.placeholderCount());
+        for (Placeholder placeholder : 
master.prepared.command.getPlaceholders()) {
+            Assertions.assertFalse(placeholder.hasMysqlColType());
+        }
+        master.execute(payload);
+        for (int i = 0; i < follower.prepared.command.placeholderCount(); ++i) 
{
+            Assertions.assertEquals(follower.bound(i), master.bound(i));
+            
Assertions.assertEquals(follower.prepared.command.getPlaceholders().get(i).getMysqlTypeCode(),
+                    
master.prepared.command.getPlaceholders().get(i).getMysqlTypeCode());
+        }
+    }
+
+    @Test
+    public void testEmptyUploadAndStatementIsolation() throws Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_VARSTRING);
+        PreparedStatementContext other = new 
PreparedStatementContext(h.prepared.command, h.context,
+                new StatementContext(), "select ?");
+        h.context.addPreparedStatementContext("8", other);
+        h.upload(8, 0, new byte[] {65});
+        h.upload(7, 0, new byte[0]);
+        Assertions.assertTrue(h.prepared.hasLongData(0));
+        h.execute(new byte[] {0, 0});
+        Assertions.assertEquals(new StringLiteral(""), h.bound(0));
+        Assertions.assertArrayEquals(new byte[] {65}, other.getLongData(0));
+        // The next execution must consume its inline value instead of reusing 
uploaded data.
+        h.execute(new byte[] {0, 0, 1, 66});
+        Assertions.assertEquals(new StringLiteral("B"), h.bound(0));
+    }
+
+    @Test
+    public void testUploadErrorsAreDeferredAndResetClearsThem() throws 
Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_VARSTRING);
+        h.context.getSessionVariable().maxAllowedPacket = 3;
+        h.upload(7, 0, new byte[] {65, 66});
+        h.upload(7, 0, new byte[] {67, 68});
+        Assertions.assertNotNull(h.prepared.getLongDataError());
+        Assertions.assertFalse(h.prepared.hasLongData());
+        Mockito.verify(h.channel, Mockito.never()).sendAndFlush(Mockito.any());
+        h.executeError(new byte[] {0, 0}, "max_allowed_packet");
+        Assertions.assertNull(h.prepared.getLongDataError());
+        h.upload(7, 65535, new byte[0]);
+        Assertions.assertNotNull(h.prepared.getLongDataError());
+        h.processor.handleStmtResetById(7);
+        Assertions.assertNull(h.prepared.getLongDataError());
+        h.upload(7, 0, new byte[] {65});
+        h.processor.handleStmtResetById(7);
+        Assertions.assertFalse(h.prepared.hasLongData());
+        h.execute(new byte[] {0, 0, 1, 66});
+        Assertions.assertEquals(new StringLiteral("B"), h.bound(0));
+    }
+
+    @Test
+    public void testBindingFailureClearsUploadWithAuditEnabled() throws 
Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_LONG);
+        h.context.getSessionVariable().enablePreparedStmtAuditLog = true;
+        h.upload(7, 0, new byte[] {65});
+        h.executeError(new byte[] {0, 0}, "Invalid MySQL type for long data");
+        Assertions.assertFalse(h.prepared.hasLongData());
+    }
+
+    @Test
+    public void testMalformedUploadAndCloseHaveNoResponse() throws Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_VARSTRING);
+        h.command(ByteBuffer.wrap(new byte[] {0x18}));
+        h.command(ByteBuffer.wrap(new byte[] {0x18, 7, 0, 0, 0}));
+        Assertions.assertNotNull(h.prepared.getLongDataError());
+        Mockito.verify(h.channel, Mockito.never()).sendAndFlush(Mockito.any());
+        h.processor.handleStmtResetById(7);
+        h.upload(7, 0, new byte[] {65});
+        h.context.setThreadLocalInfo();
+        try {
+            h.command(ByteBuffer.wrap(new byte[] {0x19, 7, 0, 0, 0}));
+            Assertions.assertNull(h.context.getPreparedStementContext("7"));
+            Mockito.verify(h.channel, 
Mockito.never()).sendAndFlush(Mockito.any());
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    @Test
+    public void testUploadOverridesNullBitmap() throws Exception {
+        Harness h = new Harness(MysqlColType.MYSQL_TYPE_VARSTRING);
+        h.upload(7, 0, new byte[] {65});
+        h.execute(new byte[] {1, 0});
+        Assertions.assertEquals(new StringLiteral("A"), h.bound(0));
+        Assertions.assertEquals(0, h.context.getPrepareExecuteBuffer().get(0));
+    }
+
+    @Test
+    public void testBlobTypesPreserveBytes() throws Exception {
+        for (MysqlColType type : new MysqlColType[] 
{MysqlColType.MYSQL_TYPE_TINY_BLOB,
+                MysqlColType.MYSQL_TYPE_MEDIUM_BLOB, 
MysqlColType.MYSQL_TYPE_LONG_BLOB,
+                MysqlColType.MYSQL_TYPE_BLOB}) {
+            MysqlSerializer value = MysqlSerializer.newInstance();
+            byte[] bytes = new byte[] {0, (byte) 0x80, (byte) 0xff, 65};
+            value.writeLenEncodedBytes(bytes);
+            Assertions.assertEquals(new VarBinaryLiteral(bytes),
+                    Literal.getLiteralByMysqlType(type, false, 
value.toByteBuffer()));
+        }
+    }
+
+    @Test
+    public void testTruncatedBlobFailsBinding() {
+        Assertions.assertThrows(IllegalArgumentException.class,
+                () -> 
Literal.getLiteralByMysqlType(MysqlColType.MYSQL_TYPE_BLOB, false,
+                        ByteBuffer.wrap(new byte[] {5, 65})));
+    }
+
+    @Test
+    public void testForwardedPrepareUsesParserContext() throws Exception {
+        ConnectContext context = new ConnectContext(null, true);
+        OriginStatement sql = new OriginStatement("SELECT HEX(?)", 0);
+        StmtExecutor executor = new StmtExecutor(context, sql, true);
+        StatementContext constructorContext = context.getStatementContext();
+        StatementContext parsedContext = new StatementContext(context, sql);
+        parsedContext.setPrepareStage(true);
+        LogicalPlan plan = Mockito.mock(LogicalPlan.class);
+        executor.setParsedStmt(new LogicalPlanAdapter(plan, parsedContext));
+        context.setStatementContext(parsedContext);
+        CascadesContext cascades = Mockito.mock(CascadesContext.class);
+        Mockito.when(cascades.getRewritePlan()).thenReturn(plan);
+        Mockito.when(plan.getOutput()).thenReturn(List.of());
+        try (MockedConstruction<PrepareCommandPlanner> planners = 
Mockito.mockConstruction(
+                PrepareCommandPlanner.class, (planner, construction) -> {
+                    Assertions.assertSame(parsedContext, 
construction.arguments().get(0));
+                    
Mockito.when(planner.getCascadesContext()).thenReturn(cascades);
+                })) {
+            Assertions.assertFalse(constructorContext.isPrepareStage());
+            
Assertions.assertTrue(executor.planPrepareStatementSlots().isEmpty());
+            Assertions.assertEquals(1, planners.constructed().size());
+        }
+    }
+
+    private static class Harness {
+        private final ConnectContext context;
+        private final MysqlChannel channel = Mockito.mock(MysqlChannel.class);
+        private final MysqlConnectProcessor processor;
+        private final PreparedStatementContext prepared;
+
+        Harness(MysqlColType... types) throws Exception {
+            this(new ConnectContext(), IntStream.range(0, types.length)
+                    .mapToObj(i -> new Placeholder(new PlaceholderId(i), 
types[i].getCode())).toList());
+        }
+
+        Harness(int parameterCount) throws Exception {
+            this(new ConnectContext(null, true), IntStream.range(0, 
parameterCount)
+                    .mapToObj(i -> new Placeholder(new 
PlaceholderId(i))).toList());
+        }
+
+        private Harness(ConnectContext context, List<Placeholder> 
placeholders) throws Exception {
+            this.context = context;
+            Field adapter = 
ConnectContext.class.getDeclaredField("protocolAdapter");
+            adapter.setAccessible(true);
+            adapter.set(context, new MysqlProtocolAdapter(channel));
+            
Mockito.when(channel.getSerializer()).thenReturn(MysqlSerializer.newInstance());
+            context.getSessionVariable().enableServeSidePreparedStatement = 
true;
+            PrepareCommand command = new PrepareCommand("7", null, 
placeholders,
+                    new OriginStatement("select ?", 0));
+            prepared = new PreparedStatementContext(command, context, new 
StatementContext(), "select ?");
+            context.addPreparedStatementContext("7", prepared);
+            processor = new MysqlConnectProcessor(context) {
+                @Override
+                protected void resolveWorkloadGroupName() {
+                }
+            };
+        }
+
+        void upload(int statement, int parameter, byte[] bytes) throws 
Exception {
+            ByteBuffer packet = ByteBuffer.allocate(7 + 
bytes.length).order(ByteOrder.LITTLE_ENDIAN);
+            packet.put((byte) 0x18);
+            packet.putInt(statement).putShort((short) 
parameter).put(bytes).flip();
+            command(packet);
+            Assertions.assertEquals(QueryState.MysqlStateType.NOOP, 
context.getState().getStateType());
+        }
+
+        void command(ByteBuffer packet) throws Exception {
+            Mockito.when(channel.fetchOnePacket()).thenReturn(packet);
+            processor.processOnce();
+        }
+
+        void execute(byte[] payload) throws Exception {
+            try (MockedConstruction<StmtExecutor> executors = 
Mockito.mockConstruction(StmtExecutor.class);
+                    MockedStatic<AuditLogHelper> audit = 
Mockito.mockStatic(AuditLogHelper.class)) {
+                context.getState().reset();
+                processor.handleExecute(prepared.command, 7, prepared,
+                        
ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN), null);
+                Assertions.assertEquals(QueryState.MysqlStateType.OK, 
context.getState().getStateType(),
+                        context.getState().getErrorMessage());
+                Assertions.assertEquals(1, executors.constructed().size());
+                Mockito.verify(executors.constructed().get(0)).execute();
+            }
+        }
+
+        void executeError(byte[] payload, String message) throws Exception {
+            try (MockedStatic<AuditLogHelper> audit = 
Mockito.mockStatic(AuditLogHelper.class)) {
+                context.getState().reset();
+                processor.handleExecute(prepared.command, 7, prepared,
+                        
ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN), null);
+                Assertions.assertEquals(QueryState.MysqlStateType.ERR, 
context.getState().getStateType());
+                
Assertions.assertTrue(context.getState().getErrorMessage().contains(message),
+                        context.getState().getErrorMessage());
+            }
+        }
+
+        Literal bound(int parameter) {
+            return (Literal) 
prepared.getStatementContext().getIdToPlaceholderRealExpr()
+                    
.get(prepared.command.getPlaceholders().get(parameter).getPlaceholderId());
+        }
+    }
+}
diff --git a/gensrc/thrift/FrontendService.thrift 
b/gensrc/thrift/FrontendService.thrift
index e22c1be7b87..5ade99dd1e4 100644
--- a/gensrc/thrift/FrontendService.thrift
+++ b/gensrc/thrift/FrontendService.thrift
@@ -448,6 +448,8 @@ struct TMasterOpRequest {
     1008: optional bool cursor_fetch_requested
     // Capabilities negotiated with the original MySQL client.
     1009: optional i32 mysql_capability
+    // Whether the client-facing FE can complete an empty cursor with 
COM_STMT_FETCH.
+    1010: optional bool empty_cursor_supported
 }
 
 struct TColumnDefinition {
diff --git a/regression-test/data/prepared_stmt_p0/send_long_data.out 
b/regression-test/data/prepared_stmt_p0/send_long_data.out
new file mode 100644
index 00000000000..f9ec5674319
--- /dev/null
+++ b/regression-test/data/prepared_stmt_p0/send_long_data.out
@@ -0,0 +1,40 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !blob --
+a20b04215bb28f51f6871d6a94bebbc4       42      hex:00FF8041
+
+-- !after_blob --
+1
+
+-- !blob --
+079ef7341bce92c923c56623b9fc0026       42      hex:00FF8041
+
+-- !after_blob --
+1
+
+-- !blob --
+231b93478cc02c2419591997093ec8d0       42      hex:00FF8041
+
+-- !after_blob --
+1
+
+-- !character_stream --
+3ab2dbae7df7541cfbd3172de800ac0f       43      hex:
+
+-- !after_character_stream --
+2
+
+-- !inline_after_stream --
+03fdad155b7548884584c7c39b0c5cd2       44      hex:76616C7565
+
+-- !after_close --
+3
+
+-- !forwarded_blob --
+00FF8041       45
+
+-- !forwarded_blob --
+00FF8041       45
+
+-- !after_forwarding --
+4
+
diff --git a/regression-test/suites/prepared_stmt_p0/send_long_data.groovy 
b/regression-test/suites/prepared_stmt_p0/send_long_data.groovy
new file mode 100644
index 00000000000..6b28a144f55
--- /dev/null
+++ b/regression-test/suites/prepared_stmt_p0/send_long_data.groovy
@@ -0,0 +1,84 @@
+// 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 java.io.ByteArrayInputStream
+import java.io.StringReader
+import java.nio.charset.StandardCharsets
+
+suite("send_long_data") {
+    String url = getServerPrepareJdbcUrl(context.config.jdbcUrl, 
"regression_test_prepared_stmt_p0") +
+            
"&emulateUnsupportedPstmts=false&blobSendChunkSize=16384&socketTimeout=10000"
+    connect(context.config.jdbcUser, context.config.jdbcPassword, url) {
+        def prepared = prepareStatement "SELECT MD5(?), ?, CONCAT('hex:', 
HEX(?))"
+        assertEquals(com.mysql.cj.jdbc.ServerPreparedStatement, prepared.class)
+        try {
+            // Reuse the statement with both fresh and cached parameter type 
metadata.
+            ["first", "second", ""].each { text ->
+                prepared.setBlob(1, new ByteArrayInputStream(
+                        (("a\u0000中文-" + text) * 
2048).getBytes(StandardCharsets.UTF_8)))
+                prepared.setInt(2, 42)
+                prepared.setBinaryStream(3, new ByteArrayInputStream(new 
byte[] {0, -1, -128, 65}))
+                qe_blob prepared
+                qt_after_blob "SELECT 1"
+            }
+            prepared.clearParameters()
+            prepared.setCharacterStream(1, new StringReader("中文-long-data"))
+            prepared.setInt(2, 43)
+            prepared.setBlob(3, new ByteArrayInputStream(new byte[0]))
+            qe_character_stream prepared
+            qt_after_character_stream "SELECT 2"
+
+            prepared.setString(1, "inline")
+            prepared.setInt(2, 44)
+            prepared.setString(3, "value")
+            qe_inline_after_stream prepared
+        } finally {
+            prepared.close()
+        }
+        qt_after_close "SELECT 3"
+    }
+
+    def follower = sql_return_maparray("SHOW FRONTENDS").find {
+        it.IsMaster == "false" && it.Alive == "true"
+    }
+    String forwardingUrl = url
+    if (follower != null) {
+        forwardingUrl = getServerPrepareJdbcUrl(
+                "jdbc:mysql://${follower.Host}:${follower.QueryPort}/", 
"regression_test_prepared_stmt_p0", false) +
+                "&emulateUnsupportedPstmts=false&socketTimeout=10000"
+    }
+    connect(context.config.jdbcUser, context.config.jdbcPassword, 
forwardingUrl) {
+        def connection = context.getConnection()
+        connection.createStatement().withCloseable { control ->
+            control.execute("SET force_forward_all_queries=false")
+            connection.prepareStatement("SELECT HEX(?), ?").withCloseable { 
prepared ->
+                assertEquals(com.mysql.cj.jdbc.ServerPreparedStatement, 
prepared.class)
+                control.execute("SET force_forward_all_queries=true")
+                try {
+                    2.times {
+                        prepared.setBlob(1, new ByteArrayInputStream(new 
byte[] {0, -1, -128, 65}))
+                        prepared.setInt(2, 45)
+                        qe_forwarded_blob prepared
+                    }
+                } finally {
+                    control.execute("SET force_forward_all_queries=false")
+                }
+            }
+        }
+        qt_after_forwarding "SELECT 4"
+    }
+}


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

Reply via email to