This is an automated email from the ASF dual-hosted git repository.

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 9fb0c9b6141 branch-4.2 [fix](topn-filter) Prevent unsafe TopN filter 
pushdown through outer joins (#70106)
9fb0c9b6141 is described below

commit 9fb0c9b6141d8f3c5855c543e99390f3b1e88a22
Author: minghong <[email protected]>
AuthorDate: Wed Nov 4 08:29:57 2026 +0800

    branch-4.2 [fix](topn-filter) Prevent unsafe TopN filter pushdown through 
outer joins (#70106)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #70100
    
    Backport #70100 (commit b5c622238a8d25369278381222b8937cde43b362) to
    `branch-4.2`.
    
    Prevent TopN runtime filters from removing matching rows on
    null-generating outer join sides when the ordering expression can turn
    NULL into a non-NULL value. Apply the shared safety check to hash and
    nested loop joins and recheck rewritten hash join keys.
    
    The cherry-pick applied without conflicts. The only branch-specific
    adjustment replaces `List.of` with the existing Guava `ImmutableList.of`
    in the new unit tests because `branch-4.2` compiles against Java 8 APIs.
    Production logic and the regression suite are unchanged from the source
    PR.
    
    ### Release note
    
    Fix incorrect TopN query results for outer joins ordered by expressions
    that convert NULL inputs into non-NULL values.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test: `TopNRuntimeFilterTest` — 13 tests passed, 0
    failures/errors/skips, including the three new visitor tests.
    - [ ] Regression test: `topn_filter_outer_join_null` is included; not
    rerun against a running cluster on this branch.
    
    Validation:
    
    - FE production sources compiled successfully.
    - After the Java 8 test adjustment, reran test compilation and the
    targeted suite with the existing production classes: `mvn test -pl
    fe-common,fe-core -am -Dskip.clean=true -Dmaven.main.skip=true
    -Dcheckstyle.skip=true -DfailIfNoTests=false
    -Dtest=org.apache.doris.nereids.postprocess.TopNRuntimeFilterTest` (from
    `fe/`).
    - `mvn checkstyle:check -pl fe-core` — 0 violations.
    
    - Behavior changed:
    - [x] Yes. Unsafe TopN runtime filters are no longer pushed through
    null-generating outer join sides.
    
    - Does this need documentation?
        - [x] No.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 .../processor/post/TopnFilterPushDownVisitor.java  |  29 ++-
 .../nereids/postprocess/TopNRuntimeFilterTest.java |  74 ++++++
 .../topn_filter_outer_join_null.out                | 247 +++++++++++++++++++++
 .../topn_filter_outer_join_null.groovy             |  82 +++++++
 4 files changed, 422 insertions(+), 10 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
index 2519e703c06..7fd3c0727ea 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
@@ -21,9 +21,11 @@ import 
org.apache.doris.nereids.processor.post.TopnFilterPushDownVisitor.PushDow
 import org.apache.doris.nereids.trees.expressions.Alias;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.NullToNonNullFunction;
 import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.functions.scalar.Nullable;
 import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitors;
+import org.apache.doris.nereids.trees.plans.JoinType;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.algebra.TopN;
 import org.apache.doris.nereids.trees.plans.algebra.Union;
@@ -196,11 +198,7 @@ public class TopnFilterPushDownVisitor extends 
PlanVisitor<Boolean, PushDownCont
     @Override
     public Boolean visitPhysicalHashJoin(PhysicalHashJoin<? extends Plan, ? 
extends Plan> join,
             PushDownContext ctx) {
-        if (ctx.nullsFirst && (join.getJoinType().isOuterJoin() || 
join.getJoinType().isAsofOuterJoin())) {
-            // topn-filter can be pushed down to the left child of 
leftOuterJoin
-            // and to the right child of rightOuterJoin,
-            // but PushDownTopNThroughJoin rule already pushes topn to the 
left and right side.
-            // the topn-filter will be generated according to the inferred 
topn node.
+        if (!canPushToJoinLeft(join.getJoinType(), ctx)) {
             return false;
         }
         if 
(join.left().getOutputSet().containsAll(ctx.probeExpr.getInputSlots())) {
@@ -215,6 +213,9 @@ public class TopnFilterPushDownVisitor extends 
PlanVisitor<Boolean, PushDownCont
                 if (ctx.probeExpr.equals(conj.child(1))) {
                     // push to left child. right child is blocking operator, 
do not need topn-filter
                     PushDownContext ctxOnLeftChild = 
ctx.withNewProbeExpression(conj.child(0));
+                    if (!canPushToJoinLeft(join.getJoinType(), 
ctxOnLeftChild)) {
+                        continue;
+                    }
                     PushDownContext childPushDownContext = 
adjustProbeExprNullableThroughOuterJoin(join.left(),
                             ctxOnLeftChild);
                     return join.left().accept(this, childPushDownContext);
@@ -229,11 +230,7 @@ public class TopnFilterPushDownVisitor extends 
PlanVisitor<Boolean, PushDownCont
     @Override
     public Boolean visitPhysicalNestedLoopJoin(PhysicalNestedLoopJoin<? 
extends Plan, ? extends Plan> join,
             PushDownContext ctx) {
-        if (ctx.nullsFirst && join.getJoinType().isOuterJoin()) {
-            // topn-filter can be pushed down to the left child of 
leftOuterJoin
-            // and to the right child of rightOuterJoin,
-            // but PushDownTopNThroughJoin rule already pushes topn to the 
left and right side.
-            // the topn-filter will be generated according to the inferred 
topn node.
+        if (!canPushToJoinLeft(join.getJoinType(), ctx)) {
             return false;
         }
         // push to left child. right child is blocking operator, do not need 
topn-filter
@@ -243,6 +240,18 @@ public class TopnFilterPushDownVisitor extends 
PlanVisitor<Boolean, PushDownCont
         return false;
     }
 
+    private boolean canPushToJoinLeft(JoinType joinType, PushDownContext ctx) {
+        if (ctx.nullsFirst && (joinType.isOuterJoin() || 
joinType.isAsofOuterJoin())) {
+            // PushDownTopNThroughJoin already creates a child TopN for safe 
preserved-side pushdown.
+            // Its filter is generated separately; the parent filter must not 
create leading NULL rows.
+            return false;
+        }
+        // Filtering a left match may make a RIGHT/FULL join emit a new 
null-extended row.
+        // NULLS LAST cannot protect expressions such as COALESCE that turn 
those NULLs into values.
+        return !joinType.isLeftSideNullable()
+                || !ctx.probeExpr.anyMatch(e -> 
NullToNonNullFunction.canConvertNullToNonNull((Expression) e));
+    }
+
     @Override
     public Boolean visitPhysicalRelation(PhysicalRelation relation, 
PushDownContext ctx) {
         if (supportPhysicalRelations(relation) && 
relation.getOutputSet().containsAll(ctx.probeExpr.getInputSlots())) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
index a137487b449..8931377cd59 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
@@ -21,6 +21,8 @@ import org.apache.doris.nereids.datasets.ssb.SSBTestBase;
 import org.apache.doris.nereids.glue.translator.PlanTranslatorContext;
 import org.apache.doris.nereids.processor.post.PlanPostProcessors;
 import org.apache.doris.nereids.processor.post.TopnFilterContext;
+import org.apache.doris.nereids.processor.post.TopnFilterPushDownVisitor;
+import 
org.apache.doris.nereids.processor.post.TopnFilterPushDownVisitor.PushDownContext;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
@@ -29,7 +31,9 @@ import 
org.apache.doris.nereids.trees.expressions.functions.scalar.Substring;
 import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.SortPhase;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin;
 import 
org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalNestedLoopJoin;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalRelation;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalTopN;
@@ -279,6 +283,76 @@ public class TopNRuntimeFilterTest extends SSBTestBase 
implements MemoPatternMat
         }
     }
 
+    @Test
+    public void testTopNFilterRejectsNullToNonNullThroughOuterJoin() {
+        checkTopNFilterThroughJoin(ImmutableList.of(
+                "coalesce(l.k, -1)",
+                "ifnull(l.k, -1)",
+                "if(l.k is null, -1, l.k)",
+                "case when l.k is null then -1 else l.k end",
+                "coalesce(l.k, -1) - 1",
+                "cast(l.k is null as int)",
+                "cast(l.k <=> 0 as int)",
+                "cast(to_json(array(l.k)) as string)"), 
ImmutableList.of("RIGHT OUTER", "FULL OUTER"), false, false);
+    }
+
+    @Test
+    public void testTopNFilterPreservesNullPropagatingOuterJoinExpressions() {
+        checkTopNFilterThroughJoin(ImmutableList.of("l.k", "cast(l.k as 
bigint)", "abs(l.k)"),
+                ImmutableList.of("RIGHT OUTER", "FULL OUTER"), false, true);
+        checkTopNFilterThroughJoin(ImmutableList.of("l.k"), 
ImmutableList.of("RIGHT OUTER", "FULL OUTER"), true, false);
+    }
+
+    @Test
+    public void testTopNFilterPreservesNonNullGeneratingJoinSide() {
+        checkTopNFilterThroughJoin(ImmutableList.of("coalesce(l.k, -1)"),
+                ImmutableList.of("INNER", "LEFT OUTER"), false, true);
+    }
+
+    private void checkTopNFilterThroughJoin(List<String> expressions, 
List<String> joinTypes,
+            boolean nullsFirst, boolean expectedFilter) {
+        double previousRatio = 
connectContext.getSessionVariable().topnFilterRatio;
+        boolean previousJoinReorder = 
connectContext.getSessionVariable().isDisableJoinReorder();
+        String previousDisabledRules = String.join(",",
+                
connectContext.getSessionVariable().getDisableNereidsRuleNames());
+        connectContext.getSessionVariable().topnFilterRatio = 1000000;
+        connectContext.getSessionVariable().setDisableJoinReorder(true);
+        
connectContext.getSessionVariable().setDisableNereidsRules(previousDisabledRules
 + ",PRUNE_EMPTY_PARTITION");
+        try {
+            for (String joinType : joinTypes) {
+                for (boolean hashJoin : ImmutableList.of(true, false)) {
+                    String condition = hashJoin ? "l.id = r.id" : "l.id < 
r.id";
+                    for (String expression : expressions) {
+                        String sql = "SELECT " + expression + " AS sort_key 
FROM topn_safety l "
+                                + joinType + " JOIN topn_safety r ON " + 
condition
+                                + " ORDER BY sort_key NULLS " + (nullsFirst ? 
"FIRST" : "LAST") + " LIMIT 1";
+                        PlanChecker checker = 
PlanChecker.from(connectContext).analyze(sql).rewrite().implement();
+                        PhysicalPlan plan = checker.getPhysicalPlan();
+                        Assertions.assertTrue(plan.anyMatch(node -> hashJoin
+                                ? node instanceof PhysicalHashJoin : node 
instanceof PhysicalNestedLoopJoin),
+                                sql + "\n" + plan.treeString());
+                        // Exercise the visitor even when the planner chooses 
a single-stage sort.
+                        List<PhysicalTopN<? extends Plan>> topns = 
plan.collectToList(
+                                node -> node instanceof PhysicalTopN);
+                        Assertions.assertFalse(topns.isEmpty(), 
plan.treeString());
+                        TopnFilterContext filters = new TopnFilterContext();
+                        TopnFilterPushDownVisitor visitor = new 
TopnFilterPushDownVisitor(filters);
+                        for (PhysicalTopN<? extends Plan> topn : topns) {
+                            topn.accept(visitor, new PushDownContext(topn, 
topn.getOrderKeys().get(0).getExpr(),
+                                    topn.getOrderKeys().get(0).isNullFirst()));
+                        }
+                        Assertions.assertEquals(expectedFilter, 
!filters.getTopnFilters().isEmpty(),
+                                sql + "\n" + plan.treeString());
+                    }
+                }
+            }
+        } finally {
+            connectContext.getSessionVariable().topnFilterRatio = 
previousRatio;
+            
connectContext.getSessionVariable().setDisableJoinReorder(previousJoinReorder);
+            
connectContext.getSessionVariable().setDisableNereidsRules(previousDisabledRules);
+        }
+    }
+
     @Test
     public void testProbeExprNullableThroughRightOuterJoin() {
         // topn node push down filter value to scan node.
diff --git 
a/regression-test/data/nereids_syntax_p0/topn_filter_outer_join_null.out 
b/regression-test/data/nereids_syntax_p0/topn_filter_outer_join_null.out
new file mode 100644
index 00000000000..1376f7a7ea1
--- /dev/null
+++ b/regression-test/data/nereids_syntax_p0/topn_filter_outer_join_null.out
@@ -0,0 +1,247 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+-1     0
+
+-- !disabled --
+-1     0
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+0      1
+
+-- !disabled --
+0      1
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+-1     0
+
+-- !disabled --
+-1     0
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+0      1
+
+-- !disabled --
+0      1
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+-1     0
+
+-- !disabled --
+-1     0
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+0      1
+
+-- !disabled --
+0      1
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+0      0
+
+-- !disabled --
+0      0
+
+-- !enabled --
+-1     0
+
+-- !disabled --
+-1     0
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+1      1
+
+-- !disabled --
+1      1
+
+-- !enabled --
+0      1
+
+-- !disabled --
+0      1
+
+-- !bare_key --
+0      0
+
+-- !bare_key --
+0      0
+
diff --git 
a/regression-test/suites/nereids_syntax_p0/topn_filter_outer_join_null.groovy 
b/regression-test/suites/nereids_syntax_p0/topn_filter_outer_join_null.groovy
new file mode 100644
index 00000000000..b93c34907cd
--- /dev/null
+++ 
b/regression-test/suites/nereids_syntax_p0/topn_filter_outer_join_null.groovy
@@ -0,0 +1,82 @@
+// 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.
+
+suite("topn_filter_outer_join_null", "p0") {
+    sql "SET enable_sql_cache = false"
+    sql "SET enable_query_cache = false"
+    sql "SET disable_join_reorder = true"
+    sql "SET parallel_pipeline_task_num = 1"
+    sql "DROP TABLE IF EXISTS topn_filter_outer_join_null"
+    sql """CREATE TABLE topn_filter_outer_join_null (k INT)
+        DUPLICATE KEY(k)
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES("replication_num" = "1")"""
+    sql "INSERT INTO topn_filter_outer_join_null VALUES (0), (1)"
+
+    for (String joinType : ["FULL OUTER", "RIGHT OUTER"]) {
+        // The inequalities match the same integer keys while requiring a 
nested loop join.
+        for (String condition : ["l.k = r.k", "l.k < r.k + 1 AND l.k > r.k - 
1"]) {
+            for (String direction : ["ASC", "DESC"]) {
+                int firstKey = direction == "ASC" ? 0 : 1
+                int fallback = direction == "ASC" ? -1 : 2
+                String probe = """(
+                    SELECT k FROM topn_filter_outer_join_null WHERE k = 
${firstKey}
+                    UNION ALL
+                    SELECT k FROM topn_filter_outer_join_null
+                    WHERE k != ${firstKey} AND sleep(k + ${firstKey}) = 0
+                )"""
+                for (String expression : ["coalesce(l.k, ${fallback})",
+                        "ifnull(l.k, ${fallback})",
+                        "if(l.k is null, ${fallback}, l.k)",
+                        "case when l.k is null then ${fallback} else l.k end",
+                        "coalesce(l.k, ${fallback}) - 1"]) {
+                    String query = """SELECT ${expression} AS sort_key, 
coalesce(r.k, -1)
+                        FROM ${probe} l ${joinType} JOIN 
topn_filter_outer_join_null r ON ${condition}
+                        ORDER BY sort_key ${direction} NULLS LAST, 2 NULLS 
LAST LIMIT 1"""
+                    sql "SET topn_filter_ratio = 1000000"
+                    explain {
+                        sql query
+                        contains(condition == "l.k = r.k" ? "HASH JOIN" : 
"NESTED LOOP JOIN")
+                        notContains "TOPN OPT:"
+                    }
+                    qt_enabled query
+                    sql "SET topn_filter_ratio = 0"
+                    qt_disabled query
+                }
+            }
+        }
+    }
+
+    sql "SET topn_filter_ratio = 1000000"
+    for (String nullOrder : ["FIRST", "LAST"]) {
+        String query = """SELECT l.k, r.k FROM (
+            SELECT k FROM topn_filter_outer_join_null WHERE k = 0
+            UNION ALL
+            SELECT k FROM topn_filter_outer_join_null WHERE k > 0 AND sleep(k) 
= 0
+        ) l FULL OUTER JOIN topn_filter_outer_join_null r ON l.k = r.k
+        ORDER BY l.k NULLS ${nullOrder}, r.k NULLS LAST LIMIT 1"""
+        explain {
+            sql query
+            if (nullOrder == "FIRST") {
+                notContains "TOPN OPT:"
+            } else {
+                contains "TOPN OPT:"
+            }
+        }
+        qt_bare_key query
+    }
+}


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

Reply via email to