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 07009d25633 branch-4.1: [fix](sqlcache) Do not replay the MySQL sql
cache on an Arrow Flight connection #67381 (#67556)
07009d25633 is described below
commit 07009d25633aaecc82bd24f4d047b63542a46e7f
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Sep 8 11:09:14 2026 +0800
branch-4.1: [fix](sqlcache) Do not replay the MySQL sql cache on an Arrow
Flight connection #67381 (#67556)
Cherry-picked from #67381
### Backport notes
One conflict, in `SessionVariable`: branch-4.1 still declares session
variables with
`@VariableMgr.VarAttr` rather than `@VarAttrDef.VarAttr`, so
`RETURN_OBJECT_DATA_AS_BINARY` keeps
that annotation here. The `affectQueryResultInExecution` attribute
exists on this branch's
annotation and `NereidsSqlCacheManager.usedVariablesChanged` compares
the same
`SqlCacheContext.computeAffectQueryResultVariables()` string, so the
behaviour is identical.
`ConnectProcessor`, `StmtExecutor` and both regression suites applied
cleanly.
### Local verification
`./build.sh --fe` on this branch: **BUILD SUCCESS**, no errors,
checkstyle clean on every module (`fe-common` and `fe-core` included).
Regression suites were not run locally.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DzUYFcGHQH3bnLGCjpncVj
---
.../java/org/apache/doris/qe/ConnectProcessor.java | 10 +-
.../java/org/apache/doris/qe/SessionVariable.java | 7 +-
.../java/org/apache/doris/qe/StmtExecutor.java | 6 +
.../test_sql_cache_over_arrow_flight.groovy | 168 +++++++++++++++++++++
.../query_p0/cache/sql_cache_object_type.groovy | 107 +++++++++++++
5 files changed, 296 insertions(+), 2 deletions(-)
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 90e0f6e8a62..fa6f646f5e8 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
@@ -269,7 +269,15 @@ public abstract class ConnectProcessor {
ctx.setSqlHash(sqlHash);
SessionVariable sessionVariable = ctx.getSessionVariable();
- boolean wantToParseSqlFromSqlCache =
CacheAnalyzer.canUseSqlCache(sessionVariable);
+ // The sql cache keeps the result rows in MySQL wire format and
replays them through a
+ // MysqlChannel (StmtExecutor.sendCachedValues -> sendFields), which
only exists on a MySQL
+ // connection. An Arrow Flight SQL connection has no channel and needs
Arrow batches built by
+ // the BE, and the cached rows would be wrong for it anyway (object
types such as HLL /
+ // BITMAP / QUANTILE_STATE were serialized as NULL under
return_object_data_as_binary=false).
+ // So a non-MySQL connection must always re-execute the query instead
of replaying the cache.
+ // The cache is never populated by such a connection either, see
StmtExecutor.handleQueryStmt.
+ boolean wantToParseSqlFromSqlCache =
connectType.equals(ConnectType.MYSQL)
+ && CacheAnalyzer.canUseSqlCache(sessionVariable);
List<StatementBase> stmts = null;
long parseSqlStartTime = System.currentTimeMillis();
List<StatementBase> cachedStmts = null;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index 7bcb664f9e6..a4fe894e927 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -2000,7 +2000,12 @@ public class SessionVariable implements Serializable,
Writable {
@VariableMgr.VarAttr(name = ENABLE_INFER_PREDICATE)
private boolean enableInferPredicate = true;
- @VariableMgr.VarAttr(name = RETURN_OBJECT_DATA_AS_BINARY)
+ // Forwarded to the BE as a query option and read by the MySQL result
writer: when it is false
+ // the object types (HLL / BITMAP / QUANTILE_STATE) are serialized as NULL
instead of their raw
+ // bytes. It therefore changes the result rows the sql cache stores, and
must take part in the
+ // cache key, otherwise a session that turns it on replays the NULLs
cached by a session that
+ // had it off. It only affects execution, not the plan, so it does not
force forwarding.
+ @VariableMgr.VarAttr(name = RETURN_OBJECT_DATA_AS_BINARY,
affectQueryResultInExecution = true)
private boolean returnObjectDataAsBinary = false;
@VariableMgr.VarAttr(name = BLOCK_ENCRYPTION_MODE, affectQueryResultInPlan
= true)
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 0384ddd07da..b1a80513b43 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
@@ -1352,6 +1352,12 @@ public class StmtExecutor {
LogicalPlanAdapter logicalPlanAdapter = (LogicalPlanAdapter)
parsedStmt;
LogicalPlan logicalPlan = logicalPlanAdapter.getLogicalPlan();
if (logicalPlan instanceof
org.apache.doris.nereids.trees.plans.algebra.SqlCache) {
+ // sendCachedValues replays MySQL protocol packets, so it
needs a MysqlChannel.
+ // ConnectProcessor.executeQuery only looks the sql cache up
for a MySQL connection,
+ // so a cached plan must never reach another protocol here.
+ Preconditions.checkState(channel != null,
+ "sql cache can only be replayed on a MySQL connection,
but connect type is %s",
+ context.getConnectType());
NereidsPlanner nereidsPlanner = (NereidsPlanner) planner;
PhysicalSqlCache physicalSqlCache = (PhysicalSqlCache)
nereidsPlanner.getPhysicalPlan();
sendCachedValues(channel, physicalSqlCache.getCacheValues(),
logicalPlanAdapter, false, true);
diff --git
a/regression-test/suites/arrow_flight_sql_p0/test_sql_cache_over_arrow_flight.groovy
b/regression-test/suites/arrow_flight_sql_p0/test_sql_cache_over_arrow_flight.groovy
new file mode 100644
index 00000000000..577665b275b
--- /dev/null
+++
b/regression-test/suites/arrow_flight_sql_p0/test_sql_cache_over_arrow_flight.groovy
@@ -0,0 +1,168 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.util.JdbcUtils
+
+// Regression for https://github.com/apache/doris/issues/67364
+//
+// The FE sql cache is shared by every protocol, but its rows are MySQL wire
protocol packets that
+// StmtExecutor.sendCachedValues replays through a MysqlChannel, and an Arrow
Flight SQL connection
+// has none. Replaying an entry created by an identical MySQL query used to
fail
+// Preconditions.checkState(connectType == MYSQL) in StmtExecutor.sendFields()
and reach the client
+// as "INTERNAL ... IllegalStateException, msg: null", for any result type.
The issue was reported
+// on raw HLL / QUANTILE_STATE columns only because that sql text happened to
be the one primed
+// through the MySQL control session.
+//
+// Two setup details decide whether this test can reproduce the bug at all --
get either wrong and
+// it stays green on a broken FE:
+//
+// 1. The flight statements are sent on the raw flight connection.
Suite.arrow_flight_sql()
+// prepends "USE <db>;" to the statement, which changes the sql text and
therefore the cache
+// key (NereidsSqlCacheManager.generateCacheKey is
"<catalog>.<db>:<user>:<sql text>").
+// 2. Both sessions must agree on every session variable the cache compares
+// (NereidsSqlCacheManager.usedVariablesChanged compares the whole
affectQueryResult* set).
+// The MySQL JDBC driver adds STRICT_TRANS_TABLES to sql_mode at connect
time while the Arrow
+// Flight JDBC driver does not, and sql_mode is affectQueryResultInPlan,
so an unaligned
+// sql_mode alone makes every flight lookup miss.
+suite("test_sql_cache_over_arrow_flight") {
+ def mysqlConn = context.getConn()
+ def flightConn = context.getArrowFlightSqlConnection()
+
+ def runOnMysql = { String stmt ->
+ def (result, meta) = JdbcUtils.executeToList(mysqlConn, stmt)
+ return result
+ }
+ def runOnFlight = { String stmt ->
+ def (result, meta) = JdbcUtils.executeToList(flightConn, stmt)
+ return result
+ }
+
+ def hasSqlCache = { String stmt ->
+ def (explainRows, meta) = JdbcUtils.executeToList(mysqlConn, "explain
physical plan " + stmt)
+ return explainRows.collect { row -> row.get(0).toString()
}.join("\n").contains("PhysicalSqlCache")
+ }
+
+ // Create the cache entry on the MySQL connection, and wait until an
identical statement is
+ // actually served from it, so the flight query below really runs against
a populated cache.
+ def primeSqlCacheOnMysql = { String stmt ->
+ for (int i = 0; i < 60; ++i) {
+ runOnMysql(stmt)
+ if (hasSqlCache(stmt)) {
+ return
+ }
+ sleep(1000)
+ }
+ throw new IllegalStateException("failed to create sql cache for: " +
stmt)
+ }
+
+ // JdbcUtils renders a binary column as an "0x.." hex string, but falls
back to the raw object
+ // when the driver does not implement getBytes().
+ def isNonEmptyBinary = { value ->
+ if (value == null) {
+ return false
+ }
+ if (value instanceof byte[]) {
+ return ((byte[]) value).length > 0
+ }
+ return value.toString().length() > "0x".length()
+ }
+
+ withGlobalLock("cache_last_version_interval_second") {
+ runOnMysql "ADMIN SET ALL FRONTENDS CONFIG
('cache_last_version_interval_second' = '0')"
+
+ def dbName = context.dbName
+ runOnMysql "USE `${dbName}`"
+ runOnFlight "USE `${dbName}`"
+ runOnMysql "set enable_sql_cache=true"
+ runOnFlight "set enable_sql_cache=true"
+ // See note 2 above: without this the flight lookup always misses and
the test is toothless.
+ runOnMysql "set sql_mode='ONLY_FULL_GROUP_BY'"
+ runOnFlight "set sql_mode='ONLY_FULL_GROUP_BY'"
+
+ // The cache key is the catalog, the database, the user and the sql
text, and the lookup
+ // additionally compares the session variables that affect the result.
The statements below
+ // are byte identical on both connections, so assert the rest of the
inputs match too.
+ assertEquals(runOnMysql("select database()")[0][0],
runOnFlight("select database()")[0][0])
+ assertEquals(runOnMysql("select current_user()")[0][0],
runOnFlight("select current_user()")[0][0])
+ assertEquals(runOnMysql("select @@sql_mode")[0][0],
runOnFlight("select @@sql_mode")[0][0])
+
+ // 1. A constant result, cached in the FE itself
(PhysicalOneRowRelation.computeResultInFe
+ // -> tryAddFeSqlCache). This replays through the resultSet branch of
sendCachedValues,
+ // needs no table and no quiet window, and is the cheapest way to hit
the bug.
+ def constantSql = "select 1 as c, 'x' as s"
+ primeSqlCacheOnMysql(constantSql)
+ def constantOnFlight = runOnFlight(constantSql)
+ assertEquals(1, constantOnFlight.size())
+ assertEquals(1, constantOnFlight[0][0] as int)
+ assertEquals("x", constantOnFlight[0][1].toString())
+
+ def tblName = "test_sql_cache_over_arrow_flight_tbl"
+ runOnMysql "DROP TABLE IF EXISTS ${tblName}"
+ runOnMysql """
+ CREATE TABLE ${tblName} (
+ k INT,
+ h HLL HLL_UNION,
+ q QUANTILE_STATE QUANTILE_UNION
+ ) AGGREGATE KEY(k)
+ DISTRIBUTED BY HASH(k) BUCKETS 1
+ PROPERTIES("replication_num"="1")
+ """
+ runOnMysql "INSERT INTO ${tblName} SELECT 1, HLL_HASH('x'),
TO_QUANTILE_STATE(1, 2048)"
+
+ // 2. A plain scalar result read from a table, cached on the BE. The
failure was protocol
+ // specific, not type specific.
+ def scalarSql = "select k from ${tblName} order by k"
+ primeSqlCacheOnMysql(scalarSql)
+ def scalarOnFlight = runOnFlight(scalarSql)
+ assertEquals(1, scalarOnFlight.size())
+ assertEquals(1, scalarOnFlight[0][0] as int)
+
+ // 3. The raw aggregate state columns from the issue. HLL and
QUANTILE_STATE are carried as
+ // arrow binary (be/src/format/arrow/arrow_row_batch.cpp), so flight
returns the serialized
+ // state, while the MySQL protocol keeps showing NULL under
+ // return_object_data_as_binary=false. Asserting both at once also
proves the flight result
+ // is produced by the BE rather than replayed from the MySQL rows
sitting in the cache.
+ def rawStateSql = "select h, q from ${tblName}"
+ primeSqlCacheOnMysql(rawStateSql)
+ def rawStateOnMysql = runOnMysql(rawStateSql)
+ assertEquals(1, rawStateOnMysql.size())
+ assertNull(rawStateOnMysql[0][0])
+ assertNull(rawStateOnMysql[0][1])
+ def rawStateOnFlight = runOnFlight(rawStateSql)
+ assertEquals(1, rawStateOnFlight.size())
+ assertTrue(isNonEmptyBinary(rawStateOnFlight[0][0]),
+ "expect a non empty HLL state over arrow flight, but got: " +
rawStateOnFlight[0][0])
+ assertTrue(isNonEmptyBinary(rawStateOnFlight[0][1]),
+ "expect a non empty QUANTILE_STATE over arrow flight, but got:
" + rawStateOnFlight[0][1])
+
+ // 4. The server side conversions the issue used as a workaround.
+ def convertedSql = "select hll_cardinality(h) as c,
quantile_percent(q, 0.5) as p from ${tblName}"
+ primeSqlCacheOnMysql(convertedSql)
+ def convertedOnFlight = runOnFlight(convertedSql)
+ assertEquals(1, convertedOnFlight.size())
+ assertEquals(1L, convertedOnFlight[0][0] as long)
+ assertEquals(1.0d, convertedOnFlight[0][1] as double, 1e-9)
+
+ // The flight queries must not have consumed the cache: a cached plan
reaching a non MySQL
+ // connection is exactly the crash this test guards against, and the
entries must still be
+ // there for the MySQL session afterwards.
+ assertTrue(hasSqlCache(constantSql))
+ assertTrue(hasSqlCache(scalarSql))
+ assertTrue(hasSqlCache(rawStateSql))
+ assertTrue(hasSqlCache(convertedSql))
+ }
+}
diff --git a/regression-test/suites/query_p0/cache/sql_cache_object_type.groovy
b/regression-test/suites/query_p0/cache/sql_cache_object_type.groovy
new file mode 100644
index 00000000000..6300840d20f
--- /dev/null
+++ b/regression-test/suites/query_p0/cache/sql_cache_object_type.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.
+
+import org.apache.doris.regression.util.JdbcUtils
+
+// return_object_data_as_binary decides whether the BE's MySQL result writer
serializes HLL /
+// BITMAP / QUANTILE_STATE as their raw bytes or as NULL, so it changes the
very rows the sql cache
+// stores. It must therefore take part in the cache key comparison
+// (NereidsSqlCacheManager.usedVariablesChanged over
SessionVariable.affectQueryResultFields),
+// otherwise a session that turns it on replays the NULLs cached by a session
that had it off.
+suite("sql_cache_object_type") {
+ def conn = context.getConn()
+ def run = { String stmt ->
+ def (result, meta) = JdbcUtils.executeToList(conn, stmt)
+ return result
+ }
+ def hasSqlCache = { String stmt ->
+ def (rows, meta) = JdbcUtils.executeToList(conn, "explain physical
plan " + stmt)
+ return rows.collect { row -> row.get(0).toString()
}.join("\n").contains("PhysicalSqlCache")
+ }
+ def primeSqlCache = { String stmt ->
+ for (int i = 0; i < 60; ++i) {
+ run(stmt)
+ if (hasSqlCache(stmt)) {
+ return
+ }
+ sleep(1000)
+ }
+ throw new IllegalStateException("failed to create sql cache for: " +
stmt)
+ }
+ def isNonEmpty = { value ->
+ if (value == null) {
+ return false
+ }
+ if (value instanceof byte[]) {
+ return ((byte[]) value).length > 0
+ }
+ return !value.toString().isEmpty()
+ }
+
+ withGlobalLock("cache_last_version_interval_second") {
+ run "ADMIN SET ALL FRONTENDS CONFIG
('cache_last_version_interval_second' = '0')"
+ run "set enable_sql_cache=true"
+
+ def tblName = "sql_cache_object_type_tbl"
+ run "DROP TABLE IF EXISTS ${tblName}"
+ run """
+ CREATE TABLE ${tblName} (
+ k INT,
+ h HLL HLL_UNION,
+ b BITMAP BITMAP_UNION
+ ) AGGREGATE KEY(k)
+ DISTRIBUTED BY HASH(k) BUCKETS 1
+ PROPERTIES("replication_num"="1")
+ """
+ run "INSERT INTO ${tblName} SELECT 1, HLL_HASH('x'), TO_BITMAP(1)"
+
+ def objectSql = "select h, b from ${tblName}"
+
+ // With the default (false) the object columns come back as NULL, and
that is what lands in
+ // the cache.
+ run "set return_object_data_as_binary=false"
+ primeSqlCache(objectSql)
+ def asNull = run(objectSql)
+ assertEquals(1, asNull.size())
+ assertNull(asNull[0][0])
+ assertNull(asNull[0][1])
+
+ // Turning it on must not be served the cached NULLs: it is a
different result, so it is a
+ // different cache key and the query has to be executed again.
+ run "set return_object_data_as_binary=true"
+ assertFalse(hasSqlCache(objectSql),
+ "return_object_data_as_binary=true must not reuse the entry
cached with it off")
+ def asBinary = run(objectSql)
+ assertEquals(1, asBinary.size())
+ assertTrue(isNonEmpty(asBinary[0][0]),
+ "expect the raw HLL bytes, but got: " + asBinary[0][0])
+ assertTrue(isNonEmpty(asBinary[0][1]),
+ "expect the raw BITMAP bytes, but got: " + asBinary[0][1])
+
+ // The two settings keep their own entries, and each still serves its
own result.
+ primeSqlCache(objectSql)
+ def asBinaryCached = run(objectSql)
+ assertTrue(isNonEmpty(asBinaryCached[0][0]))
+ assertTrue(isNonEmpty(asBinaryCached[0][1]))
+
+ run "set return_object_data_as_binary=false"
+ assertTrue(hasSqlCache(objectSql))
+ def asNullAgain = run(objectSql)
+ assertNull(asNullAgain[0][0])
+ assertNull(asNullAgain[0][1])
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]