This is an automated email from the ASF dual-hosted git repository.
morrySnow 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 cba3cac5a2f [Fix](Query Stats) Add QueryStatsRecorder for column-level
query and filter - Part1 (#63067)
cba3cac5a2f is described below
commit cba3cac5a2fbeb7d23346285db1b0a7a47d5e969
Author: nsivarajan <[email protected]>
AuthorDate: Wed May 27 10:59:36 2026 +0530
[Fix](Query Stats) Add QueryStatsRecorder for column-level query and filter
- Part1 (#63067)
### What problem does this PR solve?
Problem Summary:
`Show Query Stats` returns only 0, as it is broken . This PR is intended
to make Query and Filter Stats to available when
below config is enabled.
```sql
admin set frontend config ("enable_query_hit_stats" = "true");
set enable_nereids_planner = true;
```
This is planned as Sequence of PR with incremental capture of more cases
later (like Group by, order by, Window and more).
---------
Co-authored-by: Sivarajan Narayanan <[email protected]>
---
.../org/apache/doris/nereids/NereidsPlanner.java | 4 +
.../org/apache/doris/nereids/StatementContext.java | 9 +
.../doris/statistics/query/QueryStatsRecorder.java | 212 ++++++++++
.../doris/statistics/query/QueryStatsUtil.java | 6 +-
.../statistics/query/QueryStatsRecorderTest.java | 435 +++++++++++++++++++++
.../data/query_p0/stats/query_stats_test.out | 52 ---
.../suites/query_p0/stats/query_stats_test.groovy | 120 +++++-
7 files changed, 776 insertions(+), 62 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
index 7952d009e51..5ccca2a185f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
@@ -85,6 +85,7 @@ import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.ResultSet;
import org.apache.doris.qe.SessionVariable;
import org.apache.doris.qe.VariableMgr;
+import org.apache.doris.statistics.query.QueryStatsRecorder;
import org.apache.doris.statistics.util.StatisticsUtil;
import org.apache.doris.thrift.TQueryCacheParam;
@@ -179,6 +180,9 @@ public class NereidsPlanner extends Planner {
LOG.info(getExplainString(new
ExplainOptions(ExplainLevel.SHAPE_PLAN, false)));
LOG.info(getExplainString(new
ExplainOptions(ExplainLevel.DISTRIBUTED_PLAN, false)));
}
+ if (physicalPlan != null) {
+ QueryStatsRecorder.record(physicalPlan, statementContext);
+ }
}
@VisibleForTesting
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index 2af64752cc4..3659b1f79f5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -319,6 +319,7 @@ public class StatementContext implements Closeable {
// When true, data will be collected to a single node to avoid generating
too many small files
private boolean useGatherForIcebergRewrite = false;
private boolean hasNestedColumns;
+ private boolean queryStatsRecorded = false;
private final Set<CTEId> mustInlineCTE = new HashSet<>();
private final Set<String> usedAIResourceNames = new LinkedHashSet<>();
@@ -1174,6 +1175,14 @@ public class StatementContext implements Closeable {
return isInsert;
}
+ public boolean isQueryStatsRecorded() {
+ return queryStatsRecorded;
+ }
+
+ public void markQueryStatsRecorded() {
+ queryStatsRecorded = true;
+ }
+
public Optional<Map<TableIf, Set<Expression>>> getMvRefreshPredicates() {
return mvRefreshPredicates;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java
new file mode 100644
index 00000000000..1b5b76a1bc5
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java
@@ -0,0 +1,212 @@
+// 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.statistics.query;
+
+import org.apache.doris.analysis.StatementBase;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.common.Config;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.glue.LogicalPlanAdapter;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.commands.Command;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
+import org.apache.doris.qe.ConnectContext;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Records column-level query-hit and filter-hit statistics from the Nereids
physical plan.
+ * Called once per query in NereidsPlanner after plan translation.
+ *
+ * <p>Scope (Part 1):
+ * <ul>
+ * <li>queryHit: base SELECT columns whose ExprId flows straight through to
the root
+ * plan's output without rewriting. Columns hidden by an alias, an
expression,
+ * or an aggregate function are NOT recorded yet (Part 2).</li>
+ * <li>filterHit: columns referenced in WHERE predicate conjuncts.</li>
+ * <li>Only OlapTable scans are recorded; external tables (Hive, Iceberg,
JDBC, …) are not.</li>
+ * <li>DML, EXPLAIN, and internal queries (e.g. auto-analyze) are
skipped.</li>
+ * <li>Per query, each table's count is incremented at most once regardless
of scan count.</li>
+ * </ul>
+ * GROUP BY, ORDER BY, window, JOIN, and aliased/projected columns are
deferred to Part 2.
+ */
+public class QueryStatsRecorder {
+ private static final Logger LOG =
LogManager.getLogger(QueryStatsRecorder.class);
+
+ private QueryStatsRecorder() {}
+
+ public static void record(PhysicalPlan plan, StatementContext stmtContext)
{
+ if (!shouldRecord(stmtContext)) {
+ return;
+ }
+ if (stmtContext.isQueryStatsRecorded()) {
+ return;
+ }
+ // Set the latch before the work so a partial-failure retry does not
double-count.
+ stmtContext.markQueryStatsRecorded();
+ try {
+ Map<String, StatsDelta> deltas = collectDeltas(plan);
+ for (StatsDelta delta : deltas.values()) {
+ if (!delta.empty()) {
+ try {
+ Env.getCurrentEnv().getQueryStats().addStats(delta);
+ } catch (Exception e) {
+ ConnectContext cc = stmtContext.getConnectContext();
+ String queryId = (cc != null && cc.queryId() != null)
+ ? cc.queryId().toString() : "unknown";
+ LOG.warn("Failed to record query stats for query={}",
queryId, e);
+ }
+ }
+ }
+ } catch (Exception e) {
+ ConnectContext cc = stmtContext.getConnectContext();
+ String queryId = (cc != null && cc.queryId() != null)
+ ? cc.queryId().toString() : "unknown";
+ LOG.warn("Failed to build query stats deltas for query={}",
queryId, e);
+ }
+ }
+
+ /**
+ * Builds the per-table StatsDelta map from the physical plan.
+ * Package-private so unit tests can verify recording logic without
touching Env.
+ */
+ static Map<String, StatsDelta> collectDeltas(PhysicalPlan plan) {
+ Map<ExprId, PhysicalOlapScan> exprIdToScan = new HashMap<>();
+ Map<String, StatsDelta> deltas = new HashMap<>();
+ walkPlan(plan, exprIdToScan, deltas);
+ if (exprIdToScan.isEmpty()) {
+ return deltas;
+ }
+ for (Slot slot : plan.getOutput()) {
+ if (!(slot instanceof SlotReference)) {
+ continue;
+ }
+ SlotReference sr = (SlotReference) slot;
+ PhysicalOlapScan sourceScan = exprIdToScan.get(sr.getExprId());
+ if (sourceScan == null) {
+ continue;
+ }
+ StatsDelta delta = getOrCreateDelta(deltas, sourceScan);
+ if (delta != null) {
+ sr.getOriginalColumn().ifPresent(col ->
delta.addQueryStats(col.getName()));
+ }
+ }
+ return deltas;
+ }
+
+ // Package-private for testing.
+ static boolean shouldRecord(StatementContext ctx) {
+ if (!Config.enable_query_hit_stats) {
+ return false;
+ }
+ ConnectContext connectContext = ctx.getConnectContext();
+ if (connectContext != null && connectContext.getState().isInternal()) {
+ return false;
+ }
+ StatementBase stmt = ctx.getParsedStatement();
+ if (stmt == null || stmt.isExplain()) {
+ return false;
+ }
+ // isInsert guards INSERT INTO … SELECT: parsedStmt may be the SELECT
sub-plan,
+ // not the INSERT Command, when NereidsPlanner re-enters for execution
planning.
+ if (ctx.isInsert()) {
+ return false;
+ }
+ if (stmt instanceof LogicalPlanAdapter
+ && ((LogicalPlanAdapter) stmt).getLogicalPlan() instanceof
Command) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Single-pass tree walk: registers scan output slots into exprIdToScan,
+ * and records filterHit for PhysicalFilter conjuncts.
+ * Children are visited before the current node so scans are registered
+ * before parent filters look them up.
+ * PhysicalLazyMaterializeOlapScan is checked before PhysicalOlapScan
+ * because it is a subclass; the inner scan's metadata must be used.
+ */
+ private static void walkPlan(Plan plan,
+ Map<ExprId, PhysicalOlapScan> exprIdToScan,
+ Map<String, StatsDelta> deltas) {
+ if (plan instanceof PhysicalLazyMaterializeOlapScan) {
+ PhysicalOlapScan inner =
+ ((PhysicalLazyMaterializeOlapScan) plan).getScan();
+ for (Slot slot : plan.getOutput()) {
+ exprIdToScan.put(slot.getExprId(), inner);
+ }
+ return;
+ }
+ if (plan instanceof PhysicalOlapScan) {
+ PhysicalOlapScan scan = (PhysicalOlapScan) plan;
+ for (Slot slot : scan.getOutput()) {
+ exprIdToScan.put(slot.getExprId(), scan);
+ }
+ return;
+ }
+ for (Plan child : plan.children()) {
+ walkPlan(child, exprIdToScan, deltas);
+ }
+ if (plan instanceof PhysicalFilter) {
+ PhysicalFilter<?> filter = (PhysicalFilter<?>) plan;
+ for (Expression conjunct : filter.getConjuncts()) {
+ conjunct.getInputSlots().forEach(slot -> {
+ if (!(slot instanceof SlotReference)) {
+ return;
+ }
+ SlotReference sr = (SlotReference) slot;
+ PhysicalOlapScan sourceScan =
exprIdToScan.get(sr.getExprId());
+ if (sourceScan == null) {
+ return;
+ }
+ StatsDelta delta = getOrCreateDelta(deltas, sourceScan);
+ if (delta != null) {
+ sr.getOriginalColumn().ifPresent(col ->
delta.addFilterStats(col.getName()));
+ }
+ });
+ }
+ }
+ }
+
+ private static StatsDelta getOrCreateDelta(Map<String, StatsDelta> deltas,
+ PhysicalOlapScan scan) {
+ OlapTable t = scan.getTable();
+ DatabaseIf<?> db = scan.getDatabase();
+ if (t == null || db == null) {
+ return null;
+ }
+ String key = t.getCatalogId() + "_" + db.getId() + "_" + t.getId()
+ + "_" + scan.getSelectedIndexId();
+ return deltas.computeIfAbsent(key, k ->
+ new StatsDelta(t.getCatalogId(), db.getId(), t.getId(),
scan.getSelectedIndexId()));
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
index 3359fb54767..a8cf306f271 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
@@ -24,6 +24,7 @@ import org.apache.doris.common.Pair;
import org.apache.doris.common.UserException;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.system.Frontend;
+import org.apache.doris.system.SystemInfoService.HostInfo;
import org.apache.doris.thrift.FrontendService;
import org.apache.doris.thrift.TGetQueryStatsRequest;
import org.apache.doris.thrift.TNetworkAddress;
@@ -171,8 +172,11 @@ public class QueryStatsUtil {
private static List<TQueryStatsResult> getStats(TGetQueryStatsRequest
request) {
List<TQueryStatsResult> results = new ArrayList<>();
+ HostInfo selfHostInfo = Env.getCurrentEnv().getSelfNode();
for (Frontend fe : Env.getCurrentEnv().getFrontends(null /* all */)) {
- if (!fe.isAlive() ||
fe.getHost().equals(Env.getCurrentEnv().getSelfNode().getHost())) {
+ if (!fe.isAlive()
+ || (fe.getHost().equals(selfHostInfo.getHost())
+ && fe.getEditLogPort() == selfHostInfo.getPort())) {
continue;
}
FrontendService.Client client = null;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
b/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
new file mode 100644
index 00000000000..a06a6eba4ad
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
@@ -0,0 +1,435 @@
+// 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.statistics.query;
+
+import org.apache.doris.analysis.ExplainOptions;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.common.Config;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.glue.LogicalPlanAdapter;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.commands.Command;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.QueryState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public class QueryStatsRecorderTest {
+
+ private boolean originalConfigValue;
+
+ @BeforeEach
+ public void setUp() {
+ originalConfigValue = Config.enable_query_hit_stats;
+ }
+
+ @AfterEach
+ public void tearDown() {
+ // Restore config to avoid affecting other tests.
+ Config.enable_query_hit_stats = originalConfigValue;
+ }
+
+ // ── shouldRecord guard tests
─────────────────────────────────────────────
+
+ @Test
+ public void testShouldNotRecordWhenConfigOff() {
+ Config.enable_query_hit_stats = false;
+ StatementContext ctx = new StatementContext();
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(
+
Mockito.mock(org.apache.doris.nereids.trees.plans.logical.LogicalPlan.class),
ctx);
+ ctx.setParsedStatement(stmt);
+ Assertions.assertFalse(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ @Test
+ public void testShouldNotRecordWhenStatementIsNull() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ // parsedStatement not set — getParsedStatement() returns null
+ Assertions.assertFalse(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ @Test
+ public void testShouldNotRecordExplain() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(
+
Mockito.mock(org.apache.doris.nereids.trees.plans.logical.LogicalPlan.class),
ctx);
+ // isExplain() returns true when explainOptions is non-null.
+ stmt.setIsExplain(new ExplainOptions(false, false, false));
+ ctx.setParsedStatement(stmt);
+ Assertions.assertFalse(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ @Test
+ public void testShouldNotRecordDml() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ Command dmlCommand = Mockito.mock(Command.class);
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(dmlCommand, ctx);
+ ctx.setParsedStatement(stmt);
+ Assertions.assertFalse(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ @Test
+ public void testShouldNotRecordInternalQuery() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ org.apache.doris.nereids.trees.plans.logical.LogicalPlan selectPlan =
+
Mockito.mock(org.apache.doris.nereids.trees.plans.logical.LogicalPlan.class);
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(selectPlan, ctx);
+ ctx.setParsedStatement(stmt);
+ QueryState state = new QueryState();
+ state.setInternal(true);
+ ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+ Mockito.when(connectContext.getState()).thenReturn(state);
+ ctx.setConnectContext(connectContext);
+ Assertions.assertFalse(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ @Test
+ public void testShouldNotRecordInsert() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ org.apache.doris.nereids.trees.plans.logical.LogicalPlan selectPlan =
+
Mockito.mock(org.apache.doris.nereids.trees.plans.logical.LogicalPlan.class);
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(selectPlan, ctx);
+ ctx.setParsedStatement(stmt);
+ ctx.setIsInsert(true);
+ Assertions.assertFalse(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ @Test
+ public void testShouldRecordNormalSelect() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ org.apache.doris.nereids.trees.plans.logical.LogicalPlan selectPlan =
+
Mockito.mock(org.apache.doris.nereids.trees.plans.logical.LogicalPlan.class);
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(selectPlan, ctx);
+ ctx.setParsedStatement(stmt);
+ Assertions.assertTrue(QueryStatsRecorder.shouldRecord(ctx));
+ }
+
+ // ── collectDeltas (walkPlan) tests
───────────────────────────────────────
+
+ /**
+ * Plan: Filter(k2=1) → Scan[k1(id1), k2(id2)], root output = [k1]
+ * Expected: k1.queryHit=true (SELECT), k2.filterHit=true (WHERE), no
cross-contamination.
+ */
+ @Test
+ public void testFilterHitRecorded() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k2Slot));
+
+ Expression conjunct = Mockito.mock(Expression.class);
+
Mockito.when(conjunct.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(conjunct));
+ // Root output: only k1 (SELECT k1 WHERE k2=1)
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ // k1: queryHit only (in SELECT output)
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ Assertions.assertFalse(delta.getColumnStats().get("k1").filterHit);
+ // k2: filterHit only (in WHERE conjunct, not in SELECT output)
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"));
+ Assertions.assertTrue(delta.getColumnStats().get("k2").filterHit);
+ Assertions.assertFalse(delta.getColumnStats().get("k2").queryHit);
+ }
+
+ /**
+ * Plan: Scan[k1(id1), k2(id2)] as root, root output = [k1]
+ * Expected: k1.queryHit=true, k2 not in delta.
+ */
+ @Test
+ public void testQueryHitFromRootOutput() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k2Slot));
+ // Root output exposes only k1
+ Mockito.when(scan.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas(scan);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ Assertions.assertFalse(delta.getColumnStats().get("k1").filterHit);
+ // k2 not in root output — no delta entry
+ Assertions.assertNull(delta.getColumnStats().get("k2"));
+ }
+
+ /**
+ * Calling record() twice on the same StatementContext must record only
once.
+ * The latch (isQueryStatsRecorded) is checked after shouldRecord passes,
+ * so we need a valid statement context for shouldRecord to return true.
+ */
+ @Test
+ public void testRecordIsIdempotent() {
+ Config.enable_query_hit_stats = true;
+ StatementContext ctx = new StatementContext();
+ // Set up a proper SELECT so shouldRecord() returns true
+ org.apache.doris.nereids.trees.plans.logical.LogicalPlan logicalPlan =
+
Mockito.mock(org.apache.doris.nereids.trees.plans.logical.LogicalPlan.class);
+ LogicalPlanAdapter stmt = new LogicalPlanAdapter(logicalPlan, ctx);
+ ctx.setParsedStatement(stmt);
+ // Pre-set the latch — simulates a second call after first recording
+ ctx.markQueryStatsRecorded();
+
+ PhysicalOlapScan plan = Mockito.mock(PhysicalOlapScan.class);
+ QueryStatsRecorder.record(plan, ctx);
+ // isQueryStatsRecorded() is true → record() returns before touching
the plan
+ Mockito.verify(plan, Mockito.never()).getOutput();
+ }
+
+ /**
+ * PhysicalLazyMaterializeOlapScan wrapping an inner scan:
+ * the delta key and table metadata must come from the inner scan.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testDeferMaterializeScanUsesInnerScan() {
+ ExprId id1 = new ExprId(1);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+
+ // Inner scan has catalogId=2, dbId=2, tableId=2, indexId=2
+ PhysicalOlapScan inner = mockScan(2L, 2L, 2L, 2L,
ImmutableList.of(k1Slot));
+
+ PhysicalLazyMaterializeOlapScan defer =
+ Mockito.mock(PhysicalLazyMaterializeOlapScan.class);
+ Mockito.when(defer.getScan()).thenReturn(inner);
+ // Both walkPlan and collectDeltas root-output loop call getOutput()
+ Mockito.when(defer.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) defer);
+
+ Assertions.assertEquals(1, deltas.size());
+ Assertions.assertTrue(deltas.containsKey("2_2_2_2"),
+ "Delta key must use inner scan's table identifiers");
+ StatsDelta delta = deltas.get("2_2_2_2");
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ }
+
+ /**
+ * Plan: Filter(k1=1) → Scan[k1(id1)], root output = [k1].
+ * k1 appears in both the WHERE predicate and the SELECT output.
+ * Expected: k1.queryHit=true AND k1.filterHit=true simultaneously.
+ */
+ @Test
+ public void testColumnInBothSelectAndWhere() {
+ ExprId id1 = new ExprId(1);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot));
+
+ Expression conjunct = Mockito.mock(Expression.class);
+
Mockito.when(conjunct.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(conjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit,
"k1.queryHit must be true");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").filterHit,
"k1.filterHit must be true");
+ }
+
+ /**
+ * Plan has no OlapScan nodes: collectDeltas should return an empty map
without throwing.
+ */
+ @Test
+ public void testNoPlanNodesReturnsEmptyDeltas() {
+ PhysicalPlan leafPlan = Mockito.mock(PhysicalPlan.class);
+ Mockito.when(leafPlan.children()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas(leafPlan);
+
+ Assertions.assertTrue(deltas.isEmpty());
+ }
+
+ /**
+ * SlotReference whose getOriginalColumn() returns Optional.empty():
+ * no column entry must be added to the delta (no NPE, no phantom entry).
+ */
+ @Test
+ public void testSlotWithNoOriginalColumnIsSkipped() {
+ ExprId id1 = new ExprId(1);
+ SlotReference slotNoCol = Mockito.mock(SlotReference.class);
+ Mockito.when(slotNoCol.getExprId()).thenReturn(id1);
+
Mockito.when(slotNoCol.getOriginalColumn()).thenReturn(Optional.empty());
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(slotNoCol));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas(scan);
+
+ Assertions.assertEquals(1, deltas.size());
+
Assertions.assertTrue(deltas.values().iterator().next().getColumnStats().isEmpty(),
+ "No column stats should be recorded for a slot with no
originalColumn");
+ }
+
+ /**
+ * Filter with two conjuncts each referencing a different column:
+ * both columns must receive filterHit; neither must receive queryHit.
+ */
+ @Test
+ public void testMultipleConjunctsAllGetFilterHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ ExprId id3 = new ExprId(3);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ SlotReference k3Slot = mockSlot(id3, "k3");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
+ ImmutableList.of(k1Slot, k2Slot, k3Slot));
+
+ Expression conj1 = Mockito.mock(Expression.class);
+
Mockito.when(conj1.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+ Expression conj2 = Mockito.mock(Expression.class);
+
Mockito.when(conj2.getInputSlots()).thenReturn(ImmutableSet.of(k3Slot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(conj1,
conj2));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertTrue(delta.getColumnStats().get("k2").filterHit,
"k2.filterHit must be true");
+ Assertions.assertFalse(delta.getColumnStats().get("k2").queryHit,
"k2.queryHit must be false");
+ Assertions.assertTrue(delta.getColumnStats().get("k3").filterHit,
"k3.filterHit must be true");
+ Assertions.assertFalse(delta.getColumnStats().get("k3").queryHit,
"k3.queryHit must be false");
+ }
+
+ /**
+ * Two scans from different tables in one plan: each must produce an
independent
+ * StatsDelta with a distinct key, recording only its own table's columns.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testTwoDifferentTablesProduceSeparateDeltas() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+
+ PhysicalOlapScan scan1 = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot));
+ PhysicalOlapScan scan2 = mockScan(2L, 2L, 2L, 2L,
ImmutableList.of(k2Slot));
+
+ PhysicalPlan join = Mockito.mock(PhysicalPlan.class);
+ Mockito.when(join.children()).thenReturn(ImmutableList.of(scan1,
scan2));
+ Mockito.when(join.getOutput()).thenReturn(ImmutableList.of(k1Slot,
k2Slot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas(join);
+
+ Assertions.assertEquals(2, deltas.size(), "Each table must have its
own delta");
+ Assertions.assertTrue(deltas.containsKey("1_1_1_1"), "scan1 delta
missing");
+ Assertions.assertTrue(deltas.containsKey("2_2_2_2"), "scan2 delta
missing");
+
Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k1").queryHit);
+ Assertions.assertNull(deltas.get("1_1_1_1").getColumnStats().get("k2"),
+ "scan1 must not record scan2's column");
+
Assertions.assertTrue(deltas.get("2_2_2_2").getColumnStats().get("k2").queryHit);
+ Assertions.assertNull(deltas.get("2_2_2_2").getColumnStats().get("k1"),
+ "scan2 must not record scan1's column");
+ }
+
+ /**
+ * Scan whose getTable() returns null: getOrCreateDelta returns null and
+ * no stats are recorded — no NPE.
+ */
+ @Test
+ public void testNullTableInScanDoesNotCrash() {
+ ExprId id1 = new ExprId(1);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+
+ PhysicalOlapScan scan = Mockito.mock(PhysicalOlapScan.class);
+ Mockito.when(scan.getTable()).thenReturn(null);
+ Mockito.when(scan.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+ Mockito.when(scan.children()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas(scan);
+
+ Assertions.assertTrue(deltas.isEmpty(), "Null-table scan must not
create a delta");
+ }
+
+ // ── helpers
──────────────────────────────────────────────────────────────
+
+ private SlotReference mockSlot(ExprId exprId, String columnName) {
+ SlotReference slot = Mockito.mock(SlotReference.class);
+ Mockito.when(slot.getExprId()).thenReturn(exprId);
+ Column col = Mockito.mock(Column.class);
+ Mockito.when(col.getName()).thenReturn(columnName);
+ Mockito.when(slot.getOriginalColumn()).thenReturn(Optional.of(col));
+ return slot;
+ }
+
+ @SuppressWarnings("unchecked")
+ private PhysicalOlapScan mockScan(long catalogId, long dbId, long tableId,
long indexId,
+ List<Slot> outputSlots) {
+ PhysicalOlapScan scan = Mockito.mock(PhysicalOlapScan.class);
+ OlapTable table = Mockito.mock(OlapTable.class);
+ DatabaseIf<OlapTable> db = (DatabaseIf<OlapTable>)
Mockito.mock(DatabaseIf.class);
+ Mockito.when(table.getCatalogId()).thenReturn(catalogId);
+ Mockito.when(table.getId()).thenReturn(tableId);
+ Mockito.when(db.getId()).thenReturn(dbId);
+ Mockito.when(scan.getTable()).thenReturn(table);
+ Mockito.when(scan.getDatabase()).thenReturn(db);
+ Mockito.when(scan.getSelectedIndexId()).thenReturn(indexId);
+ Mockito.when(scan.getOutput()).thenReturn((List<Slot>) (List<?>)
outputSlots);
+ Mockito.when(scan.children()).thenReturn(ImmutableList.of());
+ return scan;
+ }
+}
diff --git a/regression-test/data/query_p0/stats/query_stats_test.out
b/regression-test/data/query_p0/stats/query_stats_test.out
deleted file mode 100644
index 984413ec2e0..00000000000
--- a/regression-test/data/query_p0/stats/query_stats_test.out
+++ /dev/null
@@ -1,52 +0,0 @@
--- This file is automatically generated. You should know what you did if you
want to edit this
--- !sql --
-k0 0 0
-k1 0 0
-k2 0 0
-k3 0 0
-k4 0 0
-k5 0 0
-k6 0 0
-k10 0 0
-k11 0 0
-k7 0 0
-k8 0 0
-k9 0 0
-k12 0 0
-k13 0 0
-
--- !sql --
-k0 0 0
-k1 0 0
-k2 0 0
-k3 0 0
-k4 0 0
-k5 0 0
-k6 0 0
-k10 0 0
-k11 0 0
-k7 0 0
-k8 0 0
-k9 0 0
-k12 0 0
-k13 0 0
-
--- !sql --
-stats_table 0
-
--- !sql --
-stats_table k0 0 0
- k1 0 0
- k2 0 0
- k3 0 0
- k4 0 0
- k5 0 0
- k6 0 0
- k10 0 0
- k11 0 0
- k7 0 0
- k8 0 0
- k9 0 0
- k12 0 0
- k13 0 0
-
diff --git a/regression-test/suites/query_p0/stats/query_stats_test.groovy
b/regression-test/suites/query_p0/stats/query_stats_test.groovy
index 492cca65899..aa9b7e5a0b6 100644
--- a/regression-test/suites/query_p0/stats/query_stats_test.groovy
+++ b/regression-test/suites/query_p0/stats/query_stats_test.groovy
@@ -38,19 +38,121 @@ suite("query_stats_test") {
) engine=olap
DISTRIBUTED BY HASH(`k1`) BUCKETS 1 properties("replication_num" = "1")
"""
- sql "admin set frontend config (\"enable_query_hit_stats\"=\"true\");"
+
+ def origNereids = sql("select @@enable_nereids_planner")[0][0]
+ def origCache = sql("select @@enable_query_cache")[0][0]
+
+ sql "admin set all frontends config (\"enable_query_hit_stats\"=\"true\");"
+ sql "set enable_nereids_planner = true"
+ sql "set enable_query_cache = false"
+
+ sql """INSERT INTO ${tbName} VALUES
+ (true, 1, 100, 1000, 10000, 123.456, 'A1234', '2024-01-01',
'2024-01-01 10:00:00', 'alpha', 1.23, 4.56, 'text1', 12345678901234567890),
+ (false, -5, 200, 2000, 20000, 234.567, 'B2345', '2024-02-02',
'2024-02-02 11:30:00', 'beta', 2.34, 5.67, 'text2', 22345678901234567890),
+ (true, 10, -300, 3000, 30000, 345.678, 'C3456', '2024-03-03',
'2024-03-03 12:45:00', 'gamma', 3.45, 6.78, 'text3', 32345678901234567890),
+ (NULL, 0, 0, 0, 0, 0.000, 'D4567', '2024-04-04',
'2024-04-04 00:00:00', 'delta', 0.00, 0.00, 'text4', 42345678901234567890),
+ (false, 127, 32767, 2147483647, 9223372036854775807, 999.999,
'E5678', '2024-05-05', '2024-05-05 23:59:59', 'epsilon', 9.99, 9.99, 'text5',
52345678901234567890)"""
+
sql "clean all query stats"
- explain {
- sql("select k1 from ${tbName} where k1 = 1")
- }
- qt_sql "show query stats from ${tbName}"
+ explain { sql("select k1 from ${tbName} where k1 = 1") }
+ def explainStats = sql "show query stats from ${tbName}"
+ for (row in explainStats) {
+ assertEquals(0, row[1] as int)
+ assertEquals(0, row[2] as int)
+ }
+ // Counts may exceed 1 in multi-FE clusters; verify right columns hit,
wrong ones stay 0.
sql "select k1 from ${tbName} where k0 = 1"
sql "select k4 from ${tbName} where k2 = 1991"
+ def stats1 = sql "show query stats from ${tbName}"
+ def s1k1 = stats1.find { it[0] == "k1" }
+ def s1k0 = stats1.find { it[0] == "k0" }
+ def s1k4 = stats1.find { it[0] == "k4" }
+ def s1k2 = stats1.find { it[0] == "k2" }
+ assertNotNull(s1k1)
+ assertNotNull(s1k0)
+ assertNotNull(s1k4)
+ assertNotNull(s1k2)
+ assertTrue((s1k1[1] as int) >= 1)
+ assertTrue((s1k0[2] as int) >= 1)
+ assertTrue((s1k4[1] as int) >= 1)
+ assertTrue((s1k2[2] as int) >= 1)
+ assertEquals(0, s1k1[2] as int)
+ assertEquals(0, s1k0[1] as int)
+ for (col in ["k3", "k5", "k6", "k10", "k11", "k7", "k8", "k9", "k12",
"k13"]) {
+ def row = stats1.find { it[0] == col }
+ assertNotNull(row)
+ assertEquals(0, row[1] as int)
+ assertEquals(0, row[2] as int)
+ }
+ def allStats1 = sql "show query stats from ${tbName} all"
+ assertTrue((allStats1[0][1] as int) >= 2)
+ def verboseStats1 = sql "show query stats from ${tbName} all verbose"
+ assertTrue(verboseStats1.size() > 0)
+
+ sql "clean all query stats"
+ sql "select k3 from ${tbName} where k5 = 1.0"
+ def stats2 = sql "show query stats from ${tbName}"
+ def s2k3 = stats2.find { it[0] == "k3" }
+ def s2k5 = stats2.find { it[0] == "k5" }
+ assertNotNull(s2k3)
+ assertNotNull(s2k5)
+ assertTrue((s2k3[1] as int) >= 1)
+ assertTrue((s2k5[2] as int) >= 1)
+ assertEquals(0, s2k3[2] as int)
+ assertEquals(0, s2k5[1] as int)
+ for (col in ["k0", "k1", "k2", "k4", "k6", "k10", "k11", "k7", "k8", "k9",
"k12", "k13"]) {
+ def row = stats2.find { it[0] == col }
+ assertNotNull(row)
+ assertEquals(0, row[1] as int)
+ assertEquals(0, row[2] as int)
+ }
+ def allStats2 = sql "show query stats from ${tbName} all"
+ assertTrue((allStats2[0][1] as int) >= 1)
+
+ // Column in both SELECT and WHERE.
+ sql "clean all query stats"
+ sql "select k1 from ${tbName} where k1 = 1"
+ def stats3 = sql "show query stats from ${tbName}"
+ def s3k1 = stats3.find { it[0] == "k1" }
+ assertNotNull(s3k1)
+ assertTrue((s3k1[1] as int) >= 1)
+ assertTrue((s3k1[2] as int) >= 1)
+ for (col in ["k0", "k2", "k3", "k4", "k5", "k6", "k10", "k11", "k7", "k8",
"k9", "k12", "k13"]) {
+ def row = stats3.find { it[0] == col }
+ assertNotNull(row)
+ assertEquals(0, row[1] as int)
+ assertEquals(0, row[2] as int)
+ }
+
+ // INSERT INTO ... SELECT must not record stats.
+ sql "clean all query stats"
+ sql "insert into ${tbName} select * from ${tbName} where k1 > 0"
+ def insertStats = sql "show query stats from ${tbName}"
+ for (row in insertStats) {
+ assertEquals(0, row[1] as int)
+ assertEquals(0, row[2] as int)
+ }
+
+ // Alias gap: k1.queryHit = 0 until Part 2 walks Project nodes.
+ sql "clean all query stats"
+ sql "select k1 as x from ${tbName} where k2 = 1"
+ def aliasResult = sql "show query stats from ${tbName}"
+ def arK1 = aliasResult.find { it[0] == "k1" }
+ def arK2 = aliasResult.find { it[0] == "k2" }
+ assertNotNull(arK1)
+ assertNotNull(arK2)
+ assertEquals(0, arK1[1] as int)
+ assertTrue((arK2[2] as int) >= 1)
+
+ // Self-join: StatsDelta dedup keeps table count = 1 per FE.
+ sql "clean all query stats"
+ sql "select a.k1 from ${tbName} a, ${tbName} b where a.k1 = b.k1"
+ def joinResult = sql "show query stats from ${tbName} all"
+ assertTrue((joinResult[0][1] as int) >= 1)
- qt_sql "show query stats from ${tbName}"
- qt_sql "show query stats from ${tbName} all"
- qt_sql "show query stats from ${tbName} all verbose"
- sql "admin set frontend config (\"enable_query_hit_stats\"=\"false\");"
+ sql "admin set all frontends config
(\"enable_query_hit_stats\"=\"false\");"
+ sql "set enable_nereids_planner = ${origNereids}"
+ sql "set enable_query_cache = ${origCache}"
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]