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 cb580c38d3d branch-4.1: [fix](rbo) Guard partial TopN pushdown through 
joins #67796 (#68235)
cb580c38d3d is described below

commit cb580c38d3d5c0b16adf14ab217e0d0a26446333
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Sep 20 19:49:10 2026 +0800

    branch-4.1: [fix](rbo) Guard partial TopN pushdown through joins #67796 
(#68235)
    
    Cherry-picked from #67796
    
    Co-authored-by: morrySnow <[email protected]>
---
 .../apache/doris/nereids/properties/DataTrait.java |   3 +-
 .../doris/nereids/properties/FuncDepsDG.java       |  53 ++++++++
 .../rewrite/PushDownTopNDistinctThroughJoin.java   |  42 +++++-
 .../doris/nereids/properties/FuncDepsDGTest.java   |  15 +++
 .../PushDownTopNDistinctThroughJoinTest.java       | 141 +++++++++++++++++++++
 .../push_down_top_n_distinct_through_join.out      |   7 +
 .../push_down_top_n_distinct_through_join.groovy   |  41 ++++++
 7 files changed, 294 insertions(+), 8 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DataTrait.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DataTrait.java
index 4b9409f553a..9c3ef231f1d 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DataTrait.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/DataTrait.java
@@ -73,8 +73,7 @@ public class DataTrait {
     }
 
     public boolean isDependent(Set<Slot> dominate, Set<Slot> dependency) {
-        return fdDg.findValidFuncDeps(Sets.union(dependency, dominate))
-                .isFuncDeps(dominate, dependency);
+        return fdDg.isDependent(dominate, dependency);
     }
 
     public boolean isUnique(Slot slot) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/FuncDepsDG.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/FuncDepsDG.java
index 425273fda35..317bdeb34be 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/FuncDepsDG.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/FuncDepsDG.java
@@ -24,6 +24,7 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -97,6 +98,58 @@ public class FuncDepsDG {
         return dgItems.isEmpty();
     }
 
+    /**
+     * Checks whether the determinant closure contains every dependency slot.
+     *
+     * <p>Nodes are indexed by each slot that they are still waiting for. When 
a dependency edge adds a slot to
+     * the closure, only nodes waiting for that slot are revisited. Each node 
and edge is expanded at most once,
+     * avoiding construction of the graph's full transitive FD relation.</p>
+     */
+    public boolean isDependent(Set<Slot> determinants, Set<Slot> dependencies) 
{
+        Set<Slot> closure = new HashSet<>(determinants);
+        if (closure.containsAll(dependencies)) {
+            return true;
+        }
+
+        int[] missingSlotCounts = new int[dgItems.size()];
+        Map<Slot, List<Integer>> waitingNodes = new HashMap<>();
+        ArrayDeque<Integer> readyNodes = new ArrayDeque<>();
+        for (DGItem item : dgItems) {
+            for (Slot slot : item.slots) {
+                if (!closure.contains(slot)) {
+                    missingSlotCounts[item.index]++;
+                    waitingNodes.computeIfAbsent(slot, key -> new 
ArrayList<>()).add(item.index);
+                }
+            }
+            if (missingSlotCounts[item.index] == 0) {
+                readyNodes.add(item.index);
+            }
+        }
+
+        while (!readyNodes.isEmpty()) {
+            DGItem item = dgItems.get(readyNodes.remove());
+            for (int childIndex : item.children) {
+                for (Slot slot : dgItems.get(childIndex).slots) {
+                    if (closure.add(slot)) {
+                        List<Integer> nodes = waitingNodes.get(slot);
+                        if (nodes != null) {
+                            for (int nodeIndex : nodes) {
+                                missingSlotCounts[nodeIndex]--;
+                                if (missingSlotCounts[nodeIndex] == 0) {
+                                    readyNodes.add(nodeIndex);
+                                }
+                            }
+                        }
+                    }
+                }
+                if (closure.containsAll(dependencies)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
     /**
      * Finds all functional dependencies that are applicable to a given set of 
valid slots.
      */
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoin.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoin.java
index 4b0b8a89dc4..da1eb1eec60 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoin.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoin.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.rules.rewrite;
 
+import org.apache.doris.nereids.properties.DataTrait;
 import org.apache.doris.nereids.properties.OrderKey;
 import org.apache.doris.nereids.rules.Rule;
 import org.apache.doris.nereids.rules.RuleType;
@@ -32,6 +33,7 @@ import org.apache.doris.qe.ConnectContext;
 
 import com.google.common.collect.ImmutableList;
 
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -104,7 +106,7 @@ public class PushDownTopNDistinctThroughJoin implements 
RewriteRuleFactory {
                     return null;
                 }
                 List<OrderKey> pushedOrderKeys = 
getPushedOrderKeys(groupBySlots,
-                        join.left().getOutputSet(), topN.getOrderKeys());
+                        join.left(), topN.getOrderKeys());
                 if (!pushedOrderKeys.isEmpty()) {
                     LogicalTopN<Plan> left = topN.withLimitOrderKeyAndChild(
                             topN.getLimit() + topN.getOffset(), 0, 
pushedOrderKeys,
@@ -119,7 +121,7 @@ public class PushDownTopNDistinctThroughJoin implements 
RewriteRuleFactory {
                     return null;
                 }
                 List<OrderKey> pushedOrderKeys = 
getPushedOrderKeys(groupBySlots,
-                        join.right().getOutputSet(), topN.getOrderKeys());
+                        join.right(), topN.getOrderKeys());
                 if (!pushedOrderKeys.isEmpty()) {
                     LogicalTopN<Plan> right = topN.withLimitOrderKeyAndChild(
                             topN.getLimit() + topN.getOffset(), 0, 
pushedOrderKeys,
@@ -132,14 +134,14 @@ public class PushDownTopNDistinctThroughJoin implements 
RewriteRuleFactory {
                 Plan leftChild = join.left();
                 Plan rightChild = join.right();
                 List<OrderKey> leftPushedOrderKeys = 
getPushedOrderKeys(groupBySlots,
-                        join.left().getOutputSet(), topN.getOrderKeys());
+                        join.left(), topN.getOrderKeys());
                 if (!(join.left() instanceof TopN) && 
!leftPushedOrderKeys.isEmpty()) {
                     leftChild = topN.withLimitOrderKeyAndChild(
                             topN.getLimit() + topN.getOffset(), 0, 
leftPushedOrderKeys,
                             PlanUtils.distinct(join.left()));
                 }
                 List<OrderKey> rightPushedOrderKeys = 
getPushedOrderKeys(groupBySlots,
-                        join.right().getOutputSet(), topN.getOrderKeys());
+                        join.right(), topN.getOrderKeys());
                 if (!(join.right() instanceof TopN) && 
!rightPushedOrderKeys.isEmpty()) {
                     rightChild = topN.withLimitOrderKeyAndChild(
                             topN.getLimit() + topN.getOffset(), 0, 
rightPushedOrderKeys,
@@ -160,8 +162,9 @@ public class PushDownTopNDistinctThroughJoin implements 
RewriteRuleFactory {
     /**
      * return pushed order-keys. If top-n distinct cannot be pushed, return 
empty list.
      */
-    private List<OrderKey> getPushedOrderKeys(Set<Slot> groupBySlots, 
Set<Slot> joinChildSlot,
+    private List<OrderKey> getPushedOrderKeys(Set<Slot> groupBySlots, Plan 
joinChild,
             List<OrderKey> orderKeys) {
+        Set<Slot> joinChildSlot = joinChild.getOutputSet();
         // NOTICE: Currently, we have implemented strict restrictions to 
ensure that the distinct columns is
         //   a superset of the output from the corresponding child of the join 
operator. In the future, we can relax
         //   this restriction and only require that there is overlap between 
the output of the corresponding child of
@@ -187,6 +190,33 @@ public class PushDownTopNDistinctThroughJoin implements 
RewriteRuleFactory {
                 notFound = true;
             }
         }
-        return pushedOrderKeys.build();
+        List<OrderKey> pushedOrderKeyList = pushedOrderKeys.build();
+        if (pushedOrderKeyList.size() == orderKeys.size()
+                || isOrderKeyPrefixUniqueAfterDistinct(joinChild, 
pushedOrderKeyList)) {
+            return pushedOrderKeyList;
+        }
+        return ImmutableList.of();
+    }
+
+    /**
+     * A partial order-key prefix is safe for a hard limit only when it 
uniquely orders the rows produced by
+     * {@link PlanUtils#distinct(Plan)}. This is true when a leading part of 
the prefix either is already a
+     * non-null unique key, covers every child output, or functionally 
determines every remaining child output.
+     */
+    private boolean isOrderKeyPrefixUniqueAfterDistinct(Plan joinChild, 
List<OrderKey> orderKeyPrefix) {
+        if (orderKeyPrefix.isEmpty()) {
+            return false;
+        }
+        Set<Slot> childOutput = joinChild.getOutputSet();
+        Set<Slot> prefixSlots = new HashSet<>();
+        for (OrderKey orderKey : orderKeyPrefix) {
+            prefixSlots.add((Slot) orderKey.getExpr());
+        }
+        if (prefixSlots.containsAll(childOutput)) {
+            return true;
+        }
+        DataTrait childTrait = joinChild.getLogicalProperties().getTrait();
+        return childTrait.isUniqueAndNotNull(prefixSlots)
+                || childTrait.isDependent(prefixSlots, childOutput);
     }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/FuncDepsDGTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/FuncDepsDGTest.java
index d504176e807..3ead4833f17 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/FuncDepsDGTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/properties/FuncDepsDGTest.java
@@ -52,6 +52,21 @@ class FuncDepsDGTest {
         Assertions.assertEquals(1, res.size());
     }
 
+    @Test
+    void testDependencyClosure() {
+        FuncDepsDG.Builder dg = new FuncDepsDG.Builder();
+        Slot s1 = new SlotReference("s1", IntegerType.INSTANCE);
+        Slot s2 = new SlotReference("s2", IntegerType.INSTANCE);
+        Slot s3 = new SlotReference("s3", IntegerType.INSTANCE);
+        Slot s4 = new SlotReference("s4", IntegerType.INSTANCE);
+        dg.addDeps(Sets.newHashSet(s1), Sets.newHashSet(s2));
+        dg.addDeps(Sets.newHashSet(s2, s3), Sets.newHashSet(s4));
+
+        FuncDepsDG funcDeps = dg.build();
+        Assertions.assertTrue(funcDeps.isDependent(Sets.newHashSet(s1, s3), 
Sets.newHashSet(s2, s4)));
+        Assertions.assertFalse(funcDeps.isDependent(Sets.newHashSet(s1), 
Sets.newHashSet(s4)));
+    }
+
     @Test
     void testCircle() {
         FuncDepsDG.Builder dg = new FuncDepsDG.Builder();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoinTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoinTest.java
new file mode 100644
index 00000000000..07b0ae8a679
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushDownTopNDistinctThroughJoinTest.java
@@ -0,0 +1,141 @@
+// 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.KeysType;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.common.Pair;
+import org.apache.doris.nereids.trees.expressions.Add;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.plans.JoinType;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.util.LogicalPlanBuilder;
+import org.apache.doris.nereids.util.MemoPatternMatchSupported;
+import org.apache.doris.nereids.util.PlanChecker;
+import org.apache.doris.nereids.util.PlanConstructor;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class PushDownTopNDistinctThroughJoinTest implements MemoPatternMatchSupported 
{
+    private static final PushDownTopNDistinctThroughJoin RULE = new 
PushDownTopNDistinctThroughJoin();
+    private static final LogicalOlapScan LEFT_SCAN = 
PlanConstructor.newLogicalOlapScan(0, "t1", 0);
+    private static final LogicalOlapScan RIGHT_SCAN = 
PlanConstructor.newLogicalOlapScan(1, "t2", 0);
+    private ConnectContext connectContext;
+
+    @BeforeEach
+    void setUp() {
+        connectContext = new ConnectContext();
+        connectContext.setThreadLocalInfo();
+    }
+
+    @AfterEach
+    void tearDown() {
+        ConnectContext.remove();
+    }
+
+    @Test
+    void pushDirectShapeWhenAccumulatedPrefixDeterminesChildOutput() {
+        NamedExpression id = LEFT_SCAN.getOutput().get(0);
+        LogicalPlan left = new LogicalPlanBuilder(LEFT_SCAN)
+                .projectExprs(ImmutableList.of(id, 
LEFT_SCAN.getOutput().get(1),
+                        new Alias(new Add(id, new IntegerLiteral(1)), 
"id_plus_one")))
+                .build();
+        LogicalPlan plan = new LogicalPlanBuilder(left)
+                .join(RIGHT_SCAN, JoinType.LEFT_OUTER_JOIN, Pair.of(0, 0))
+                .distinct(ImmutableList.of(0, 1, 2, 3, 4))
+                .topN(10, 0, ImmutableList.of(0, 1, 3))
+                .build();
+
+        PlanChecker.from(connectContext, plan)
+                .applyTopDown(RULE)
+                .matchesFromRoot(
+                        logicalTopN(
+                                logicalAggregate(
+                                        logicalJoin(
+                                                
logicalTopN(logicalAggregate(logicalProject(logicalOlapScan())))
+                                                        .when(topN -> 
topN.getLimit() == 10),
+                                                logicalOlapScan()
+                                        )
+                                )
+                        )
+                );
+    }
+
+    @Test
+    void pushAllSlotsProjectShapeForNonNullUniquePrefix() {
+        LogicalOlapScan uniqueScan = newUniqueScan(2, "unique_not_null", 
false);
+        LogicalPlan join = new LogicalPlanBuilder(uniqueScan)
+                .join(RIGHT_SCAN, JoinType.LEFT_OUTER_JOIN, Pair.of(0, 0))
+                .build();
+        LogicalPlan plan = new LogicalPlanBuilder(join)
+                
.projectExprs(ImmutableList.<NamedExpression>builder().addAll(join.getOutput()).build())
+                .distinct(ImmutableList.of(0, 1, 2, 3))
+                .topN(10, 0, ImmutableList.of(0, 2))
+                .build();
+
+        PlanChecker.from(connectContext, plan)
+                .applyTopDown(RULE)
+                .matchesFromRoot(
+                        logicalTopN(
+                                logicalProject(
+                                        logicalAggregate(
+                                                logicalJoin(
+                                                        
logicalTopN(logicalAggregate(logicalOlapScan()))
+                                                                .when(topN -> 
topN.getLimit() == 10),
+                                                        logicalOlapScan()
+                                                )
+                                        )
+                                )
+                        )
+                );
+    }
+
+    @Test
+    void rejectNullableUniquePrefix() {
+        LogicalOlapScan nullableUniqueScan = newUniqueScan(3, 
"unique_nullable", true);
+        LogicalPlan plan = new LogicalPlanBuilder(nullableUniqueScan)
+                .join(RIGHT_SCAN, JoinType.LEFT_OUTER_JOIN, Pair.of(0, 0))
+                .distinct(ImmutableList.of(0, 1, 2, 3))
+                .topN(10, 0, ImmutableList.of(0, 2))
+                .build();
+
+        PlanChecker.from(connectContext, plan)
+                .applyTopDown(RULE)
+                .matchesFromRoot(
+                        logicalTopN(
+                                logicalAggregate(
+                                        logicalJoin(logicalOlapScan(), 
logicalOlapScan())
+                                )
+                        )
+                );
+    }
+
+    private LogicalOlapScan newUniqueScan(long tableId, String tableName, 
boolean keyNullable) {
+        OlapTable table = PlanConstructor.newOlapTable(tableId, tableName, 0, 
KeysType.UNIQUE_KEYS);
+        table.getFullSchema().get(0).setIsAllowNull(keyNullable);
+        table.getFullSchema().get(1).setIsKey(false);
+        return new LogicalOlapScan(PlanConstructor.getNextRelationId(), table, 
ImmutableList.of("db"));
+    }
+}
diff --git 
a/regression-test/data/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.out
 
b/regression-test/data/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.out
index c5f90edd139..96d3f10ae0d 100644
--- 
a/regression-test/data/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.out
+++ 
b/regression-test/data/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.out
@@ -45,3 +45,10 @@ PhysicalResultSink
 6
 7
 
+-- !partial_prefix_asc --
+1      0       10
+
+-- !partial_prefix_desc_offset --
+6      0       60
+7      0       70
+
diff --git 
a/regression-test/suites/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.groovy
 
b/regression-test/suites/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.groovy
index 3f959c91fdc..e05dd203d86 100644
--- 
a/regression-test/suites/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.groovy
+++ 
b/regression-test/suites/nereids_rules_p0/push_down_top_n/push_down_top_n_distinct_through_join.groovy
@@ -67,4 +67,45 @@ suite("push_down_top_n_distinct_through_join") {
     qt_push_down_topn_through_join_data """
         select distinct * from (select t1.id from table_join t1 cross join 
table_join t2) t order by id limit 10;
     """
+
+    sql "DROP TABLE IF EXISTS topn_distinct_left"
+    sql "DROP TABLE IF EXISTS topn_distinct_right"
+    sql """
+        CREATE TABLE topn_distinct_left (
+            k INT NOT NULL,
+            id INT NOT NULL
+        ) DUPLICATE KEY(k, id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES("replication_num" = "1")
+    """
+    sql """
+        CREATE TABLE topn_distinct_right (
+            id INT NOT NULL,
+            s INT NOT NULL
+        ) DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES("replication_num" = "1")
+    """
+    sql """
+        INSERT INTO topn_distinct_left VALUES
+            (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8)
+    """
+    sql """
+        INSERT INTO topn_distinct_right VALUES
+            (1, 10), (2, 20), (3, 30), (4, 40), (5, 50), (6, 60), (7, 70), (8, 
80)
+    """
+
+    order_qt_partial_prefix_asc """
+        SELECT DISTINCT l.id, l.k, r.s
+        FROM topn_distinct_left l LEFT JOIN topn_distinct_right r ON l.id = 
r.id
+        ORDER BY l.k ASC, r.s ASC
+        LIMIT 1
+    """
+
+    order_qt_partial_prefix_desc_offset """
+        SELECT DISTINCT l.id, l.k, r.s
+        FROM topn_distinct_left l LEFT JOIN topn_distinct_right r ON l.id = 
r.id
+        ORDER BY l.k ASC, r.s DESC
+        LIMIT 2 OFFSET 1
+    """
 }


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

Reply via email to