This is an automated email from the ASF dual-hosted git repository.
yujun777 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 fe39f5b6a42 [fix](ivm) Answer FE-computable dry runs on the frontend
instead of a placeholder backend (#67753)
fe39f5b6a42 is described below
commit fe39f5b6a42de75359358735db32b7020b6d7abe
Author: yujun <[email protected]>
AuthorDate: Fri Sep 11 17:21:05 2026 +0800
[fix](ivm) Answer FE-computable dry runs on the frontend instead of a
placeholder backend (#67753)
`REFRESH MATERIALIZED VIEW ... INCREMENTAL WITH DRY RUN` fails with
`Unable to resolve host dummy` in cloud mode when the delta is empty.
An empty delta plans to a `LogicalEmptyRelation`, so the Nereids planner
marks the plan as not needing a backend (`notNeedBackend`) and binds the
placeholder `Backend(-1, "dummy", -1)`. Regular queries short-circuit
such FE-computable plans inside `handleQueryInFe`; the internal
streaming entry used by IVM dry runs (`RefreshMTMVCommand.dryRunRefresh`
-> `StmtExecutor.executeInternalQueryAndSend`) skipped that
short-circuit and started a coordinator, which then sent fragments to
`dummy:-1`.
### What changed
- `StmtExecutor.executeInternalQueryCommon` now short-circuits
FE-computable plans through `planner.handleQueryInFe` for both the
streaming (`sendChannel`) and the collecting internal entry, writing
rows to the caller's mysql channel.
- `sendResultSet`, `sendMetaData`, `sendTextResultRow` and
`sendBinaryResultRow` take an explicit `MysqlChannel` overload; the
previous signatures keep using the executor's own channel.
- `isHandleQueryInFe` and `ExecutedByFrontend` are set only after the
result set was sent, matching the regular query path.
- `test_ivm_refresh_dry_run` no longer skips cloud. The new
`ivm_dry_run_qt` helper masks `__DORIS_SEQUENCE_COL__` (whose base value
differs between cloud and shared-nothing) through the `quickRunTest` row
converter, which now optionally receives the result metadata, so both
modes share one suite and one `.out`. Both suites cover the empty-delta
dry run.
Trace issue: https://github.com/apache/doris/issues/65418
---
.../java/org/apache/doris/qe/StmtExecutor.java | 69 +++++++++++++++++++---
.../data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out | 12 ++--
.../org/apache/doris/regression/suite/Suite.groovy | 12 +++-
regression-test/plugins/plugin_planner.groovy | 21 +++++++
.../mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy | 29 +++++----
5 files changed, 116 insertions(+), 27 deletions(-)
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 701f213c6ef..ef0c28cbeb5 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
@@ -1854,11 +1854,16 @@ public class StmtExecutor {
}
private void sendMetaData(ResultSetMetaData metaData, List<FieldInfo>
fieldInfos) throws IOException {
+ sendMetaData(metaData, fieldInfos, context.getMysqlChannel());
+ }
+
+ private void sendMetaData(ResultSetMetaData metaData, List<FieldInfo>
fieldInfos, MysqlChannel channel)
+ throws IOException {
Preconditions.checkState(context.getConnectType() ==
ConnectType.MYSQL);
// sends how many columns
serializer.reset();
serializer.writeVInt(metaData.getColumnCount());
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
// send field one by one
for (int i = 0; i < metaData.getColumns().size(); i++) {
Column col = metaData.getColumn(i);
@@ -1869,9 +1874,9 @@ public class StmtExecutor {
} else {
serializer.writeField(fieldInfos.get(i), col.getType());
}
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
- sendMetadataTerminatorIfNeeded(context.getMysqlChannel());
+ sendMetadataTerminatorIfNeeded(channel);
}
private List<PrimitiveType> exprToStringType(List<Expr> exprs) {
@@ -2034,19 +2039,34 @@ public class StmtExecutor {
}
public void sendResultSet(ResultSet resultSet, List<FieldInfo> fieldInfos)
throws IOException {
+ sendResultSet(resultSet, fieldInfos, null);
+ }
+
+ /**
+ * Sends a FE-computed result set to the given mysql channel. Regular
queries use the
+ * executor's own channel; internal queries have to stream to the channel
of the caller
+ * that issued them, because the executor's own channel is not connected
to that client.
+ *
+ * <p>A null channel means the session's own. It is resolved inside the
mysql branch on
+ * purpose: a connection of any other type has no mysql channel, and
asking for one throws,
+ * so a caller must be able to hand a result set over without naming a
channel first.
+ */
+ private void sendResultSet(ResultSet resultSet, List<FieldInfo>
fieldInfos, MysqlChannel channel)
+ throws IOException {
if (context.getConnectType().equals(ConnectType.MYSQL)) {
+ MysqlChannel targetChannel = channel == null ?
context.getMysqlChannel() : channel;
context.updateReturnRows(resultSet.getResultRows().size());
// Send meta data.
- sendMetaData(resultSet.getMetaData(), fieldInfos);
+ sendMetaData(resultSet.getMetaData(), fieldInfos, targetChannel);
// Send result set.
if (isComStmtExecute) {
if (LOG.isDebugEnabled()) {
LOG.debug("Use binary protocol to set result.");
}
- sendBinaryResultRow(resultSet);
+ sendBinaryResultRow(resultSet, targetChannel);
} else {
- sendTextResultRow(resultSet);
+ sendTextResultRow(resultSet, targetChannel);
}
context.getState().setEof();
} else if
(context.getConnectType().equals(ConnectType.ARROW_FLIGHT_SQL)) {
@@ -2060,6 +2080,10 @@ public class StmtExecutor {
}
protected void sendTextResultRow(ResultSet resultSet) throws IOException {
+ sendTextResultRow(resultSet, context.getMysqlChannel());
+ }
+
+ protected void sendTextResultRow(ResultSet resultSet, MysqlChannel
channel) throws IOException {
for (List<String> row : resultSet.getResultRows()) {
serializer.reset();
for (String item : row) {
@@ -2069,11 +2093,15 @@ public class StmtExecutor {
serializer.writeLenEncodedString(item);
}
}
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
}
protected void sendBinaryResultRow(ResultSet resultSet) throws IOException
{
+ sendBinaryResultRow(resultSet, context.getMysqlChannel());
+ }
+
+ protected void sendBinaryResultRow(ResultSet resultSet, MysqlChannel
channel) throws IOException {
//
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;
@@ -2136,7 +2164,7 @@ public class StmtExecutor {
}
}
}
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
}
@@ -2311,6 +2339,31 @@ public class StmtExecutor {
planner = new NereidsPlanner(statementContext);
planner.plan(adapter, context.getSessionVariable().toThrift());
+ // A plan that FE can compute on its own (e.g. the empty delta of
a dry run) must be
+ // answered by the frontend, like a regular query does. Otherwise
the coordinator
+ // would send fragments to the placeholder backend registered when
no backend is
+ // needed (see NereidsPlanner#notNeedBackend), which cannot
resolve. Results go to the
+ // caller's channel: the executor's own channel is not connected
to that client.
+ if (context.supportHandleByFe()) {
+ Optional<ResultSet> resultSet =
planner.handleQueryInFe(adapter);
+ if (resultSet.isPresent()) {
+ boolean sendToChannel = !collectMode;
+ if (sendToChannel) {
+ sendResultSet(resultSet.get(),
adapter.getFieldInfos(), sendChannel);
+ }
+ isHandleQueryInFe = true;
+ if (context.getSessionVariable().enableProfile() &&
profile != null) {
+
profile.getSummaryProfile().setExecutedByFrontend(true);
+ }
+ if (sendToChannel) {
+ return new ArrayList<>();
+ }
+ return resultSet.get().getResultRows().stream()
+ .map(ResultRow::new)
+ .collect(Collectors.toList());
+ }
+ }
+
if (!collectMode) {
executeAndSendResult(false, false, adapter, sendChannel, null,
null);
return new ArrayList<>();
diff --git a/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
index 1208b149aa3..686e463d2f3 100644
--- a/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
@@ -5,8 +5,8 @@
3 1 30
-- !ivm_dry_run_full --
-1 1 1 15 1 1 4097 0
-4 4 1 40 1 1 4097 0
+1 1 1 15 1 1 [regression-fake-sequence] 0
+4 4 1 40 1 1 [regression-fake-sequence] 0
-- !ivm_dry_run_after --
1 1 10
@@ -14,8 +14,8 @@
3 1 30
-- !ivm_dry_run_repeat --
-1 1 1 15 1 1 4097 0
-4 4 1 40 1 1 4097 0
+1 1 1 15 1 1 [regression-fake-sequence] 0
+4 4 1 40 1 1 [regression-fake-sequence] 0
-- !ivm_dry_run_after_refresh --
1 1 15
@@ -23,3 +23,7 @@
3 1 30
4 1 40
+-- !ivm_dry_run_empty --
+
+-- !ivm_dry_run_empty_repeat --
+
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
index d906c610ed8..6ef166d9459 100644
---
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
@@ -1727,8 +1727,10 @@ class Suite implements GroovyInterceptable {
return result
}
- // rowConverter: { row -> convertedRow }
+ // rowConverter: { row -> convertedRow }, or { row, meta -> convertedRow }
to inspect the result
+ // metadata, for example to mask a column whose value depends on the
deployment mode.
void quickRunTest(String tag, Object arg, boolean isOrder = false, Closure
rowConverter = null) {
+ boolean converterNeedsMeta = rowConverter != null &&
rowConverter.maximumNumberOfParameters > 1
if (context.config.generateOutputFile ||
context.config.forceGenerateOutputFile) {
Tuple2<List<List<Object>>, ResultSetMetaData> tupleResult = null
if (arg instanceof PreparedStatement) {
@@ -1763,7 +1765,9 @@ class Suite implements GroovyInterceptable {
}
def (result, meta) = tupleResult
if (rowConverter != null) {
- result = result.collect { rowConverter.call(it) }
+ result = result.collect {
+ converterNeedsMeta ? rowConverter.call(it, meta) :
rowConverter.call(it)
+ }
}
if (isOrder) {
result = sortByToString(result)
@@ -1815,7 +1819,9 @@ class Suite implements GroovyInterceptable {
}
def (realResults, meta) = tupleResult
if (rowConverter != null) {
- realResults = realResults.collect { rowConverter.call(it) }
+ realResults = realResults.collect {
+ converterNeedsMeta ? rowConverter.call(it, meta) :
rowConverter.call(it)
+ }
}
if (isOrder) {
realResults = sortByToString(realResults)
diff --git a/regression-test/plugins/plugin_planner.groovy
b/regression-test/plugins/plugin_planner.groovy
index 0a5a12c402a..eca9594bd2d 100644
--- a/regression-test/plugins/plugin_planner.groovy
+++ b/regression-test/plugins/plugin_planner.groovy
@@ -135,3 +135,24 @@ Suite.metaClass.explainIvmPlan = { String tag, String sql
->
)
}
+// __DORIS_SEQUENCE_COL__ of an IVM dry-run delta encodes the refresh version,
whose base value
+// differs between cloud and shared-nothing deployments. Mask that column so
that both modes can
+// share one suite and one .out file.
+def maskIvmDryRunSequence = { row, meta ->
+ for (int i = 1; i <= meta.getColumnCount(); i++) {
+ if ("__DORIS_SEQUENCE_COL__".equalsIgnoreCase(meta.getColumnLabel(i)))
{
+ def masked = new ArrayList(row)
+ masked.set(i - 1, "[regression-fake-sequence]")
+ return masked
+ }
+ }
+ return row
+}
+
+// Named ivm_dry_run_qt instead of order_qt_*: Suite.invokeMethod intercepts
every method whose
+// name starts with qt_ / order_qt_ and derives the tag from the method name,
so a metaClass
+// method could never be reached under those prefixes.
+Suite.metaClass.ivm_dry_run_qt = { String tag, String sql ->
+ delegate.quickRunTest(tag, sql, true, maskIvmDryRunSequence)
+}
+
diff --git a/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
index 5cb89692a6f..f2583e14e8d 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
@@ -16,14 +16,9 @@
// under the License.
suite("test_ivm_refresh_dry_run") {
- // Cloud mode: __DORIS_SEQUENCE_COL__ in the dry-run delta rows derives
from cloud txn
- // versioning and differs from local (e.g. 6145 vs 4097), so the .out
values
- // for the sequence column do not apply.
- if (isCloudMode()) {
- logger.info("skip test_ivm_refresh_dry_run on cloud mode: " +
- "__DORIS_SEQUENCE_COL__ differs between cloud and local")
- return
- }
+ // __DORIS_SEQUENCE_COL__ of the dry-run delta rows encodes the refresh
version, whose base
+ // value differs between cloud and shared-nothing deployments (e.g. 6145
vs 4097).
+ // ivm_dry_run_qt masks that column, so cloud and local share this suite
and its .out.
sql "DROP MATERIALIZED VIEW IF EXISTS test_ivm_refresh_dry_run_mv"
sql "DROP TABLE IF EXISTS test_ivm_refresh_dry_run_base"
@@ -65,9 +60,9 @@ suite("test_ivm_refresh_dry_run") {
order_qt_ivm_dry_run_before "SELECT k1, cnt, sum_v1 FROM
test_ivm_refresh_dry_run_mv"
- order_qt_ivm_dry_run_full """
+ ivm_dry_run_qt("ivm_dry_run_full", """
REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN
- """
+ """)
// The delta rows picked by a LIMIT depend on scan order (no ORDER BY
before the cap),
// so assert only the returned row count instead of exact rows in the .out
file.
@@ -77,11 +72,21 @@ suite("test_ivm_refresh_dry_run") {
order_qt_ivm_dry_run_after "SELECT k1, cnt, sum_v1 FROM
test_ivm_refresh_dry_run_mv"
- order_qt_ivm_dry_run_repeat """
+ ivm_dry_run_qt("ivm_dry_run_repeat", """
REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN LIMIT 10
- """
+ """)
sql "REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL"
waitingMTMVTaskFinishedByMvName("test_ivm_refresh_dry_run_mv")
order_qt_ivm_dry_run_after_refresh "SELECT k1, cnt, sum_v1 FROM
test_ivm_refresh_dry_run_mv"
+
+ // Empty delta: the delta query is a LogicalEmptyRelation and the dry run
must return an
+ // empty result instead of sending fragments to a placeholder backend.
Every dry-run
+ // comparison goes through ivm_dry_run_qt so the sequence column is masked
in all of them.
+ ivm_dry_run_qt("ivm_dry_run_empty", """
+ REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN
+ """)
+ ivm_dry_run_qt("ivm_dry_run_empty_repeat", """
+ REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN
+ """)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]