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 dfee70e8a3f branch-4.1: [fix](preagg) Enable pre-aggregation for 
multi-argument aggregate functions with all-key distinct inputs #65846 (#66676)
dfee70e8a3f is described below

commit dfee70e8a3fa818d8cd2ecff19bda3b0627026c0
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Aug 13 18:39:44 2026 +0800

    branch-4.1: [fix](preagg) Enable pre-aggregation for multi-argument 
aggregate functions with all-key distinct inputs #65846 (#66676)
    
    Cherry-picked from #65846
    
    Co-authored-by: starocean999 <[email protected]>
---
 .../nereids/rules/rewrite/SetPreAggStatus.java     | 384 +++++++--
 .../nereids/rules/rewrite/SetPreAggStatusTest.java | 355 +++++++++
 .../nereids_rules_p0/set_preagg/set_preagg.out     | 148 ++++
 .../nereids_rules_p0/set_preagg/set_preagg.groovy  | 887 ++++++++++++++++++++-
 4 files changed, 1716 insertions(+), 58 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
index aef5f660056..f81b6159200 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
@@ -21,13 +21,16 @@ import org.apache.doris.catalog.AggregateType;
 import org.apache.doris.catalog.KeysType;
 import org.apache.doris.catalog.MaterializedIndexMeta;
 import org.apache.doris.common.Pair;
+import org.apache.doris.nereids.exceptions.AnalysisException;
 import org.apache.doris.nereids.jobs.JobContext;
 import org.apache.doris.nereids.trees.expressions.CaseWhen;
 import org.apache.doris.nereids.trees.expressions.Cast;
 import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
 import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
 import org.apache.doris.nereids.trees.expressions.WhenClause;
+import 
org.apache.doris.nereids.trees.expressions.functions.NoneMovableFunction;
 import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
 import org.apache.doris.nereids.trees.expressions.functions.agg.BitmapUnion;
 import 
org.apache.doris.nereids.trees.expressions.functions.agg.BitmapUnionCount;
@@ -91,11 +94,67 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
         private List<Expression> groupingScalarFunctionExpresssions = new 
ArrayList<>();
         private Set<AggregateFunction> aggregateFunctions = new HashSet<>();
         private Set<RelationId> olapScanIds = new HashSet<>();
+        private boolean hasUnresolvedExpression = false;
+
+        // Scans on the selected side of an ASOF join. ASOF emits at most one
+        // matching row per probe row (the index may pick any equal-time row), 
so
+        // the selected side must stay storage-merged (pre-agg OFF): with 
pre-agg
+        // ON, duplicate full keys remain as partial rows and a stale partial 
can
+        // be picked instead of the merged value, changing MAX/MIN.
+        private Set<RelationId> asofSelectedSideRelationIds = new HashSet<>();
+
+        // Retained non-movable project expressions (e.g. assert_true). They 
are
+        // kept by pruneOutputs even when unused, and must run on 
storage-merged
+        // rows: a pre-agg ON scan would evaluate them on raw partial rows. Any
+        // scan whose value columns they reference must stay OFF.
+        private List<Expression> retainedNonMovableExpressions = new 
ArrayList<>();
 
         private Map<Slot, Expression> replaceMap = new HashMap<>();
 
-        private void setReplaceMap(Map<Slot, Expression> replaceMap) {
-            this.replaceMap = replaceMap;
+        private void setReplaceMap(Map<Slot, Expression> newReplaceMap) {
+            // merge instead of replace: sibling projects under a join share 
one
+            // PreAggInfoContext, and a full replacement would lose mappings 
from
+            // the sibling. merge keeps all entries; new entries shadow old 
ones
+            // so a chain of projects still resolves correctly.
+            //
+            // Before merging, resolve the new aliases' producers through the
+            // existing replaceMap so that upper-layer aliases reference base 
table
+            // columns directly instead of intermediate computed aliases.
+            // Resolve only the new entries into a small temp map against the
+            // unchanged old map, then putAll them to keep accumulation linear.
+            if (newReplaceMap.isEmpty()) {
+                return;
+            }
+            Map<Slot, Expression> resolved = new HashMap<>(
+                    com.google.common.collect.Maps.newHashMapWithExpectedSize(
+                            newReplaceMap.size()));
+            for (Map.Entry<Slot, Expression> entry : newReplaceMap.entrySet()) 
{
+                Expression resolvedProducer;
+                try {
+                    resolvedProducer = ExpressionUtils.replace(
+                            entry.getValue(), this.replaceMap);
+                } catch (AnalysisException e) {
+                    if (e.getErrorCode() == 
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+                        // Eager composition hit the depth/width limit (e.g. 
deep
+                        // xN = x(N-1) + x(N-1) chains expand width 
exponentially).
+                        // Keep the raw producer so the query still plans, but 
mark
+                        // the expression unresolved: the raw producer still
+                        // references intermediate aliases (not base columns), 
so a
+                        // later replace is top-down short-circuit and never 
expands
+                        // them. Without the flag, an aggregate over such an 
alias
+                        // would have an empty local slot intersection and be
+                        // whitelisted as an other-table MAX/MIN, wrongly 
turning
+                        // this scan ON (e.g. a SUM-origin chain whose value 
differs
+                        // between raw and merged duplicate full keys).
+                        resolvedProducer = entry.getValue();
+                        hasUnresolvedExpression = true;
+                    } else {
+                        throw e;
+                    }
+                }
+                resolved.put(entry.getKey(), resolvedProducer);
+            }
+            this.replaceMap.putAll(resolved);
         }
 
         private void addRelationId(RelationId id) {
@@ -104,18 +163,45 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
 
         private void addJoinInfo(LogicalJoin logicalJoin) {
             joinConjuncts.addAll(logicalJoin.getExpressions());
-            joinConjuncts = 
Lists.newArrayList(ExpressionUtils.replace(joinConjuncts, replaceMap));
+            try {
+                joinConjuncts = Lists.newArrayList(
+                        ExpressionUtils.replace(joinConjuncts, replaceMap));
+            } catch (AnalysisException e) {
+                if (e.getErrorCode() == 
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+                    hasUnresolvedExpression = true;
+                } else {
+                    throw e;
+                }
+            }
         }
 
         private void addFilterConjuncts(List<Expression> conjuncts) {
             filterConjuncts.addAll(conjuncts);
-            filterConjuncts = 
Lists.newArrayList(ExpressionUtils.replace(filterConjuncts, replaceMap));
+            try {
+                filterConjuncts = Lists.newArrayList(
+                        ExpressionUtils.replace(filterConjuncts, replaceMap));
+            } catch (AnalysisException e) {
+                if (e.getErrorCode() == 
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+                    hasUnresolvedExpression = true;
+                } else {
+                    throw e;
+                }
+            }
         }
 
         private void addGroupByExpresssions(List<Expression> expressions) {
             groupByExpresssions.addAll(expressions);
             groupByExpresssions.removeAll(groupingScalarFunctionExpresssions);
-            groupByExpresssions = 
Lists.newArrayList(ExpressionUtils.replace(groupByExpresssions, replaceMap));
+            try {
+                groupByExpresssions = Lists.newArrayList(
+                        ExpressionUtils.replace(groupByExpresssions, 
replaceMap));
+            } catch (AnalysisException e) {
+                if (e.getErrorCode() == 
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+                    hasUnresolvedExpression = true;
+                } else {
+                    throw e;
+                }
+            }
         }
 
         private void addGroupingScalarFunctionExpresssions(List<Expression> 
expressions) {
@@ -126,12 +212,33 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
             groupingScalarFunctionExpresssions.add(expression);
         }
 
+        private void addRetainedNonMovableExpression(Expression expr) {
+            try {
+                
retainedNonMovableExpressions.add(ExpressionUtils.replace(expr, replaceMap));
+            } catch (AnalysisException e) {
+                if (e.getErrorCode() == 
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+                    hasUnresolvedExpression = true;
+                } else {
+                    throw e;
+                }
+            }
+        }
+
         private void addAggregateFunctions(Set<AggregateFunction> functions) {
             aggregateFunctions.addAll(functions);
             Set<AggregateFunction> newAggregateFunctions = Sets.newHashSet();
             for (AggregateFunction aggregateFunction : aggregateFunctions) {
-                newAggregateFunctions
-                        .add((AggregateFunction) 
ExpressionUtils.replace(aggregateFunction, replaceMap));
+                try {
+                    newAggregateFunctions
+                            .add((AggregateFunction) ExpressionUtils.replace(
+                                    aggregateFunction, replaceMap));
+                } catch (AnalysisException e) {
+                    if (e.getErrorCode() == 
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+                        hasUnresolvedExpression = true;
+                    } else {
+                        throw e;
+                    }
+                }
             }
             aggregateFunctions = newAggregateFunctions;
         }
@@ -193,6 +300,19 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
         LogicalJoin plan = (LogicalJoin) super.visit(logicalJoin, context);
         if (!context.empty() && context.peek() != null) {
             context.peek().addJoinInfo(plan);
+            if (plan.getJoinType().isAsofJoin()) {
+                // ASOF join keeps at most one matching row per probe row, and 
the
+                // ASOF index may pick any equal-time row. If the selected 
side is
+                // pre-agg ON, duplicate full keys stay as partial rows and a 
stale
+                // partial (e.g. an older v9=100) could be picked instead of 
the
+                // storage-merged value (200), changing MAX/MIN. Force the 
selected
+                // side OFF so storage merges its columns first.
+                Plan selectedSide = plan.getJoinType().isAsofLeftJoin() ? 
plan.right() : plan.left();
+                for (LogicalOlapScan scan : selectedSide
+                        
.<LogicalOlapScan>collect(LogicalOlapScan.class::isInstance)) {
+                    
context.peek().asofSelectedSideRelationIds.add(scan.getRelationId());
+                }
+            }
         }
         return plan;
     }
@@ -200,9 +320,18 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
     @Override
     public Plan visitLogicalProject(LogicalProject<? extends Plan> 
logicalProject,
             Stack<PreAggInfoContext> context) {
-        LogicalProject plan = (LogicalProject) super.visit(logicalProject, 
context);
+        LogicalProject<?> plan = (LogicalProject) super.visit(logicalProject, 
context);
         if (!context.empty() && context.peek() != null) {
             context.peek().setReplaceMap(plan.getAliasToProducer());
+            // Track retained non-movable project expressions (e.g. 
assert_true):
+            // pruneOutputs keeps them even when unused, and they must run on
+            // storage-merged rows. If pre-agg turns a scan ON, these would be
+            // evaluated on raw partial rows, so keep affected scans OFF.
+            for (NamedExpression output : plan.getOutputs()) {
+                if (output.containsType(NoneMovableFunction.class)) {
+                    context.peek().addRetainedNonMovableExpression(output);
+                }
+            }
         }
         return plan;
     }
