This is an automated email from the ASF dual-hosted git repository.
englefly 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 28c9f8d8705 [fix](fe) Keep deleted rows out of the simple-agg MIN/MAX
cache (#68990)
28c9f8d8705 is described below
commit 28c9f8d8705d761b8b70636a69d4d60be2d7c432
Author: minghong <[email protected]>
AuthorDate: Fri Oct 16 14:35:48 2026 +0800
[fix](fe) Keep deleted rows out of the simple-agg MIN/MAX cache (#68990)
### What problem does this PR solve?
Problem Summary:
A no-filter, no-GROUP BY `SELECT min(c), max(c) FROM t` on a
DUPLICATE-key table
is answered by `RewriteSimpleAggToConstantRule` from the
SimpleAggCacheMgr cache.
That cache ran its internal SQL on
`StatisticsUtil.buildConnectContext()`, the
session builder statistics collection uses, which turns on
`force_pushdown_zonemap_minmax` because statistics only needs an
approximate
bound.
With that switch on, the BE answers a pushed-down MIN/MAX from the
segment zone
maps even when a delete predicate removed rows, and a zone map still
covers what
that predicate filters out. So once a DELETEd row held the column's max,
the
cache stored that deleted value:
CREATE TABLE t (k VARCHAR(20) NULL, v DATETIME NULL) DUPLICATE KEY(k)
DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES ('replication_num' = '1');
INSERT INTO t VALUES ('a', '2022-04-27 16:00:33'), ('b', '2022-04-27
16:00:58');
INSERT INTO t VALUES ('1', '2024-01-01 00:00:00');
DELETE FROM t WHERE k = '1';
SELECT min(v), max(v) FROM t; -- 16:00:33 / 16:00:58: reads the rows and
-- kicks off the background cache load
SELECT min(v), max(v) FROM t; -- 16:00:33 / 2024-01-01 00:00:00 -- wrong
From the second query on the answer is a constant, so it stops depending
on the
table at all, and the deleted value is served until the table's version
changes,
the entry is evicted, or the FE restarts. Base compaction does not clear
it
either: the entry is validated on the table's visible version, which
compaction
does not change. Numeric and date/datetime columns are affected alike; a
string
column is not, because MIN/MAX is not cached for it. COUNT is not
affected:
MIN/MAX is the only aggregate the switch can force, so the BE keeps the
delete-predicate guard for COUNT with the switch on or off.
The cache promises exact values, so it must not inherit a switch that
exists to
accept approximate ones. It now builds its own session and sets only
what its
query needs, so a statistics-only switch cannot reach it again by
default. Both
loaders share that builder, which also drops the session setup they had
duplicated.
Kept deliberately, each for the query rather than for statistics:
- the page cache stays off, and the block file cache is disabled in
cloud mode: a
background scan of a whole column must not evict the pages user queries
are
reading, and its result outlives a user query by far once FE has cached
it;
- the SQL cache and query cache stay off: this cache answers from the
table's
current visible version, so a result cache must neither answer the query
nor be
filled by it;
- materialized view rewrite stays off: read the base table, not a
snapshot.
Dropped, because they are statistics' own tuning rather than the query's
shape:
the statistics memory and CPU budgets, the analyze timeout reused as the
query
and insert timeout, and the statistics parallel instance count.
### Release note
Fixed a wrong `min()`/`max()` result on a DUPLICATE-key table after a
`DELETE`.
A repeated no-filter `SELECT min(c), max(c)` could return a value that
only a
deleted row held, until the table's version changed or the FE was
restarted.
### Check List (For Author)
- Test: Regression test, Unit Test, Manual test
- Added `min_max_after_delete.groovy` to the existing
`nereids_rules_p0/rewrite_simple_agg_to_constant` suite. Its `.out` was
generated on the parent commit, where the rewritten query recorded the
deleted row's bounds (`2024-01-01T00:00`, `100`) while the rule-disabled
query returned the surviving rows' bounds; with this fix both record the
same, correct bounds.
- Ran that whole suite directory: 4 suites, 0 failures.
- Ran `RewriteSimpleAggToConstantRuleTest`: 15 tests, 0 failures.
- Manually confirmed on a cluster that after a DELETE the MIN/MAX
rewrite
still fires with the surviving rows' bound, that the COUNT rewrite still
fires and follows an INSERT, that the result holds across a base
compaction, and that a DELETE after the cache is warm reloads it.
- Behavior changed: Yes. The wrong `min()`/`max()` described above is no
longer
returned; correct results are unchanged and the constant rewrite still
applies.
The internal session of this cache no longer inherits statistics'
resource
budgets, so a load that no longer fits them (a default 900s query
timeout
instead of the 12h analyze timeout) times out and costs the rewrite
rather
than correctness.
- Does this need documentation: No
---
.../doris/nereids/stats/SimpleAggCacheMgr.java | 78 +++++++++++++---
.../min_max_after_delete.out | 7 ++
.../min_max_after_delete.groovy | 103 +++++++++++++++++++++
3 files changed, 174 insertions(+), 14 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/SimpleAggCacheMgr.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/SimpleAggCacheMgr.java
index 290c65322a0..8b31247a158 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/SimpleAggCacheMgr.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/SimpleAggCacheMgr.java
@@ -17,13 +17,18 @@
package org.apache.doris.nereids.stats;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.TableIf;
+import org.apache.doris.cloud.qe.ComputeGroupException;
import org.apache.doris.common.Config;
+import org.apache.doris.common.FeConstants;
import org.apache.doris.common.ThreadPoolManager;
import org.apache.doris.qe.AutoCloseConnectContext;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
import org.apache.doris.qe.StmtExecutor;
import org.apache.doris.statistics.repository.ResultRow;
import org.apache.doris.statistics.util.StatisticsUtil;
@@ -404,6 +409,63 @@ public class SimpleAggCacheMgr {
return "SELECT count(*) FROM " + fullTable;
}
+ /**
+ * Builds the session that the internal SQL of this cache runs on.
+ *
+ * <p>Deliberately not {@code StatisticsUtil.buildConnectContext()}: that
one is shaped for
+ * statistics collection, which only needs an approximate MIN/MAX and
therefore turns on
+ * {@code force_pushdown_zonemap_minmax}. With that on, the BE answers a
pushed-down MIN/MAX from
+ * the segment zone maps even when a delete predicate removed rows, and a
zone map still covers
+ * what that predicate filters out. The cache would then store a deleted
row's bound as the exact
+ * value and hand it to {@code RewriteSimpleAggToConstantRule}.
+ *
+ * <p>So this builder starts from a default {@link SessionVariable} and
sets only what the query
+ * itself needs. Nothing here may trade accuracy for speed.
+ */
+ private static AutoCloseConnectContext buildConnectContext() {
+ ConnectContext connectContext = new ConnectContext();
+ connectContext.getState().setInternal(true);
+ connectContext.setEnv(Env.getCurrentEnv());
+ connectContext.setCurrentUserIdentity(UserIdentity.ADMIN);
+ connectContext.setStartTime();
+ // The internal SQL names its table fully qualified; this only gives
the context a database.
+ connectContext.setDatabase(FeConstants.INTERNAL_DB_NAME);
+ SessionVariable sessionVariable = connectContext.getSessionVariable();
+ // A load is one aggregate over one table, so a single pipeline task
is enough.
+ sessionVariable.parallelPipelineTaskNum = 1;
+ // Disable our own rule to prevent infinite recursion:
+ // this internal SQL goes through Nereids and would otherwise trigger
+ // RewriteSimpleAggToConstantRule again.
+
sessionVariable.setDisableNereidsRules("REWRITE_SIMPLE_AGG_TO_CONSTANT");
+ // Statistics collection turns this on to accept approximate bounds.
This cache promises
+ // exact ones, so a delete predicate has to keep MIN/MAX off the zone
maps. COUNT keeps the
+ // BE's delete-predicate guard whether the switch is on or off.
+ sessionVariable.forcePushDownZonemapMinMax = false;
+ // The cache exists to answer from the table's current visible
version. A result cache keyed
+ // on a session-variable signature must neither answer this query nor
be filled by it.
+ sessionVariable.enableSqlCache = false;
+ sessionVariable.enableQueryCache = false;
+ // Read the base table: a materialized view answers from its own
snapshot.
+ sessionVariable.enableMaterializedViewRewrite = false;
+ // A background scan of a whole column must not evict the pages user
queries are reading, and
+ // this query's result outlives a user query by far once it is cached
in FE, so it reads
+ // storage directly instead of through a block cache. disableFileCache
only takes effect in
+ // cloud mode; elsewhere the block file cache is off regardless.
+ sessionVariable.enablePageCache = false;
+ sessionVariable.disableFileCache = true;
+ if (Config.isCloudMode()) {
+ try {
+ connectContext.getCloudCluster();
+ } catch (ComputeGroupException e) {
+ // Best effort, the way statistics collection treats it: the
planner resolves the
+ // compute group again if this fails, and a load that cannot
run costs the rewrite
+ // rather than correctness.
+ LOG.warn("failed to resolve the compute group of an internal
min/max query", e);
+ }
+ }
+ return new AutoCloseConnectContext(connectContext);
+ }
+
/**
* Async cache loader that issues internal SQL queries to compute exact
min/max.
*/
@@ -454,13 +516,7 @@ public class SimpleAggCacheMgr {
String sql = genMinMaxSql(qualifiers, column.getName());
List<ResultRow> rows;
- try (AutoCloseConnectContext r =
StatisticsUtil.buildConnectContext(false)) {
- r.connectContext.getSessionVariable().setPipelineTaskNum("1");
- // Disable our own rule to prevent infinite recursion:
- // this internal SQL goes through Nereids and would otherwise
trigger
- // RewriteSimpleAggToConstantRule again.
- r.connectContext.getSessionVariable().setDisableNereidsRules(
- "REWRITE_SIMPLE_AGG_TO_CONSTANT");
+ try (AutoCloseConnectContext r = buildConnectContext()) {
StmtExecutor stmtExecutor = new StmtExecutor(r.connectContext,
sql);
rows = stmtExecutor.executeInternalQuery();
}
@@ -523,13 +579,7 @@ public class SimpleAggCacheMgr {
String sql = genCountSql(qualifiers);
List<ResultRow> rows;
- try (AutoCloseConnectContext r =
StatisticsUtil.buildConnectContext(false)) {
- r.connectContext.getSessionVariable().setPipelineTaskNum("1");
- // Disable our own rule to prevent infinite recursion:
- // this internal SQL goes through Nereids and would otherwise
trigger
- // RewriteSimpleAggToConstantRule again.
- r.connectContext.getSessionVariable().setDisableNereidsRules(
- "REWRITE_SIMPLE_AGG_TO_CONSTANT");
+ try (AutoCloseConnectContext r = buildConnectContext()) {
StmtExecutor stmtExecutor = new StmtExecutor(r.connectContext,
sql);
rows = stmtExecutor.executeInternalQuery();
}
diff --git
a/regression-test/data/nereids_rules_p0/rewrite_simple_agg_to_constant/min_max_after_delete.out
b/regression-test/data/nereids_rules_p0/rewrite_simple_agg_to_constant/min_max_after_delete.out
new file mode 100644
index 00000000000..f857b0d4607
--- /dev/null
+++
b/regression-test/data/nereids_rules_p0/rewrite_simple_agg_to_constant/min_max_after_delete.out
@@ -0,0 +1,7 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !min_max_after_delete --
+2022-04-27T16:00:33 2022-04-27T16:00:58 1 2
+
+-- !min_max_after_delete_rule_off --
+2022-04-27T16:00:33 2022-04-27T16:00:58 1 2
+
diff --git
a/regression-test/suites/nereids_rules_p0/rewrite_simple_agg_to_constant/min_max_after_delete.groovy
b/regression-test/suites/nereids_rules_p0/rewrite_simple_agg_to_constant/min_max_after_delete.groovy
new file mode 100644
index 00000000000..035f2cb34ed
--- /dev/null
+++
b/regression-test/suites/nereids_rules_p0/rewrite_simple_agg_to_constant/min_max_after_delete.groovy
@@ -0,0 +1,103 @@
+// 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.
+
+// A no-filter MIN/MAX is rewritten to a constant from SimpleAggCacheMgr. That
cache runs its
+// internal SQL on the statistics connect context, which forces MIN/MAX onto
the segment zone maps.
+// A zone map still covers the rows a delete predicate filters out, so the
cache used to store the
+// max of a row that had been DELETEd and the rewrite handed that value back
to the user.
+suite("min_max_after_delete") {
+ // The rewrite is only reached when the plan is built, so the SQL result
cache would answer the
+ // later queries without ever consulting SimpleAggCacheMgr.
+ sql "SET enable_sql_cache=false"
+
+ sql "DROP DATABASE IF EXISTS test_min_max_after_delete"
+ sql "CREATE DATABASE test_min_max_after_delete"
+ sql "USE test_min_max_after_delete"
+
+ // disable_auto_compaction is what preserves the fault this test is about.
The cached bound is
+ // only wrong while the deleted row is still on disk behind its delete
predicate, so that the
+ // segment zone map still holds it. Auto compaction is on by default, and
a DELETE alone is
+ // enough to make cumulative compaction merge the preceding rowsets and
base compaction apply
+ // the predicate. If that wins the race against the async cache load, the
unfixed code answers
+ // correctly too and this test would pass without covering anything.
+ sql """
+ CREATE TABLE dup_min_max_deleted (
+ k VARCHAR(20) NULL,
+ d DATETIME NULL,
+ n BIGINT NULL
+ ) DUPLICATE KEY(k)
+ DISTRIBUTED BY HASH(k) BUCKETS 1
+ PROPERTIES(
+ 'replication_num' = '1',
+ 'disable_auto_compaction' = 'true'
+ )
+ """
+
+ sql """INSERT INTO dup_min_max_deleted VALUES ('keep_a', '2022-04-27
16:00:33', 1)"""
+ sql """INSERT INTO dup_min_max_deleted VALUES ('keep_b', '2022-04-27
16:00:58', 2)"""
+ // Holds both the max datetime and the max integer, and is deleted right
below. DELETE leaves it
+ // as a delete predicate, so its rows are still on disk behind that
predicate.
+ sql """INSERT INTO dup_min_max_deleted VALUES ('deleted_row', '2024-01-01
00:00:00', 100)"""
+ sql """DELETE FROM dup_min_max_deleted WHERE k = 'deleted_row'"""
+
+ // ===================================================================
+ // Warm up the SimpleAggCacheMgr async cache.
+ //
+ // The first call to getStats() only triggers an async load. Poll until
+ // explain shows "constant exprs", which proves the cache entry -- the
+ // one that used to hold the deleted row's bound -- is in place.
+ //
+ // The query must push down MIN/MAX alone. Adding count(*) would make it
+ // a MIX push-down, which the BE keeps behind its delete-predicate guard,
+ // and the bug would not be exercised.
+ // ===================================================================
+ sql "SELECT min(d), max(d) FROM dup_min_max_deleted"
+ sql "SELECT min(n), max(n) FROM dup_min_max_deleted"
+
+ def minMaxSql = "SELECT min(d), max(d), min(n), max(n) FROM
dup_min_max_deleted"
+ def cacheReady = false
+ for (int i = 0; i < 30; i++) {
+ def explainResult = sql "EXPLAIN ${minMaxSql}"
+ if (explainResult.toString().contains("constant exprs")) {
+ cacheReady = true
+ break
+ }
+ sleep(1000)
+ }
+ if (!cacheReady) {
+ if (isCloudMode()) {
+ logger.info("SimpleAggCacheMgr cache did not warm up within 30s in
cloud mode, skip")
+ return
+ }
+ assertTrue(false, "SimpleAggCacheMgr cache did not warm up within 30
seconds")
+ }
+
+ // The deleted row must not contribute to the answer, neither through the
constant rewrite nor
+ // through the rows the BE reads. Both values below come from the two
surviving rows.
+ explain {
+ sql("${minMaxSql}")
+ contains "constant exprs"
+ }
+ order_qt_min_max_after_delete """SELECT min(d), max(d), min(n), max(n)
FROM dup_min_max_deleted"""
+
+ // A delete predicate has to keep the pushed-down MIN/MAX off the zone
maps, so disabling the
+ // rewrite must agree with the rewritten answer above.
+ order_qt_min_max_after_delete_rule_off """
+ SELECT /*+
SET_VAR(disable_nereids_rules=REWRITE_SIMPLE_AGG_TO_CONSTANT) */
+ min(d), max(d), min(n), max(n) FROM dup_min_max_deleted
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]