@@ -256,7 +385,15 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
                 PreAggStatus preAggStatus = PreAggStatus.off("No valid 
aggregate on scan.");
                 PreAggInfoContext preAggInfoContext = 
context.get(olapScan.getRelationId());
                 if (preAggInfoContext != null) {
-                    preAggStatus = createPreAggStatus(olapScan, 
preAggInfoContext);
+                    if 
(preAggInfoContext.asofSelectedSideRelationIds.contains(olapScan.getRelationId()))
 {
+                        // ASOF join's one-row selection does not commute with
+                        // exposing this scan's partial (unmerged) rows: 
storage
+                        // must merge first so the picked row is the merged 
value.
+                        preAggStatus = PreAggStatus.off(
+                                "can't turn preAgg on because the scan is the 
selected side of an ASOF join");
+                    } else {
+                        preAggStatus = createPreAggStatus(olapScan, 
preAggInfoContext);
+                    }
                 }
                 return olapScan.withPreAggStatus(preAggStatus);
             } else {
@@ -265,6 +402,9 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
         }
 
         private PreAggStatus createPreAggStatus(LogicalOlapScan 
logicalOlapScan, PreAggInfoContext context) {
+            if (context.hasUnresolvedExpression) {
+                return PreAggStatus.off("Expression exceeds limit, can't 
determine preAgg status.");
+            }
             List<Expression> filterConjuncts = context.filterConjuncts;
             List<Expression> joinConjuncts = context.joinConjuncts;
             Set<AggregateFunction> aggregateFuncs = context.aggregateFunctions;
@@ -294,6 +434,65 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
                 return PreAggStatus.off(String.format("Join conjuncts %s 
contains non-key column %s",
                         joinConjuncts, joinInputSlots));
             }
+
+            // Retained non-movable project expressions (e.g. assert_true) must
+            // run on storage-merged rows: with pre-agg ON they would be 
evaluated
+            // on raw partial rows. Keep any scan whose value columns they 
read OFF.
+            //
+            // Slotless volatile retained outputs (e.g. assert_true(random() > 
0.5))
+            // have no input slots, so the value-slot fence above misses them, 
and
+            // the volatility checks below only cover agg/filter/join/grouping
+            // expressions. PREAGG ON would then evaluate the volatile 
expression
+            // once per raw partial row instead of once per storage-merged row 
—
+            // a different evaluation cardinality that can change its result — 
so
+            // keep the scan OFF here as well.
+            for (Expression retained : context.retainedNonMovableExpressions) {
+                if (retained.containsVolatileExpression()) {
+                    return PreAggStatus.off(String.format(
+                            "retained non-movable expression %s contains 
volatile expression",
+                            retained));
+                }
+                if (!Sets.intersection(retained.getInputSlots(), 
valueSlots).isEmpty()) {
+                    return PreAggStatus.off(String.format(
+                            "retained non-movable expression %s references 
non-key column %s",
+                            retained, valueSlots));
+                }
+            }
+
+            // Row-stability check: volatile expressions evaluated per partial 
row
+            // produce different results than per merged logical row, even when
+            // their input slots are all key columns or empty. Check centrally
+            // before per-scan candidate filtering so the guard also covers
+            // other-table aggregates, slot-less filters, joins, and grouping.
+            for (AggregateFunction aggFunc : aggregateFuncs) {
+                if (aggFunc.containsVolatileExpression()) {
+                    return PreAggStatus.off(
+                            String.format("aggregate function %s contains 
volatile expression",
+                                    aggFunc));
+                }
+            }
+            for (Expression conjunct : filterConjuncts) {
+                if (conjunct.containsVolatileExpression()) {
+                    return PreAggStatus.off(
+                            String.format("filter conjunct %s contains 
volatile expression",
+                                    conjunct));
+                }
+            }
+            for (Expression conjunct : joinConjuncts) {
+                if (conjunct.containsVolatileExpression()) {
+                    return PreAggStatus.off(
+                            String.format("join conjunct %s contains volatile 
expression",
+                                    conjunct));
+                }
+            }
+            for (Expression expr : groupingExprs) {
+                if (expr.containsVolatileExpression()) {
+                    return PreAggStatus.off(
+                            String.format("grouping expression %s contains 
volatile expression",
+                                    expr));
+                }
+            }
+
             Set<AggregateFunction> candidateAggFuncs = Sets.newHashSet();
             for (AggregateFunction aggregateFunction : aggregateFuncs) {
                 if (!Sets.intersection(aggregateFunction.getInputSlots(), 
outputSlots).isEmpty()) {
@@ -315,39 +514,60 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
                 return !aggregateFuncs.isEmpty() || !groupingExprs.isEmpty() ? 
PreAggStatus.on()
                         : PreAggStatus.off("No aggregate on scan.");
             } else {
-                return checkAggregateFunctions(candidateAggFuncs, 
candidateGroupByInputSlots);
+                return checkAggregateFunctions(candidateAggFuncs, 
candidateGroupByInputSlots, outputSlots);
             }
         }
 
         private PreAggStatus checkAggregateFunctions(Set<AggregateFunction> 
aggregateFuncs,
-                Set<Slot> groupingExprsInputSlots) {
+                Set<Slot> groupingExprsInputSlots, Set<Slot> outputSlots) {
             if (aggregateFuncs.isEmpty() && groupingExprsInputSlots.isEmpty()) 
{
                 return PreAggStatus.off("No aggregate on scan.");
             }
             PreAggStatus preAggStatus = PreAggStatus.on();
             for (AggregateFunction aggFunc : aggregateFuncs) {
-                if (aggFunc.children().isEmpty()) {
-                    preAggStatus = PreAggStatus.off(
-                            String.format("can't turn preAgg on for aggregate 
function %s", aggFunc));
-                } else if (aggFunc.children().size() == 1 && aggFunc.child(0) 
instanceof Slot) {
-                    Slot aggSlot = (Slot) aggFunc.child(0);
-                    if (aggSlot instanceof SlotReference
-                            && ((SlotReference) 
aggSlot).getOriginalColumn().isPresent()) {
-                        if (((SlotReference) 
aggSlot).getOriginalColumn().get().isKey()) {
-                            preAggStatus = 
OneKeySlotAggChecker.INSTANCE.check(aggFunc);
-                        } else {
+                // candidateAggFuncs only contains functions whose input slots
+                // intersect outputSlots (see createPreAggStatus), so aggSlots 
is
+                // never empty and splitKeyValueSlots always inspects real 
slots.
+                Set<Slot> aggSlots = Sets.intersection(
+                        aggFunc.getInputSlots(), outputSlots);
+                Pair<Set<SlotReference>, Set<SlotReference>> splitSlots = 
splitKeyValueSlots(aggSlots);
+                if (splitSlots.first.isEmpty()) {
+                    // only value slots
+                    Expression valueChild = aggFunc.child(0);
+                    if (aggFunc.children().size() == 1 && valueChild 
instanceof SlotReference) {
+                        SlotReference slotRef = (SlotReference) valueChild;
+                        if (slotRef.getOriginalColumn().isPresent()) {
                             preAggStatus = 
OneValueSlotAggChecker.INSTANCE.check(aggFunc,
-                                    ((SlotReference) 
aggSlot).getOriginalColumn().get().getAggregationType());
+                                    
slotRef.getOriginalColumn().get().getAggregationType());
+                        } else {
+                            preAggStatus = PreAggStatus.off(
+                                    String.format("can't turn preAgg on for 
aggregate function %s", aggFunc));
                         }
+                    } else if (aggFunc.children().size() == 1
+                            && (valueChild instanceof If || valueChild 
instanceof CaseWhen)) {
+                        // IF/CaseWhen: the condition references only key 
columns (foreign or
+                        // local), stable across this scan's partial rows.  
The return
+                        // expressions reference local value columns validated 
below.
+                        // Reuse checkAggWithKeyAndValueSlots: the global 
condition
+                        // check (step 2) accepts foreign key columns.
+                        preAggStatus = checkAggWithKeyAndValueSlots(aggFunc, 
outputSlots);
                     } else {
                         preAggStatus = PreAggStatus.off(
-                                String.format("aggregate function %s use 
unknown slot %s from scan",
-                                        aggFunc, aggSlot));
+                                String.format("can't turn preAgg on for 
aggregate function %s", aggFunc));
                     }
+                } else if (splitSlots.second.isEmpty()) {
+                    // only key slots
+                    preAggStatus = KeySlotAggChecker.INSTANCE.check(aggFunc);
                 } else {
-                    Set<Slot> aggSlots = aggFunc.getInputSlots();
-                    Pair<Set<SlotReference>, Set<SlotReference>> splitSlots = 
splitKeyValueSlots(aggSlots);
-                    preAggStatus = checkAggWithKeyAndValueSlots(aggFunc, 
splitSlots.first, splitSlots.second);
+                    // checkAggWithKeyAndValueSlots only inspects child(0) for 
IF/CaseWhen patterns.
+                    // For multi-argument aggregate functions, child(0) 
inspection is insufficient
+                    // as later arguments may contain value columns that are 
not validated.
+                    if (aggFunc.children().size() > 1) {
+                        preAggStatus = PreAggStatus.off(
+                                String.format("can't turn preAgg on for 
aggregate function %s", aggFunc));
+                    } else {
+                        preAggStatus = checkAggWithKeyAndValueSlots(aggFunc, 
outputSlots);
+                    }
                 }
                 if (preAggStatus.isOff()) {
                     return preAggStatus;
@@ -371,41 +591,93 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
             return Pair.of(keySlots, valueSlots);
         }
 
-        private PreAggStatus checkAggWithKeyAndValueSlots(AggregateFunction 
aggFunc,
-                Set<SlotReference> keySlots, Set<SlotReference> valueSlots) {
+        private PreAggStatus checkAggWithKeyAndValueSlots(AggregateFunction 
aggFunc, Set<Slot> outputSlots) {
             Expression child = aggFunc.child(0);
             List<Expression> conditionExps = new ArrayList<>();
             List<Expression> returnExps = new ArrayList<>();
 
-            // ignore cast
-            while (child instanceof Cast) {
-                if (!((Cast) child).getDataType().isNumericType()) {
-                    return PreAggStatus.off(String.format("%s is not numeric 
CAST.", child.toSql()));
-                }
-                child = child.child(0);
-            }
-            // step 1: extract all condition exprs and return exprs
+            // No cast is peeled for MAX/MIN: DOUBLE/DECIMAL→FLOAT can 
underflow
+            // to -0.0 and change the observable tie representative (signed 
zero)
+            // under MAX/MIN, so even nondecreasing casts are not MAX/MIN
+            // homomorphisms. Any remaining cast is rejected below / by the
+            // checker, keeping pre-agg conservatively OFF.
+            // Reject remaining cast.
+            if (child instanceof Cast) {
+                return PreAggStatus.off(String.format("%s is not supported.", 
child.toSql()));
+            }
+            // step 1: extract all condition exprs and return exprs.
+            // child is guaranteed to be Cast-free here (rejected above), but
+            // individual IF/CaseWhen return expressions may still have their
+            // own Cast wrappers. Only strip those for MAX/MIN: sum(cast(x))
+            // and cast(sum(x)) are not interchangeable due to overflow.
             if (child instanceof If) {
                 conditionExps.add(child.child(0));
-                returnExps.add(removeCast(child.child(1)));
-                returnExps.add(removeCast(child.child(2)));
+                returnExps.add(child.child(1));
+                returnExps.add(child.child(2));
             } else if (child instanceof CaseWhen) {
                 CaseWhen caseWhen = (CaseWhen) child;
                 // WHEN THEN
                 for (WhenClause whenClause : caseWhen.getWhenClauses()) {
                     conditionExps.add(whenClause.getOperand());
-                    returnExps.add(removeCast(whenClause.getResult()));
+                    returnExps.add(whenClause.getResult());
                 }
                 // ELSE
-                
returnExps.add(removeCast(caseWhen.getDefaultValue().orElse(new 
NullLiteral())));
+                returnExps.add(caseWhen.getDefaultValue().orElse(new 
NullLiteral()));
             } else {
-                // currently, only IF and CASE WHEN are supported
-                returnExps.add(removeCast(child));
+                // Non-IF/CASE — conditionExps stays empty and returns OFF 
below.
+                returnExps.add(child);
+            }
+
+            // step 1.5: ownership — every return expression must reference 
only
+            // this scan's own columns. PREAGG ON exposes this scan's partial
+            // (unmerged) rows; under join fan-out a return that references a
+            // foreign value column would then be evaluated once per partial 
row
+            // and double-counted. So a foreign slot (value or key) in any 
return
+            // forces this scan OFF — never use a foreign column to justify ON.
+            //
+            // Exception: MAX/MIN are idempotent — max(x, x) = x — so 
repeating a
+            // foreign value across partial rows cannot change the aggregate
+            // result. The fence is over-conservative for them: a foreign 
return
+            // branch is safe once the condition is row-stable (step 2) and the
+            // return slot still matches the aggregate type (enforced by
+            // KeyAndValueSlotsAggChecker). Keep the fence for non-idempotent
+            // aggregates (SUM, COUNT, ...) where a repeated foreign value 
would
+            // be double-counted.
+            //
+            // count(distinct ...) is also multiplicity-immune (DISTINCT
+            // deduplicates repeated foreign values), like MAX/MIN, but is
+            // deliberately not exempted: the exemption would be a no-op. A 
safe
+            // foreign return for it is a key slot, and such aggregates never
+            // reach this fence — key-only local slots go through
+            // KeySlotAggChecker (isDistinct -> ON), empty local slots are
+            // whitelisted as other-table count-distinct. When this fence does
+            // fire for count(distinct), step 2 (a value column in the
+            // condition) or KeyAndValueSlotsAggChecker.visitCount (a value
+            // column in a return; only key/0/NULL returns are accepted)
+            // independently rejects it anyway.
+            if (!(aggFunc instanceof Max || aggFunc instanceof Min)) {
+                for (Expression returnExp : returnExps) {
+                    if (returnExp instanceof SlotReference && 
!outputSlots.contains(returnExp)) {
+                        return PreAggStatus.off(
+                                String.format("return expression %s references 
column not owned by this scan.",
+                                        returnExp.toSql()));
+                    }
+                }
+            }
+            if (conditionExps.isEmpty()) {
+                return PreAggStatus.off(
+                        String.format("can't turn preAgg on for aggregate 
function %s", aggFunc));
             }
 
-            // step 2: check condition expressions
+            // step 2: check condition expressions — all condition inputs must
+            // be key columns (from any table), not value columns.  A global
+            // splitKeyValueSlots check handles this correctly for both the
+            // mixed-path (called with local key/value sets) and the value-only
+            // path (foreign key conditions in IF/CaseWhen).
             Set<Slot> inputSlots = 
ExpressionUtils.getInputSlotSet(conditionExps);
-            if (!keySlots.containsAll(inputSlots)) {
+            Pair<Set<SlotReference>, Set<SlotReference>> condSplit =
+                    splitKeyValueSlots(inputSlots);
+            if (!condSplit.second.isEmpty()) {
                 return PreAggStatus
                         .off(String.format("some columns in condition %s is 
not key.", conditionExps));
             }
@@ -413,13 +685,6 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
             return KeyAndValueSlotsAggChecker.INSTANCE.check(aggFunc, 
returnExps);
         }
 
-        private static Expression removeCast(Expression expression) {
-            while (expression instanceof Cast) {
-                expression = ((Cast) expression).child();
-            }
-            return expression;
-        }
-
         private static class OneValueSlotAggChecker
                 extends ExpressionVisitor<PreAggStatus, AggregateType> {
             public static final OneValueSlotAggChecker INSTANCE = new 
OneValueSlotAggChecker();
@@ -511,8 +776,8 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
             }
         }
 
-        private static class OneKeySlotAggChecker extends 
ExpressionVisitor<PreAggStatus, Void> {
-            public static final OneKeySlotAggChecker INSTANCE = new 
OneKeySlotAggChecker();
+        private static class KeySlotAggChecker extends 
ExpressionVisitor<PreAggStatus, Void> {
+            public static final KeySlotAggChecker INSTANCE = new 
KeySlotAggChecker();
 
             public PreAggStatus check(AggregateFunction aggFun) {
                 return aggFun.accept(INSTANCE, null);
@@ -566,6 +831,13 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
 
             @Override
             public PreAggStatus visitSum(Sum sum, List<Expression> 
returnValues) {
+                // DISTINCT breaks pre-agg: storage SUM merges duplicate full 
keys
+                // first (e.g. two rowsets with v7=1 become 2), so 
sum(DISTINCT ...)
+                // over the merged value differs from DISTINCT over the raw 
rows.
+                // Reject like OneValueSlotAggChecker.visitSum does.
+                if (sum.isDistinct()) {
+                    return PreAggStatus.off(String.format("%s is not 
supported.", sum.toSql()));
+                }
                 for (Expression value : returnValues) {
                     if (!(isAggTypeMatched(value, AggregateType.SUM) || 
value.isZeroLiteral()
                             || value.isNullLiteral())) {
@@ -579,7 +851,7 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
             public PreAggStatus visitMax(Max max, List<Expression> 
returnValues) {
                 for (Expression value : returnValues) {
                     if (!(isAggTypeMatched(value, AggregateType.MAX) || 
isKeySlot(value)
-                            || value.isNullLiteral())) {
+                            || value.isLiteral())) {
                         return PreAggStatus.off(String.format("%s is not 
supported.", max.toSql()));
                     }
                 }
@@ -590,7 +862,7 @@ public class SetPreAggStatus extends 
DefaultPlanRewriter<Stack<SetPreAggStatus.P
             public PreAggStatus visitMin(Min min, List<Expression> 
returnValues) {
                 for (Expression value : returnValues) {
                     if (!(isAggTypeMatched(value, AggregateType.MIN) || 
isKeySlot(value)
-                            || value.isNullLiteral())) {
+                            || value.isLiteral())) {
                         return PreAggStatus.off(String.format("%s is not 
supported.", min.toSql()));
                     }
                 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatusTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatusTest.java
new file mode 100644
index 00000000000..aa93a831d07
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatusTest.java
@@ -0,0 +1,355 @@
+// 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.nereids.rules.rewrite;
+
+import org.apache.doris.catalog.AggregateType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.nereids.trees.expressions.Add;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import 
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Max;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Min;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Sum;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.If;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Random;
+import org.apache.doris.nereids.trees.expressions.literal.DoubleLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.plans.PreAggStatus;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.types.IntegerType;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Unit tests for the pre-agg dispatch logic in {@link SetPreAggStatus}.
+ *
+ * <p>The decision logic lives in the private static class SetOlapScanPreAgg
+ * (checkAggregateFunctions / checkAggWithKeyAndValueSlots / 
createPreAggStatus),
+ * so we reach it via reflection, mirroring TabletSlidingWindowAccessStatsTest.
+ * The dispatch matrix covered here (pure-key multi-arg, mixed multi-arg,
+ * pure-value IF path, volatile guards, ownership fence) is otherwise only
+ * exercised by explain regression tests.
+ */
+class SetPreAggStatusTest {
+
+    private static final Method CHECK_AGG_FUNCS;
+    private static final Method CHECK_AGG_WITH_KEY_VALUE;
+    private static final Method CREATE_PRE_AGG_STATUS;
+    private static final Object SET_OLAP_SCAN_PRE_AGG_INSTANCE;
+
+    static {
+        try {
+            Class<?> clazz = Class.forName(
+                    
"org.apache.doris.nereids.rules.rewrite.SetPreAggStatus$SetOlapScanPreAgg");
+            CHECK_AGG_FUNCS = clazz.getDeclaredMethod(
+                    "checkAggregateFunctions", Set.class, Set.class, 
Set.class);
+            CHECK_AGG_FUNCS.setAccessible(true);
+            CHECK_AGG_WITH_KEY_VALUE = clazz.getDeclaredMethod(
+                    "checkAggWithKeyAndValueSlots", AggregateFunction.class, 
Set.class);
+            CHECK_AGG_WITH_KEY_VALUE.setAccessible(true);
+            CREATE_PRE_AGG_STATUS = clazz.getDeclaredMethod(
+                    "createPreAggStatus", LogicalOlapScan.class, 
SetPreAggStatus.PreAggInfoContext.class);
+            CREATE_PRE_AGG_STATUS.setAccessible(true);
+            Field instance = clazz.getDeclaredField("INSTANCE");
+            instance.setAccessible(true);
+            SET_OLAP_SCAN_PRE_AGG_INSTANCE = instance.get(null);
+        } catch (Exception e) {
+            throw new ExceptionInInitializerError(e);
+        }
+    }
+
+    private static int exprIdCounter = 0;
+
+    private static SlotReference keySlot(String name) {
+        return slot(name, new Column(name, Type.INT, true, AggregateType.NONE, 
null, ""));
+    }
+
+    private static SlotReference valueSlot(String name, AggregateType 
aggregateType) {
+        return slot(name, new Column(name, Type.INT, false, aggregateType, 
null, ""));
+    }
+
+    private static SlotReference slot(String name, Column column) {
+        return new SlotReference(new ExprId(exprIdCounter++), name, 
IntegerType.INSTANCE, true,
+                ImmutableList.of("t"), null, column, null, null);
+    }
+
+    private static PreAggStatus checkAggregateFunctions(
+            Set<AggregateFunction> aggregateFuncs, Set<Slot> 
groupingExprsInputSlots, Set<Slot> outputSlots) {
+        try {
+            // checkAggregateFunctions is an instance method of 
SetOlapScanPreAgg, so pass its INSTANCE
+            return (PreAggStatus) 
CHECK_AGG_FUNCS.invoke(SET_OLAP_SCAN_PRE_AGG_INSTANCE,
+                    aggregateFuncs, groupingExprsInputSlots, outputSlots);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static PreAggStatus checkAggWithKeyAndValueSlots(AggregateFunction 
aggFunc, Set<Slot> outputSlots) {
+        try {
+            return (PreAggStatus) 
CHECK_AGG_WITH_KEY_VALUE.invoke(SET_OLAP_SCAN_PRE_AGG_INSTANCE, aggFunc, 
outputSlots);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static PreAggStatus createPreAggStatus(LogicalOlapScan scan, 
SetPreAggStatus.PreAggInfoContext context) {
+        try {
+            return (PreAggStatus) 
CREATE_PRE_AGG_STATUS.invoke(SET_OLAP_SCAN_PRE_AGG_INSTANCE, scan, context);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static Expression greaterThanZero(Slot slot) {
+        return new GreaterThan(slot, new IntegerLiteral(0));
+    }
+
+    private static Expression ifGreaterThanZero(Slot key, Expression thenExpr, 
Expression elseExpr) {
+        return new If(greaterThanZero(key), thenExpr, elseExpr);
+    }
+
+    @Test
+    void testNoAggregateReturnsOff() {
+        SlotReference k = keySlot("k");
+        Assertions.assertTrue(checkAggregateFunctions(Collections.emptySet(), 
Collections.emptySet(),
+                Sets.newHashSet(k)).isOff());
+    }
+
+    @Test
+    void testGroupingOnlyReturnsOn() {
+        SlotReference k = keySlot("k");
+        // aggregateFuncs empty but groupingExprsInputSlots non-empty -> loop 
is a no-op, returns ON
+        Assertions.assertTrue(checkAggregateFunctions(Collections.emptySet(), 
Sets.newHashSet(k),
+                Sets.newHashSet(k)).isOn());
+    }
+
+    @Test
+    void testPureKeySlotsDispatch() {
+        SlotReference k1 = keySlot("k1");
+        SlotReference k2 = keySlot("k2");
+        Set<Slot> output = Sets.newHashSet(k1, k2);
+
+        // max/min over key slots are allowed (KeySlotAggChecker)
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Max(k1)), Collections.emptySet(), output)
+                .isOn());
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Min(k1)), Collections.emptySet(), output)
+                .isOn());
+
+        // distinct aggregates over key slots are allowed (KeySlotAggChecker)
+        Assertions.assertTrue(
+                checkAggregateFunctions(Sets.newHashSet(new Count(true, k1)), 
Collections.emptySet(), output).isOn());
+        // pure-key multi-argument count(distinct k1, k2)
+        Assertions.assertTrue(
+                checkAggregateFunctions(Sets.newHashSet(new Count(true, k1, 
k2)), Collections.emptySet(), output)
+                        .isOn());
+
+        // non-distinct, non-max/min aggregates over key slots are rejected
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Sum(k1)), Collections.emptySet(), output)
+                .isOff());
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Count(k1)), Collections.emptySet(), output)
+                .isOff());
+    }
+
+    @Test
+    void testMixedMultiArgRejected() {
+        SlotReference k = keySlot("k");
+        SlotReference v = valueSlot("v", AggregateType.SUM);
+        Set<Slot> output = Sets.newHashSet(k, v);
+
+        // multi-argument aggregate with mixed local key/value slots cannot 
pre-agg
+        PreAggStatus status = checkAggregateFunctions(
+                Sets.newHashSet(new Count(true, k, v)), 
Collections.emptySet(), output);
+        Assertions.assertTrue(status.isOff());
+        Assertions.assertTrue(status.getOffReason().contains("can't turn 
preAgg on for aggregate function"));
+    }
+
+    @Test
+    void testPureValueSlotDispatch() {
+        SlotReference vSum = valueSlot("v", AggregateType.SUM);
+        SlotReference vMax = valueSlot("vmax", AggregateType.MAX);
+        Set<Slot> output = Sets.newHashSet(vSum);
+
+        // sum(v) with SUM-type value column (OneValueSlotAggChecker)
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Sum(vSum)), Collections.emptySet(), output)
+                .isOn());
+        // max(v) with MAX-type value column
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Max(vMax)), Collections.emptySet(),
+                Sets.newHashSet(vMax)).isOn());
+        // aggregation-type mismatch
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Sum(vMax)), Collections.emptySet(),
+                Sets.newHashSet(vMax)).isOff());
+        // count over a bare value column is not pre-aggregable 
(OneValueSlotAggChecker has no visitCount)
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Count(vSum)), Collections.emptySet(),
+                output).isOff());
+        // non-slot, non-IF/CaseWhen child is rejected
+        Assertions.assertTrue(checkAggregateFunctions(
+                Sets.newHashSet(new Sum(new Add(vSum, new IntegerLiteral(1)))),
+                Collections.emptySet(), output).isOff());
+    }
+
+    @Test
+    void testIfCaseWhenValuePath() {
+        SlotReference k = keySlot("k");
+        SlotReference v = valueSlot("v", AggregateType.SUM);
+        SlotReference vMax = valueSlot("vmax", AggregateType.MAX);
+        SlotReference foreignV = valueSlot("fv", AggregateType.SUM);
+        SlotReference foreignVMax = valueSlot("fvmax", AggregateType.MAX);
+        Set<Slot> output = Sets.newHashSet(k, v);
+
+        // sum(if(k > 0, v, 0)): row-stable key condition + local SUM return 
-> ON
+        Expression localIf = ifGreaterThanZero(k, v, new IntegerLiteral(0));
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Sum(localIf)), Collections.emptySet(),
+                output).isOn());
+
+        // condition references a value column -> OFF (step 2)
+        Expression condOnValue = new If(new GreaterThan(v, new 
IntegerLiteral(0)), v, new IntegerLiteral(0));
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Sum(condOnValue)), Collections.emptySet(),
+                output).isOff());
+
+        // foreign value in a SUM return: local slots are pure keys, so this 
goes through
+        // KeySlotAggChecker (sum is not distinct) -> OFF, never reaching the 
ownership fence
+        Expression foreignSumIf = ifGreaterThanZero(k, foreignV, new 
IntegerLiteral(0));
+        PreAggStatus foreignSum = checkAggregateFunctions(
+                Sets.newHashSet(new Sum(foreignSumIf)), 
Collections.emptySet(), output);
+        Assertions.assertTrue(foreignSum.isOff());
+        Assertions.assertTrue(foreignSum.getOffReason().contains("is not 
distinct"));
+
+        // MAX exemption: foreign MAX value return is allowed when local slots 
are mixed
+        Expression mixedMaxIf = new If(greaterThanZero(k), foreignVMax, vMax);
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Max(mixedMaxIf)), Collections.emptySet(),
+                Sets.newHashSet(k, vMax)).isOn());
+
+        // same mixed shape with SUM is rejected by the ownership fence
+        Expression mixedSumIf = new If(greaterThanZero(k), foreignV, v);
+        PreAggStatus mixedSum = checkAggregateFunctions(
+                Sets.newHashSet(new Sum(mixedSumIf)), Collections.emptySet(), 
output);
+        Assertions.assertTrue(mixedSum.isOff());
+        Assertions.assertTrue(mixedSum.getOffReason().contains("references 
column not owned by this scan"));
+    }
+
+    @Test
+    void testCountDistinctDispatch() {
+        SlotReference k = keySlot("k");
+        SlotReference v = valueSlot("v", AggregateType.SUM);
+        Set<Slot> output = Sets.newHashSet(k, v);
+
+        // count(distinct if(k > 0, k, 0)): local slots are all keys -> 
KeySlotAggChecker -> ON
+        Expression cdKeyIf = ifGreaterThanZero(k, k, new IntegerLiteral(0));
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Count(true, cdKeyIf)),
+                Collections.emptySet(), output).isOn());
+
+        // count(distinct if(k > 0, v, 0)): value column in return -> OFF 
(visitCount accepts only key/0/NULL)
+        Expression cdValueIf = ifGreaterThanZero(k, v, new IntegerLiteral(0));
+        Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new 
Count(true, cdValueIf)),
+                Collections.emptySet(), output).isOff());
+    }
+
+    @Test
+    void testCheckAggWithKeyAndValueSlots() {
+        SlotReference k = keySlot("k");
+        SlotReference k2 = keySlot("k2");
+        SlotReference v = valueSlot("v", AggregateType.SUM);
+        SlotReference foreignV = valueSlot("fv", AggregateType.SUM);
+        SlotReference foreignVMax = valueSlot("fvmax", AggregateType.MAX);
+        Set<Slot> output = Sets.newHashSet(k, v);
+
+        // ownership fence: foreign value in a SUM return
+        Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+                new Sum(ifGreaterThanZero(k, foreignV, new 
IntegerLiteral(0))), output).isOff());
+
+        // MAX exemption: foreign MAX value return is safe
+        Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+                new Max(ifGreaterThanZero(k, foreignVMax, new 
IntegerLiteral(0))), output).isOn());
+
+        // condition referencing a value column is rejected (step 2)
+        Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+                new Sum(new If(new GreaterThan(v, new IntegerLiteral(0)), v, 
new IntegerLiteral(0))), output).isOff());
+
+        // count(distinct) returns must be key/0/NULL: value return -> OFF, 
key return -> ON
+        Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+                new Count(true, ifGreaterThanZero(k, v, new 
IntegerLiteral(0))), output).isOff());
+        Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+                new Count(true, ifGreaterThanZero(k, k2, new 
IntegerLiteral(0))),
+                Sets.newHashSet(k, k2, v)).isOn());
+    }
+
+    @Test
+    void testVolatileAggregateTurnsScanOff() throws Exception {
+        SlotReference k = keySlot("k");
+        PreAggStatus status = createPreAggStatus(mockScan(Sets.newHashSet(k)),
+                contextWithAggregateFunctions(Sets.newHashSet(new Sum(new 
Random()))));
+        Assertions.assertTrue(status.isOff());
+        Assertions.assertTrue(status.getOffReason().contains("aggregate 
function")
+                && status.getOffReason().contains("contains volatile 
expression"));
+    }
+
+    @Test
+    void testVolatileFilterTurnsScanOff() throws Exception {
+        SlotReference k = keySlot("k");
+        SetPreAggStatus.PreAggInfoContext context = new 
SetPreAggStatus.PreAggInfoContext();
+        Field filterField = 
SetPreAggStatus.PreAggInfoContext.class.getDeclaredField("filterConjuncts");
+        filterField.setAccessible(true);
+        filterField.set(context,
+                Lists.newArrayList(new GreaterThan(new Random(), new 
DoubleLiteral(0.5))));
+
+        PreAggStatus status = createPreAggStatus(mockScan(Sets.newHashSet(k)), 
context);
+        Assertions.assertTrue(status.isOff());
+        Assertions.assertTrue(status.getOffReason().contains("filter conjunct")
+                && status.getOffReason().contains("contains volatile 
expression"));
+    }
+
+    @Test
+    void testValidAggregateTurnsScanOn() throws Exception {
+        SlotReference k = keySlot("k");
+        SlotReference v = valueSlot("v", AggregateType.SUM);
+        PreAggStatus status = createPreAggStatus(mockScan(Sets.newHashSet(k, 
v)),
+                contextWithAggregateFunctions(Sets.newHashSet(new Sum(v))));
+        Assertions.assertTrue(status.isOn());
+    }
+
+    private static LogicalOlapScan mockScan(Set<Slot> outputSlots) {
+        LogicalOlapScan scan = Mockito.mock(LogicalOlapScan.class);
+        Mockito.when(scan.getOutputSet()).thenReturn(outputSlots);
+        return scan;
+    }
+
+    private static SetPreAggStatus.PreAggInfoContext 
contextWithAggregateFunctions(
+            Set<AggregateFunction> aggregateFunctions) throws Exception {
+        SetPreAggStatus.PreAggInfoContext context = new 
SetPreAggStatus.PreAggInfoContext();
+        Field aggField = 
SetPreAggStatus.PreAggInfoContext.class.getDeclaredField("aggregateFunctions");
+        aggField.setAccessible(true);
+        aggField.set(context, aggregateFunctions);
+        return context;
+    }
+}
diff --git a/regression-test/data/nereids_rules_p0/set_preagg/set_preagg.out 
b/regression-test/data/nereids_rules_p0/set_preagg/set_preagg.out
new file mode 100644
index 00000000000..ff3fbe7f7b7
--- /dev/null
+++ b/regression-test/data/nereids_rules_p0/set_preagg/set_preagg.out
@@ -0,0 +1,148 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !q01 --
+0      0       40      7000
+1      1       30      8000
+
+-- !q02 --
+0      0       7000
+1      1       8000
+
+-- !q03 --
+0      0       0       7000    1
+1      1       1       8000    1
+
+-- !q04 --
+0      0       70      7000
+1      1       60      8000
+
+-- !q05 --
+0      0       3000    7000    1
+1      1       5000    8000    1
+
+-- !q06 --
+0      0       70      7000    1
+1      1       110     8000    1
+
+-- !q07 --
+0      0       70      7000
+1      1       110     8000
+
+-- !q08 --
+0      0       1       7000    70
+1      1       2       8000    110
+
+-- !q09 --
+0      0       3000    7000    1       1
+1      1       5000    8000    1       1
+
+-- !q10 --
+0      0       7000    1       1
+1      1       8000    1       1
+
+-- !q11 --
+0      0       40      7000    1       1
+1      1       60      8000    1       1
+
+-- !q12 --
+1      1       10      1000
+2      0       40      700
+
+-- !q13 --
+10
+
+-- !q14 --
+4
+
+-- !q15 --
+3
+
+-- !q16 --
+4
+
+-- !q17 --
+4
+
+-- !q18 --
+1
+
+-- !q19 --
+3
+
+-- !q20 --
+400
+
+-- !q21 --
+1000
+
+-- !q22 --
+100
+
+-- !q23 --
+900
+
+-- !q24 --
+1000
+
+-- !q25 --
+30
+
+-- !q26 --
+1000
+
+-- !q27 --
+1000
+
+-- !q28 --
+1000
+
+-- !q29 --
+\N
+
+-- !q30 --
+1000
+
+-- !q31 --
+0
+
+-- !q32 --
+1000
+
+-- !q33 --
+0
+
+-- !test_a --
+300
+
+-- !test_b --
+160
+
+-- !q34 --
+1000
+
+-- !q35 --
+7
+
+-- !q36 --
+false
+
+-- !q37 --
+300
+
+-- !q38 --
+600
+
+-- !q39 --
+100
+
+-- !test_c --
+20
+
+-- !test_f --
+20
+
+-- !test_d --
+1000
+
+-- !test_e --
+0
+
diff --git 
a/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy 
b/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
index f47b2b40fc2..d278699d23f 100644
--- a/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
+++ b/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
@@ -16,6 +16,9 @@
 // under the License.
 
 suite("set_preagg") {
+    // preagg_t4 gets two loads of the same full key (1,1,1,1,1,1) to create
+    // duplicate full keys across rowsets: storage SUM merges v7 to 1+1=2 while
+    // pre-agg ON would expose the raw {1,1} rows (used by the DISTINCT SUM 
test).
     multi_sql """
         set disable_nereids_rules='PRUNE_EMPTY_PARTITION';
         set forbid_unknown_col_stats=false;
@@ -23,6 +26,15 @@ suite("set_preagg") {
         drop table if exists preagg_t1;
         drop table if exists preagg_t2;
         drop table if exists preagg_t3;
+        drop table if exists preagg_t4;
+        drop table if exists preagg_t5;
+        drop table if exists preagg_f_l;
+        drop table if exists preagg_f_r;
+        drop table if exists preagg_asof_l;
+        drop table if exists preagg_asof_r;
+        drop table if exists preagg_g;
+        drop table if exists preagg_own_l;
+        drop table if exists preagg_own_r;
 
         create table preagg_t1(
             k1 int null,
@@ -67,8 +79,125 @@ suite("set_preagg") {
         aggregate key (k1,k2,k3,k4,k5,k6)
         distributed BY hash(k1) buckets 3
         properties("replication_num" = "1");
+        create table preagg_t4(
+            k1 int null,
+            k2 int null,
+            k3 int null,
+            k4 int null,
+            k5 int null,
+            k6 int null,
+            v7 bigint SUM,
+            v9 bigint MAX
+        )
+        aggregate key (k1,k2,k3,k4,k5,k6)
+        distributed BY hash(k1) buckets 3
+        properties("replication_num" = "1");
+        create table preagg_t5(
+            k1 int null,
+            v double MAX
+        )
+        aggregate key (k1)
+        distributed BY hash(k1) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_f_l(
+            k1 int null,
+            v9 bigint MAX,
+            v9m bigint MIN
+        )
+        aggregate key (k1)
+        distributed BY hash(k1) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_f_r(
+            k1 int null,
+            v9 bigint MAX,
+            v9m bigint MIN
+        )
+        aggregate key (k1)
+        distributed BY hash(k1) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_asof_l(
+            grp int null,
+            ts datetime null
+        )
+        aggregate key (grp, ts)
+        distributed BY hash(grp) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_asof_r(
+            grp int null,
+            ts datetime null,
+            v7 bigint SUM
+        )
+        aggregate key (grp, ts)
+        distributed BY hash(grp) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_g(
+            k1 int null,
+            v7 bigint SUM,
+            v9 bigint MAX
+        )
+        aggregate key (k1)
+        distributed BY hash(k1) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_own_l(
+            k1 int null,
+            v7 bigint SUM
+        )
+        aggregate key (k1)
+        distributed BY hash(k1) buckets 1
+        properties("replication_num" = "1");
+        create table preagg_own_r(
+            k1 int null,
+            v7 bigint SUM
+        )
+        aggregate key (k1)
+        distributed BY hash(k1) buckets 1
+        properties("replication_num" = "1");
+
+        insert into preagg_t1 values
+            (1,1,1,1,1,1, 10, 100, 1000),
+            (1,1,1,1,1,2, 20, 200, 900),
+            (-1,0,0,0,0,0, 30, 300, 800),
+            (2,0,0,0,0,0, 40, 400, 700);
+        insert into preagg_t2 values
+            (1,1,1,1,1,1, 50, 500, 5000),
+            (1,1,1,1,1,2, 60, 600, 4000),
+            (2,0,0,0,0,0, 70, 700, 3000);
+        insert into preagg_t3 values
+            (1,1,1,1,1,1, 80, 800, 8000),
+            (2,0,0,0,0,0, 90, 900, 7000);
+        insert into preagg_t4 values (1,1,1,1,1,1, 1, 100);
+        insert into preagg_t4 values (1,1,1,1,1,1, 1, 200);
+        insert into preagg_t4 values (2,0,0,0,0,0, 5, 300);
+        insert into preagg_t5 values (1, -1e-300);
+        insert into preagg_t5 values (1, 0.0);
+        insert into preagg_f_l values (1, 100, 100);
+        insert into preagg_f_l values (-1, 50, 50);
+        insert into preagg_f_r values (1, 1000, 1000);
+        insert into preagg_f_r values (1, 2000, 2000);
+        insert into preagg_f_r values (-1, 500, 500);
+        insert into preagg_f_r values (-1, 600, 600);
+        insert into preagg_asof_l values (1,'2020-01-01 
00:15:00'),(2,'2020-01-01 00:00:00');
+        insert into preagg_asof_r values (1,'2020-01-01 00:00:00',100);
+        insert into preagg_asof_r values (1,'2020-01-01 00:00:00',200);
+        insert into preagg_asof_r values (2,'2020-01-01 00:00:00',300);
+        insert into preagg_g values (1, -2, 10);
+        insert into preagg_g values (1, 3, 20);
+        insert into preagg_own_l values (1, 10);
+        insert into preagg_own_l values (1, 20);
+        insert into preagg_own_l values (0, 5);
+        insert into preagg_own_l values (0, 7);
+        insert into preagg_own_r values (1, 100);
+        insert into preagg_own_r values (1, 200);
+        insert into preagg_own_r values (0, 50);
+        insert into preagg_own_r values (0, 80);
     """
 
+    // preagg_own_l/preagg_own_r: full keys k1=1 and k1=0 are each loaded twice
+    // in separate rowsets, so PREAGG ON would expose duplicate full-key 
partial
+    // rows. preagg_own_l's k1=0 row makes t.a = abs(0) = 0, exercising the 
ELSE
+    // branch (r.v7) in test_b; preagg_own_r's repeated keys make its own 
ON/OFF
+    // status observable through join fan-out as well.
+
     explain {
         sql("""
             select preagg_t3.k2, t12.k2, sum(t12.v1), max(preagg_t3.v9)
@@ -89,6 +218,21 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: ON"
         contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
     }
+    order_qt_q01 """
+        select preagg_t3.k2, t12.k2, sum(t12.v1), max(preagg_t3.v9)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, 
sum(ta1.t1_sum_v7) v1, sum(ta2.t2_sum_v7) v2
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, sum(v7) t2_sum_v7 from preagg_t2 
group by k1, k2, k3, k4, k5) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -108,6 +252,21 @@ suite("set_preagg") {
         """)
         notContains "PREAGGREGATION: OFF"
     }
+    order_qt_q02 """
+        select preagg_t3.k2, t12.k2, max(preagg_t3.v9)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, 
max(ta1.t1_sum_v7) v1, sum(ta2.t2_sum_v7) v2
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, sum(v7) t2_sum_v7 from preagg_t2 
group by k1, k2, k3, k4, k5) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -129,6 +288,21 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: ON"
         contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
     }
+    order_qt_q03 """
+        select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9), 
sum(t12.v3)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, 
max(ta1.t1_sum_v7) v1, max(ta2.k4) v2, count(distinct ta2.k5) v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v7 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -150,6 +324,21 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: OFF. Reason: max(v7) is not 
match agg mode SUM"
         contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
     }
+    order_qt_q04 """
+        select preagg_t3.k2, t12.k2, sum(t12.v2), max(preagg_t3.v9)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, 
max(ta1.t1_sum_v7) v1, max(ta2.v7) v2
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v7 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -192,6 +381,21 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: ON"
         contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
     }
+    order_qt_q05 """
+        select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9), 
sum(t12.v3)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, max(case when 
ta2.k1 > 0 then ta2.v9 when ta2.k1 = 0 then null when ta2.k1 < 0 then ta2.v9 
else null end) v2, count(distinct ta2.k5) v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v7, v8, v9 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -213,6 +417,21 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: ON"
         contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
     }
+    order_qt_q06 """
+        select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9), 
sum(t12.v3)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, sum(case when 
ta2.k1 > 0 then ta2.v7 when ta2.k1 = 0 then 0 when ta2.k1 < 0 then ta2.v8 else 
0 end) v2, count(distinct ta2.k5) v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v7, v8 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -232,7 +451,21 @@ suite("set_preagg") {
         """)
         notContains "PREAGGREGATION: OFF"
     }
-
+    order_qt_q07 """
+        select preagg_t3.k2, t12.k2, sum(t12.v2), max(preagg_t3.v9)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, 
max(ta1.t1_sum_v7) v1, sum(ta2.v7) v2
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v7 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
     explain {
         sql("""
             select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9), 
sum(t12.v3)
@@ -250,9 +483,24 @@ suite("set_preagg") {
             order by 1, 2;
         """)
         contains "(preagg_t1), PREAGGREGATION: ON"
-        contains "(preagg_t2), PREAGGREGATION: OFF. Reason: count("
+        contains "(preagg_t2), PREAGGREGATION: OFF. Reason: count"
         contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
     }
+    order_qt_q08 """
+        select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9), 
sum(t12.v3)
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, 
max(ta1.t1_sum_v7) v1, count(case when ta2.k1 > 0 then ta2.v7 when ta2.k1 = 0 
then 0 when ta1.k1 < 0 then ta2.v8 else 0 end) v2, sum(ta2.v7) v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            left join 
+                (select k1, k2, k3, k4, k5, v7, v8 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+            group by k1, k2, k3, k4
+        ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -273,6 +521,20 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: ON"
         contains "(preagg_t3), PREAGGREGATION: ON"
     }
+    order_qt_q09 """
+        select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9), 
count(distinct t12.v3), count(distinct t12.k4) v3
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, ta1.t1_sum_v7 
v1, ta2.v9 v2, ta2.k5 v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v9 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+        ) t12 right join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -293,6 +555,20 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: ON"
         contains "(preagg_t3), PREAGGREGATION: ON"
     }
+    order_qt_q10 """
+        select preagg_t3.k2, t12.k2, max(preagg_t3.v9), count(distinct 
t12.v3), count(distinct t12.k4) v3
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, ta1.t1_sum_v7 
v1, ta1.k5 v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v9 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+        ) t12 right join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -313,6 +589,20 @@ suite("set_preagg") {
         contains "(preagg_t2), PREAGGREGATION: OFF. Reason: can't turn preAgg 
on because aggregate function sum"
         contains "(preagg_t3), PREAGGREGATION: OFF"
     }
+    order_qt_q11 """
+        select preagg_t3.k2, t12.k2, sum(t12.v1), max(preagg_t3.v9), 
count(distinct t12.v3), count(distinct t12.k4) v3
+        from 
+        (
+            select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, ta1.t1_sum_v7 
v1, ta1.k5 v3
+            from 
+                (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1 
group by k1, k2, k3, k4, k5) as ta1
+            inner join 
+                (select k1, k2, k3, k4, k5, v9 from preagg_t2) as ta2
+            on ta1.k3 = ta2.k3
+        ) t12 right join preagg_t3 on t12.k1 = preagg_t3.k1
+        group by preagg_t3.k2, t12.k2
+        order by 1, 2;
+    """
 
     explain {
         sql("""
@@ -328,6 +618,17 @@ suite("set_preagg") {
         contains "(preagg_t1), PREAGGREGATION: OFF. Reason: No valid aggregate 
on scan."
         contains "(preagg_t1), PREAGGREGATION: ON"
     }
+    order_qt_q12 """
+        select cw.k1, cw.k2, cw.v7, cw.v9
+        from preagg_t1 cw
+        inner join (
+            select k1, k2, max(v9) as v9
+            from preagg_t1
+            where k1 in (1, 2)
+            group by k1, k2
+        ) mw on cw.k1 = mw.k1 and cw.v9 = mw.v9
+        order by 1, 2, 3, 4;
+    """
 
     // Aggregate over limited subquery: Limit between aggregate and scan goes
     // through generic visitor path, which should block preagg collection
@@ -349,4 +650,586 @@ suite("set_preagg") {
             select count(*) from (select * from numbers("number"="10")) t;
         """)
     }
+    order_qt_q13 """
+        select count(*) from (select * from numbers("number"="10")) t;
+    """
+
+    explain {
+        sql("""select count(distinct k6, v7) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q14 """
+        select count(distinct k6, v7) from preagg_t1;
+    """
+
+    explain {
+        sql("""select count(distinct k6, k5) from preagg_t1;""")
+        contains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q15 """
+        select count(distinct k6, k5) from preagg_t1;
+    """
+
+    // Negative: count(DISTINCT IF(...), value_col) — 
checkAggWithKeyAndValueSlots
+    // only inspects child(0) (the IF). Without a multi-arg guard, it would
+    // miss v7 in child(1) and incorrectly return ON.
+    explain {
+        sql("""
+            select count(distinct if(k6 > 0, k5, 0), v7) from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q16 """
+        select count(distinct if(k6 > 0, k5, 0), v7) from preagg_t1;
+    """
+
+    explain {
+        sql("""
+            select count(distinct case when k6 > 0 then k5 else 0 end, v7) 
from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q17 """
+        select count(distinct case when k6 > 0 then k5 else 0 end, v7) from 
preagg_t1;
+    """
+
+    // Negative: count(DISTINCT key + random()) — volatile in the expression
+    // argument. With pre-agg ON, random() would be evaluated per partial row
+    // instead of per merged logical row, changing the distinct count.
+    explain {
+        sql("""select count(distinct k6 + random()) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+
+    // max/min(key + random()) have the same volatile concern.
+    explain {
+        sql("""select max(k6 + random()) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+
+    explain {
+        sql("""select min(k6 + random()) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+
+    // Volatile in an IF condition inside a mixed-key-value aggregate.
+    // The condition k6 + random() > 0 uses only key input slots but is
+    // volatile, so pre-agg must be OFF.
+    explain {
+        sql("""
+            select sum(if(k6 + random() > 0, v7, 0)) from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+
+    // Positive: two-project join where both aliases resolve to key-only
+    // expressions. With merge+resolve, x = k5 + 1 resolves fully to base
+    // key columns, so pre-agg can be ON for both scans.
+    explain {
+        sql("""
+            select count(distinct a)
+            from (
+                select l.k1 + l.x as a
+                from (
+                    select t1.k1, t1.k5 + 1 as x from preagg_t1 t1
+                ) l
+                inner join (
+                    select abs(t2.k1) as rk from preagg_t2 t2
+                ) r on l.k1 = r.rk
+            ) t;
+        """)
+        contains "(preagg_t1), PREAGGREGATION: ON"
+        contains "(preagg_t2), PREAGGREGATION: ON"
+    }
+    order_qt_q18 """
+        select count(distinct a)
+        from (
+            select l.k1 + l.x as a
+            from (
+                select t1.k1, t1.k5 + 1 as x from preagg_t1 t1
+            ) l
+            inner join (
+                select abs(t2.k1) as rk from preagg_t2 t2
+            ) r on l.k1 = r.rk
+        ) t;
+    """
+
+    // Negative: two-project join; x carries v7 + 1 (a value column). Even
+    // with merge+resolve, the fully resolved expression contains v7 so
+    // pre-agg is correctly OFF.
+    explain {
+        sql("""
+            select count(distinct a)
+            from (
+                select l.k1 + l.x as a
+                from (
+                    select t1.k1, t1.v7 + 1 as x from preagg_t1 t1
+                ) l
+                inner join (
+                    select abs(t2.k1) as rk from preagg_t2 t2
+                ) r on l.k1 = r.rk
+            ) t;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q19 """
+        select count(distinct a)
+        from (
+            select l.k1 + l.x as a
+            from (
+                select t1.k1, t1.v7 + 1 as x from preagg_t1 t1
+            ) l
+            inner join (
+                select abs(t2.k1) as rk from preagg_t2 t2
+            ) r on l.k1 = r.rk
+        ) t;
+    """
+
+    // Bypass 1: volatile in an other-table aggregate function. max(r.k1 + 
random())
+    // is whitelisted as a duplicate-insensitive MAX for scan l; the candidate 
set
+    // for l is empty, so without a central volatile check it returns ON before
+    // reaching the per-function guard. Both scans must be OFF.
+    explain {
+        sql("""
+            select max(r.k1 + random())
+            from preagg_t1 l
+            inner join preagg_t2 r on l.k1 = r.k1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+        notContains "(preagg_t2), PREAGGREGATION: ON"
+    }
+
+    // Bypass 2: volatile filter with no input slots. random() < 0.5 has an
+    // empty input-slot set, so the slot-based value-column check bypasses it.
+    // The central volatile guard must reject pre-agg on this scan.
+    explain {
+        sql("""
+            select sum(v7) from preagg_t1 where random() < 0.5;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+
+    // Foreign value column in mixed aggregate: sum(if(abs(l.k1) > 0, r.v7, 0))
+    // references l.k1 (local key) and r.v7 (foreign value). The mixed helper
+    // must not use r.v7's SUM type to justify pre-agg on l — r.v7 is not
+    // a column of l. l must be OFF; r can be ON.
+    explain {
+        sql("""
+            select sum(if(t.a > 0, r.v7, 0))
+            from (select abs(k1) as a from preagg_t1) t
+            inner join preagg_t2 r on t.a = r.k1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+        // r's local slots are only {r.v7} with IF; the value-only IF/CaseWhen
+        // handler validates conditions (foreign key t.a → safe) and returns 
ON.
+        contains "(preagg_t2), PREAGGREGATION: ON"
+    }
+
+    // CASE WHEN symmetry of the foreign-key-condition / local-value-return
+    // pattern on r: conditions reference only foreign keys, return references
+    // r.v7, so r should be ON.
+    explain {
+        sql("""
+            select sum(case when t.a > 0 then r.v7 else 0 end)
+            from (select abs(k1) as a from preagg_t1) t
+            inner join preagg_t2 r on t.a = r.k1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+        contains "(preagg_t2), PREAGGREGATION: ON"
+    }
+    order_qt_q20 """
+        select sum(case when t.a > 0 then r.v7 else 0 end)
+        from (select abs(k1) as a from preagg_t1) t
+        inner join preagg_t2 r on t.a = r.k1;
+    """
+
+    // Negative: max(cast(v9 as double)) — no cast is peeled for MAX/MIN.
+    // DOUBLE/DECIMAL→FLOAT can underflow to -0.0 and change the observable
+    // tie representative (signed zero) under MAX/MIN, so even nondecreasing
+    // casts are not MAX/MIN homomorphisms. The checker sees a Cast and
+    // returns OFF.
+    explain {
+        sql("""select max(cast(v9 as double)) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q21 """select max(cast(v9 as double)) from preagg_t1;"""
+
+    // Negative: sum(cast(v7 as double)) — sum(cast(x)) and cast(sum(x)) are
+    // not interchangeable due to precision/overflow, so cast must NOT be
+    // unwrapped. OneValueSlotAggChecker sees a Cast, not a SlotReference,
+    // and correctly returns OFF.
+    explain {
+        sql("""select sum(cast(v7 as double)) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q22 """select sum(cast(v7 as double)) from preagg_t1;"""
+
+    // Negative: max(cast(v9 as string)) — non-numeric cast is never safe
+    // for MAX/MIN because string comparison differs from numeric comparison.
+    explain {
+        sql("""select max(cast(v9 as string)) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q23 """select max(cast(v9 as string)) from preagg_t1;"""
+
+    // Negative mixed-path IF with cast in return: max(if(k6 > 0, cast(v9 as 
double), 0))
+    // — no cast is peeled for MAX/MIN, so the IF return stays wrapped in Cast
+    // and the checker returns OFF.
+    explain {
+        sql("""
+            select max(if(k6 > 0, cast(v9 as double), 0)) from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q24 """
+        select max(if(k6 > 0, cast(v9 as double), 0)) from preagg_t1;
+    """
+
+    // Negative mixed-path IF with cast in return:
+    // sum(if(k6 > 0, cast(v7 as double), 0)) — sum(cast(x)) is not
+    // interchangeable with cast(sum(x)), so the guard keeps the Cast
+    // wrapper and the checker returns OFF.
+    explain {
+        sql("""
+            select sum(if(k6 > 0, cast(v7 as double), 0)) from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q25 """
+        select sum(if(k6 > 0, cast(v7 as double), 0)) from preagg_t1;
+    """
+
+    // --- Cast regression tests ---
+    // No cast is peeled for MAX/MIN: DOUBLE/DECIMAL→FLOAT can underflow to
+    // -0.0 and change the observable tie representative (signed zero) under
+    // MAX/MIN, so even nondecreasing casts are not MAX/MIN homomorphisms.
+    // Any cast-wrapped aggregate is therefore conservatively OFF.
+
+    // Negative: BIGINT→DECIMAL(20,0) widening cast → OFF (no peeling).
+    explain {
+        sql("""select max(cast(v9 as decimal(20,0))) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q26 """select max(cast(v9 as decimal(20,0))) from preagg_t1;"""
+
+    // Negative: BIGINT→LARGEINT widening cast → OFF (no peeling).
+    explain {
+        sql("""select max(cast(v9 as largeint)) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q27 """select max(cast(v9 as largeint)) from preagg_t1;"""
+
+    // Negative: BIGINT→INT is narrowing (not injective, not float) → OFF.
+    explain {
+        sql("""select max(cast(v9 as int)) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q28 """select max(cast(v9 as int)) from preagg_t1;"""
+
+    // Negative: BIGINT→TINYINT is narrowing (not injective, not float) → OFF.
+    explain {
+        sql("""select max(cast(v9 as tinyint)) from preagg_t1;""")
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q29 """select max(cast(v9 as tinyint)) from preagg_t1;"""
+
+    // Negative mixed-path IF with widening cast: no peeling → OFF.
+    explain {
+        sql("""
+            select max(if(k6 > 0, cast(v9 as decimal(20,0)), 0)) from 
preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q30 """
+        select max(if(k6 > 0, cast(v9 as decimal(20,0)), 0)) from preagg_t1;
+    """
+
+    // Mixed-path IF with unsafe cast: max(if(..., cast(v9 as tinyint), 0))
+    // return cast is narrowing non-injective → not peeled → checker OFF.
+    explain {
+        sql("""
+            select max(if(k6 > 0, cast(v9 as tinyint), 0)) from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q31 """
+        select max(if(k6 > 0, cast(v9 as tinyint), 0)) from preagg_t1;
+    """
+
+    // Negative: CASE WHEN with widening cast → OFF (no peeling).
+    explain {
+        sql("""
+            select max(case when k6 > 0 then cast(v9 as decimal(20,0)) else 0 
end) from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q32 """
+        select max(case when k6 > 0 then cast(v9 as decimal(20,0)) else 0 end) 
from preagg_t1;
+    """
+
+    // CASE WHEN with unsafe narrowing cast → OFF.
+    explain {
+        sql("""
+            select max(case when k6 > 0 then cast(v9 as tinyint) else 0 end) 
from preagg_t1;
+        """)
+        notContains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_q33 """
+        select max(case when k6 > 0 then cast(v9 as tinyint) else 0 end) from 
preagg_t1;
+    """
+
+    // 
-------------------------------------------------------------------------
+    // Two-table derived-key ownership tests (with repeated aggregate-key data)
+    //
+    // The mixed helper must validate returns relative to the current scan: a
+    // foreign value column must never justify exposing this scan's partial
+    // (unmerged) rows. Under join fan-out, a return that references a foreign
+    // value column would be evaluated once per partial row and double-counted.
+    // 
-------------------------------------------------------------------------
+
+    // Test A: derived-key fan-out with repeated full aggregate keys. 
preagg_own_l
+    // loads k1=1 twice (v7=10,20) and k1=0 twice (v7=5,7) in separate 
rowsets, so
+    // if preagg_own_l were wrongly ON, its duplicate full-key partial rows 
would
+    // fan out under the join and double-count the foreign r.v7 in the IF 
return.
+    // Correct (l OFF, merged): t has a=1 (k1=1 merged v7=30) and a=0 (k1=0 
merged
+    // v7=12). r is legitimately ON, exposing its own partials k1=1 {100,200} 
and
+    // k1=0 {50,80}: a=1 joins both → 100+200=300; a=0 joins both but 
if(0>0,...)=0
+    // → total 300. If l were wrongly ON, a=1 would appear twice and join both 
r
+    // partials → 300*2=600, breaking the oracle.
+    explain {
+        sql("""
+            select sum(if(t.a > 0, r.v7, 0))
+            from (select abs(k1) as a from preagg_own_l) t
+            inner join preagg_own_r r on t.a = r.k1;
+        """)
+        notContains "(preagg_own_l), PREAGGREGATION: ON"
+        contains "(preagg_own_r), PREAGGREGATION: ON"
+    }
+    order_qt_test_a """
+        select sum(if(t.a > 0, r.v7, 0)) as res
+        from (select abs(k1) as a from preagg_own_l) t
+        inner join preagg_own_r r on t.a = r.k1;
+    """
+
+    // Test B: foreign value in the IF return on BOTH sides — ownership check 
must
+    // turn both scans OFF:
+    //   sum(if(t.a > 0, t.v7, r.v7))
+    //   - for scan l: return r.v7 is foreign → l OFF
+    //   - for scan r: return t.v7 is foreign → r OFF
+    // preagg_own_l's k1=0 row gives t.a = 0, so the ELSE branch (r.v7) is 
actually
+    // evaluated, and both tables carry repeated full keys so a wrongly-ON scan
+    // exposes partial rows and changes the oracle. Correct (both OFF, merged):
+    // l = {a=1(v7=30), a=0(v7=12)}, r = {k1=1→300, k1=0→130};
+    // a=1 → if(1>0,30,300)=30, a=0 → if(0>0,12,130)=130 → total 160.
+    // If l were wrongly ON: a=1 and a=0 each appear twice → 10+20+130+130=290.
+    // If r were wrongly ON: r partials fan out → 30+30+50+80=190.
+    explain {
+        sql("""
+            select sum(if(t.a > 0, t.v7, r.v7))
+            from (select abs(k1) as a, v7 from preagg_own_l) t
+            inner join preagg_own_r r on t.a = r.k1;
+        """)
+        notContains "(preagg_own_l), PREAGGREGATION: ON"
+        notContains "(preagg_own_r), PREAGGREGATION: ON"
+    }
+    order_qt_test_b """
+        select sum(if(t.a > 0, t.v7, r.v7)) as res
+        from (select abs(k1) as a, v7 from preagg_own_l) t
+        inner join preagg_own_r r on t.a = r.k1;
+    """
+
+    // Positive MAX join case: MAX is idempotent (max(x, x) = x), so a foreign
+    // value branch in the IF return cannot change the result even under join
+    // fan-out. The ownership fence is skipped for MAX/MIN (it stays for
+    // SUM/COUNT), so both scans may be ON. With the one-time dataset:
+    // l.k1=1 (v9 1000,900) and l.k1=2 (v9 700) all satisfy l.k1 > 0, so
+    // max(if(l.k1 > 0, l.v9, r.v9)) = 1000.
+    explain {
+        sql("""
+            select max(if(l.k1 > 0, l.v9, r.v9))
+            from preagg_t1 l
+            inner join preagg_t2 r on l.k1 = r.k1;
+        """)
+        contains "(preagg_t1), PREAGGREGATION: ON"
+        contains "(preagg_t2), PREAGGREGATION: ON"
+    }
+    order_qt_q34 """
+        select max(if(l.k1 > 0, l.v9, r.v9))
+        from preagg_t1 l
+        inner join preagg_t2 r on l.k1 = r.k1;
+    """
+
+    // Negative: sum(DISTINCT ...) with a nested-aggregate condition slot.
+    //   sum(distinct if(t.c > 0, r.v7, 0))
+    // t.c = sum(t.v7) has no OriginalColumn, so splitKeyValueSlots drops it 
from
+    // the condition check (it is unclassified). Previously this let the route
+    // reach visitSum, which did NOT reject DISTINCT, so preagg_t4 was wrongly
+    // turned ON. Storage SUM would then merge the duplicate full key 
(v7=1+1=2)
+    // and break DISTINCT semantics: ON sees {1,1} → 1 while OFF sees merged 2.
+    // KeyAndValueSlotsAggChecker.visitSum must reject sum.isDistinct() exactly
+    // like OneValueSlotAggChecker.visitSum does.
+    explain {
+        sql("""
+            select sum(distinct if(t.c > 0, r.v7, 0))
+            from (select k1, sum(v7) as c from preagg_t1 group by k1) t
+            inner join preagg_t4 r on t.k1 = r.k1;
+        """)
+        contains "(preagg_t1), PREAGGREGATION: ON"
+        notContains "(preagg_t4), PREAGGREGATION: ON"
+    }
+    order_qt_q35 """
+        select sum(distinct if(t.c > 0, r.v7, 0))
+        from (select k1, sum(v7) as c from preagg_t1 group by k1) t
+        inner join preagg_t4 r on t.k1 = r.k1;
+    """
+
+    // Negative signed-zero: signbit(max(if(k1 > 0, cast(v as float), cast(0 
as float))))
+    // with v a DOUBLE MAX column. No cast is peeled for MAX/MIN: DOUBLE→FLOAT 
can
+    // underflow (-1e-300 → FLOAT -0.0) and change the observable tie 
representative
+    // under signbit, so preagg_t5 must stay OFF. preagg_t5 holds the same 
full key
+    // k1=1 in two loads (v = -1e-300 and +0.0); storage MAX merges to +0.0, 
so the
+    // merged result is signbit(+0.0) = 0.
+    explain {
+        sql("""
+            select signbit(max(if(k1 > 0, cast(v as float), cast(0 as 
float)))) from preagg_t5;
+        """)
+        notContains "(preagg_t5), PREAGGREGATION: ON"
+    }
+    order_qt_q36 """
+        select signbit(max(if(k1 > 0, cast(v as float), cast(0 as float)))) 
from preagg_t5;
+    """
+
+    // Negative ASOF join selected-side: r.v7 is a direct correctly typed SUM
+    // column and ts is an aggregate key, so the match condition references 
only
+    // key columns — the pre-existing join-value check cannot keep r OFF. Only
+    // the ASOF-specific fence (asofSelectedSideRelationIds) forces the 
selected
+    // side OFF. preagg_asof_r loads the SAME full key (grp=1, ts=00:00) twice
+    // with v7=100 and v7=200 in separate rowsets; storage SUM merges to 300.
+    // With r OFF the probe l.ts=00:15 matches the merged row and returns 300.
+    // If r were wrongly ON, ASOF sees the two identical partials (ts=00:00
+    // both) and may pick either 100 or 200 — never 300 — so the oracle
+    // distinguishes a faulty ON from the correct merged result, and the
+    // ASOF-specific OFF reason pins the mechanism under test.
+    explain {
+        sql("""
+            select sum(if(l.grp > 0, r.v7, 0))
+            from preagg_asof_l l asof left join preagg_asof_r r
+                MATCH_CONDITION(l.ts >= r.ts) on l.grp = r.grp
+            where l.grp = 1;
+        """)
+        notContains "(preagg_asof_l), PREAGGREGATION: ON"
+        contains "(preagg_asof_r), PREAGGREGATION: OFF. Reason: can't turn 
preAgg on because the scan is the selected side of an ASOF join"
+    }
+    order_qt_q37 """
+        select sum(if(l.grp > 0, r.v7, 0))
+        from preagg_asof_l l asof left join preagg_asof_r r
+            MATCH_CONDITION(l.ts >= r.ts) on l.grp = r.grp
+        where l.grp = 1;
+    """
+
+    // Positive MAX foreign-branch fanout: preagg_f_l has a non-positive key
+    // (k1=-1) so if(l.k1 > 0, l.v9, r.v9) actually evaluates the foreign 
branch
+    // r.v9, and preagg_f_r repeats BOTH full keys across separate loads
+    // (k1=1: v9=1000,2000; k1=-1: v9=500,600), so PREAGG ON would expose
+    // duplicate full-key partial rows. MAX is idempotent, so the foreign value
+    // fanout cannot change the result: merged r is k1=1→2000, k1=-1→600;
+    // max(if(1>0, 100, ...)=100, if(-1>0, ..., 600)=600) = 600, and ON over
+    // partials gives max(100, 100, 500, 600) = 600 too. Both scans may be ON.
+    explain {
+        sql("""
+            select max(if(l.k1 > 0, l.v9, r.v9))
+            from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+        """)
+        contains "(preagg_f_l), PREAGGREGATION: ON"
+        contains "(preagg_f_r), PREAGGREGATION: ON"
+    }
+    order_qt_q38 """
+        select max(if(l.k1 > 0, l.v9, r.v9))
+        from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+    """
+
+    // MIN symmetry of the foreign-branch fanout, on the MIN column v9m:
+    // min(if(l.k1 > 0, l.v9m, r.v9m)). Merged r is k1=1→min(1000,2000)=1000,
+    // k1=-1→min(500,600)=500; min(100, 500) = 100, and ON over partials gives
+    // min(100, 100, 500, 600) = 100 too. Both scans ON.
+    explain {
+        sql("""
+            select min(if(l.k1 > 0, l.v9m, r.v9m))
+            from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+        """)
+        contains "(preagg_f_l), PREAGGREGATION: ON"
+        contains "(preagg_f_r), PREAGGREGATION: ON"
+    }
+    order_qt_q39 """
+        select min(if(l.k1 > 0, l.v9m, r.v9m))
+        from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+    """
+
+    // Retained non-movable project expressions (e.g. assert_true) must run on
+    // storage-merged rows. pruneOutputs deliberately keeps the unused
+    // assert_true(v7 > 0, 'bad') output; pre-agg ON would evaluate it on the
+    // raw partial row v7=-2 (preagg_g loads (1,-2,10) and (1,3,20) in separate
+    // rowsets) and throw InvalidArgument, while pre-agg OFF merges v7 to
+    // -2+3=1 and the assert passes, returning max(if(1>0, 20, 0)) = 20.
+    explain {
+        sql("""
+            select max(if(k1 > 0, v9, 0))
+            from (select k1, v9, assert_true(v7 > 0, 'bad') as checked from 
preagg_g) t
+        """)
+        notContains "(preagg_g), PREAGGREGATION: ON"
+    }
+    order_qt_test_c """
+        select max(if(k1 > 0, v9, 0))
+        from (select k1, v9, assert_true(v7 > 0, 'bad') as checked from 
preagg_g) t
+    """
+
+    // Slotless volatile retained non-movable outputs (e.g.
+    // assert_true(random() >= 0, 'bad')) have no input slots, so the 
value-slot
+    // fence misses them, and the volatility checks only cover agg/filter/join/
+    // grouping expressions. Pre-agg ON would evaluate random() once per raw
+    // partial row (preagg_g loads (1,-2,10) and (1,3,20) in separate rowsets)
+    // instead of once per merged row — a different evaluation cardinality — so
+    // preagg_g must stay OFF. random() >= 0 always holds, so the assert never
+    // fires and the query still returns 20.
+    explain {
+        sql("""
+            select max(if(k1 > 0, v9, 0))
+            from (select k1, v9, assert_true(random() >= 0, 'bad') as checked 
from preagg_g) t
+        """)
+        notContains "(preagg_g), PREAGGREGATION: ON"
+    }
+    order_qt_test_f """
+        select max(if(k1 > 0, v9, 0))
+        from (select k1, v9, assert_true(random() >= 0, 'bad') as checked from 
preagg_g) t
+    """
+
+    // Exercise the literal acceptance in KeyAndValueSlotsAggChecker.visitMax:
+    // max(if(k6 > 0, v9, 0)) has a cast-free non-NULL literal (0) in the else
+    // branch. It reaches the checker with pre-agg ON — k6 is a key column, v9 
a
+    // MAX column, the literal 0 is accepted. If literal acceptance regressed 
to
+    // NULL-only, this scan would flip OFF and the suite would miss it.
+    explain {
+        sql("""
+            select max(if(k6 > 0, v9, 0)) from preagg_t1;
+        """)
+        contains "(preagg_t1), PREAGGREGATION: ON"
+    }
+    order_qt_test_d """
+        select max(if(k6 > 0, v9, 0)) from preagg_t1;
+    """
+
+    // MIN/CASE symmetry for the parallel changed branch (visitMin): the CASE
+    // else branch holds a cast-free non-NULL literal 0, and preagg_f_l's
+    // non-positive key k1=-1 makes the else branch actually evaluate. pre-agg
+    // stays ON via the same literal acceptance.
+    explain {
+        sql("""
+            select min(case when k1 > 0 then v9m else 0 end) from preagg_f_l;
+        """)
+        contains "(preagg_f_l), PREAGGREGATION: ON"
+    }
+    order_qt_test_e """
+        select min(case when k1 > 0 then v9m else 0 end) from preagg_f_l;
+    """
+
 }


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

Reply via email to