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

BiteTheDDDDt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 271d56a379d [fix](fe) Prevent unsafe CTE runtime filter pushdown 
(#65247)
271d56a379d is described below

commit 271d56a379dbb043b66c20553c8c799d7d100fa5
Author: Pxl <[email protected]>
AuthorDate: Wed Jul 8 12:29:08 2026 +0800

    [fix](fe) Prevent unsafe CTE runtime filter pushdown (#65247)
    
    Related PR: #62383
    
    Problem Summary:
    RQG found a wrong-result case in the OR expansion + anti join + shared
    scan shape. A representative query is:
    
    ```sql
    SET enable_sql_cache = false;
    SET enable_runtime_filter_prune = false;
    
    SELECT COUNT(*)
    FROM (
        SELECT *
        FROM table_5 AS t1
        LEFT ANTI JOIN table_9 AS t2
            ON t1.pk + 0 = t2.pk + 6
            OR t1.pk + 1 = t2.pk
        INNER JOIN table_20 AS t3
    ) q;
    ```
    
    With runtime filters disabled (`SET runtime_filter_type = ''`), the RQG
    case returns the expected count `0`. With runtime filters enabled before
    this fix, it returned `100`.
    
    The regression was introduced by #62383, which unified runtime filter
    generation and changed runtime filters to the v2-style single-target
    pushdown path. That made OR-expanded branches create separate runtime
    filters on CTE consumers of the same shared producer. In this case the
    consumers use different probe expressions after mapping back to the
    producer, such as `pk + 6` and `pk - 1`.
    
    The shared CTE producer pushdown only checked that every consumer had a
    runtime filter with the same build-side source expression. It did not
    check that the consumer probe expressions mapped to the same
    producer-side target expression. It then pushed the filter using only
    the producer slot, losing the `+ 6` / `- 1` expression difference. As a
    result, one branch-specific runtime filter could be applied to the
    shared scan and affect the other branch, causing the anti join to return
    wrong results.
    
    The added regression case uses a shared CTE with two consumers and a
    build row `10`; one consumer needs `cast(pk as bigint) + 6 = 10`, the
    other needs `cast(pk as bigint) - 1 = 10`. The correct count is `1` both
    with runtime filters enabled and disabled.
    
    This patch only pushes runtime filters into a shared CTE producer when
    all candidate filters map to the same producer-side target expression,
    and it preserves that full producer target expression during pushdown.
    
    ### Release note
    
    Fix incorrect query results caused by unsafe runtime filter pushdown
    into shared CTE producers.
---
 .../processor/post/RuntimeFilterGenerator.java     | 43 ++++++++++++++++-
 .../nereids/postprocess/RuntimeFilterTest.java     | 46 ++++++++++++++++++
 .../runtime_filter/cte-runtime-filter.groovy       | 54 +++++++++++++++++++++-
 3 files changed, 140 insertions(+), 3 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterGenerator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterGenerator.java
index 96cc0b0e7a1..22e9c00b2aa 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterGenerator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/RuntimeFilterGenerator.java
@@ -60,12 +60,15 @@ import org.apache.doris.statistics.Statistics;
 import org.apache.doris.thrift.TMinMaxRuntimeFilterType;
 import org.apache.doris.thrift.TRuntimeFilterType;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
 
 import java.util.HashMap;
 import java.util.HashSet;
@@ -88,6 +91,8 @@ public class RuntimeFilterGenerator extends PlanPostProcessor 
{
             JoinType.NULL_AWARE_LEFT_ANTI_JOIN
     );
 
+    private static final Logger LOG = 
LogManager.getLogger(RuntimeFilterGenerator.class);
+
     private static final Set<Class<? extends PhysicalPlan>> SPJ_PLAN = 
ImmutableSet.of(
             PhysicalRelation.class,
             PhysicalProject.class,
@@ -158,6 +163,9 @@ public class RuntimeFilterGenerator extends 
PlanPostProcessor {
                         if (rfsToPushDown.isEmpty()) {
                             break;
                         }
+                        if 
(!canPushDownRuntimeFiltersIntoCTEProducer(rfsToPushDown, cteId)) {
+                            continue;
+                        }
 
                         // the most right deep buildNode from rfsToPushDown is 
used as buildNode for pushDown rf
                         // since the srcExpr are the same, all buildNodes of 
rfToPushDown are in the same tree path
@@ -872,6 +880,35 @@ public class RuntimeFilterGenerator extends 
PlanPostProcessor {
         return expression instanceof Slot ? ((Slot) expression) : null;
     }
 
+    /**
+     * Check whether runtime filters on CTE consumers can be pushed into their 
shared CTE producer.
+     */
+    @VisibleForTesting
+    public static boolean canPushDownRuntimeFiltersIntoCTEProducer(
+            List<RuntimeFilter> rfsToPushDown, CTEId cteId) {
+        if (rfsToPushDown.isEmpty()) {
+            LOG.warn("Skip pushing runtime filters into CTE producer because 
no runtime filters exist for cteId: {}",
+                    cteId);
+            return false;
+        }
+        Set<Expression> producerTargetExpressions = rfsToPushDown.stream()
+                .map(rf -> getProducerTargetExpression(rf, cteId))
+                .collect(Collectors.toSet());
+        return producerTargetExpressions.size() == 1;
+    }
+
+    private static Expression getProducerTargetExpression(RuntimeFilter rf, 
CTEId cteId) {
+        PhysicalRelation rel = rf.getTargetScan();
+        Preconditions.checkArgument(rel instanceof PhysicalCTEConsumer
+                && ((PhysicalCTEConsumer) rel).getCteId().equals(cteId));
+        PhysicalCTEConsumer consumer = (PhysicalCTEConsumer) rel;
+        Map<Expression, Expression> replaceMap = Maps.newHashMap();
+        for (Slot slot : rf.getTargetExpression().getInputSlots()) {
+            replaceMap.put(slot, consumer.getProducerSlot(slot));
+        }
+        return ExpressionUtils.replace(rf.getTargetExpression(), replaceMap);
+    }
+
     private boolean doPushDownIntoCTEProducerInternal(RuntimeFilter rf, 
Expression targetExpression,
                                                     RuntimeFilterContext ctx, 
PhysicalCTEProducer cteProducer) {
         PhysicalPlan inputPlanNode = (PhysicalPlan) cteProducer.child(0);
@@ -895,7 +932,9 @@ public class RuntimeFilterGenerator extends 
PlanPostProcessor {
         }
         // Map consumer slot to producer slot
         Slot producerSlot = cteConsumer.getProducerSlot(consumerSlot);
-        if (producerSlot == null) {
+        Expression producerTargetExpression = getProducerTargetExpression(rf, 
cteProducer.getCteId());
+        Slot producerTargetSlot = checkTargetChild(producerTargetExpression);
+        if (!producerSlot.equals(producerTargetSlot)) {
             return false;
         }
         if (!checkCanPushDownIntoBasicTable(inputPlanNode)) {
@@ -904,7 +943,7 @@ public class RuntimeFilterGenerator extends 
PlanPostProcessor {
         // Use the PushDownVisitor to push inside the CTE producer subtree
         RuntimeFilterPushDownVisitor.PushDownContext pushDownContext =
                 
RuntimeFilterPushDownVisitor.PushDownContext.createPushDownContext(
-                        ctx, rf.getBuilderNode(), rf.getSrcExpr(), 
producerSlot,
+                        ctx, rf.getBuilderNode(), rf.getSrcExpr(), 
producerTargetExpression,
                         rf.getType(), rf.gettMinMaxType(),
                         !rf.isBloomFilterSizeCalculatedByNdv(), 
rf.getBuildSideNdv(), rf.getExprOrder());
         if (pushDownContext.isValid()) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/RuntimeFilterTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/RuntimeFilterTest.java
index 7a34e0923c6..6a5b858255a 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/RuntimeFilterTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/RuntimeFilterTest.java
@@ -28,12 +28,19 @@ import org.apache.doris.nereids.hint.DistributeHint;
 import org.apache.doris.nereids.parser.NereidsParser;
 import org.apache.doris.nereids.processor.post.PlanPostProcessors;
 import org.apache.doris.nereids.processor.post.RuntimeFilterContext;
+import org.apache.doris.nereids.processor.post.RuntimeFilterGenerator;
 import org.apache.doris.nereids.properties.PhysicalProperties;
+import org.apache.doris.nereids.trees.expressions.Add;
 import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.CTEId;
 import org.apache.doris.nereids.trees.expressions.EqualTo;
 import org.apache.doris.nereids.trees.expressions.ExprId;
+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.Subtract;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
 import org.apache.doris.nereids.trees.plans.DistributeType;
 import org.apache.doris.nereids.trees.plans.JoinType;
@@ -41,20 +48,25 @@ import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.commands.ExplainCommand;
 import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
 import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalPlan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalProject;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalSetOperation;
 import org.apache.doris.nereids.trees.plans.physical.RuntimeFilter;
+import org.apache.doris.nereids.types.IntegerType;
 import org.apache.doris.nereids.util.MemoTestUtils;
 import org.apache.doris.nereids.util.PlanChecker;
+import org.apache.doris.planner.RuntimeFilterId;
 import org.apache.doris.qe.OriginStatement;
+import org.apache.doris.thrift.TMinMaxRuntimeFilterType;
 import org.apache.doris.thrift.TRuntimeFilterType;
 
 import com.google.common.collect.ImmutableList;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -616,6 +628,40 @@ public class RuntimeFilterTest extends SSBTestBase {
         }
     }
 
+    @Test
+    public void 
testPushSharedCteRuntimeFilterOnlyForSameProducerTargetExpression() {
+        CTEId cteId = new CTEId(1);
+        SlotReference src = new SlotReference("src", IntegerType.INSTANCE);
+        SlotReference producerPk = new SlotReference("pk", 
IntegerType.INSTANCE);
+        SlotReference consumerPk1 = new SlotReference("pk", 
IntegerType.INSTANCE);
+        SlotReference consumerPk2 = new SlotReference("pk", 
IntegerType.INSTANCE);
+
+        List<RuntimeFilter> sameTargetFilters = ImmutableList.of(
+                newCteConsumerRuntimeFilter(src, consumerPk1, consumerPk1, 
producerPk, cteId),
+                newCteConsumerRuntimeFilter(src, consumerPk2, consumerPk2, 
producerPk, cteId));
+        
Assertions.assertTrue(RuntimeFilterGenerator.canPushDownRuntimeFiltersIntoCTEProducer(
+                sameTargetFilters, cteId));
+
+        List<RuntimeFilter> differentTargetFilters = ImmutableList.of(
+                newCteConsumerRuntimeFilter(src, consumerPk1,
+                        new Add(consumerPk1, new IntegerLiteral(6)), 
producerPk, cteId),
+                newCteConsumerRuntimeFilter(src, consumerPk2,
+                        new Subtract(consumerPk2, new IntegerLiteral(1)), 
producerPk, cteId));
+        
Assertions.assertFalse(RuntimeFilterGenerator.canPushDownRuntimeFiltersIntoCTEProducer(
+                differentTargetFilters, cteId));
+    }
+
+    private RuntimeFilter newCteConsumerRuntimeFilter(Expression src, Slot 
targetSlot,
+            Expression targetExpression, Slot producerSlot, CTEId cteId) {
+        PhysicalCTEConsumer consumer = Mockito.mock(PhysicalCTEConsumer.class);
+        Mockito.when(consumer.getCteId()).thenReturn(cteId);
+        
Mockito.when(consumer.getProducerSlot(targetSlot)).thenReturn(producerSlot);
+        AbstractPhysicalPlan builder = 
Mockito.mock(AbstractPhysicalPlan.class);
+        return new 
RuntimeFilter(RuntimeFilterId.createGenerator().getNextId(), src, targetSlot, 
targetExpression,
+                TRuntimeFilterType.IN_OR_BLOOM, 0, builder, -1L, true,
+                TMinMaxRuntimeFilterType.MIN_MAX, consumer);
+    }
+
     @Test
     public void testRuntimeFilterBlockByRecCte() {
         String sql = new StringBuilder().append("with recursive xx as 
(\n").append("  select\n")
diff --git 
a/regression-test/suites/query_p0/runtime_filter/cte-runtime-filter.groovy 
b/regression-test/suites/query_p0/runtime_filter/cte-runtime-filter.groovy
index 6e9393e05d2..d7a6cb2ca93 100644
--- a/regression-test/suites/query_p0/runtime_filter/cte-runtime-filter.groovy
+++ b/regression-test/suites/query_p0/runtime_filter/cte-runtime-filter.groovy
@@ -68,4 +68,56 @@ suite('cte-runtime-filter') {
         from cte a
         join cte_runtime_filter_table b on a.user_id=b.user_id ;
         '''
-}
\ No newline at end of file
+
+    sql '''
+    drop table if exists cte_runtime_filter_shared_probe;
+    create table cte_runtime_filter_shared_probe (
+        pk int not null
+    ) ENGINE=OLAP
+    DUPLICATE KEY(pk)
+    DISTRIBUTED BY HASH(pk) BUCKETS 1
+    PROPERTIES (
+        "replication_allocation" = "tag.location.default: 1"
+    );
+
+    insert into cte_runtime_filter_shared_probe values (4), (11);
+
+    drop table if exists cte_runtime_filter_shared_build;
+    create table cte_runtime_filter_shared_build (
+        pk bigint not null
+    ) ENGINE=OLAP
+    DUPLICATE KEY(pk)
+    DISTRIBUTED BY HASH(pk) BUCKETS 1
+    PROPERTIES (
+        "replication_allocation" = "tag.location.default: 1"
+    );
+
+    insert into cte_runtime_filter_shared_build values (10);
+
+    set enable_nereids_planner=true;
+    set enable_fallback_to_original_planner=false;
+    set inline_cte_referenced_threshold=0;
+    set disable_join_reorder=true;
+    set enable_runtime_filter_prune=false;
+    set runtime_filter_mode=global;
+    set runtime_filter_wait_infinitely=true;
+    set runtime_filter_type=2;
+    '''
+
+    def sharedCteRuntimeFilterSql = '''
+        with probe as (
+            select pk from cte_runtime_filter_shared_probe
+        )
+        select count(*)
+        from probe p1
+        cross join probe p2
+        join cte_runtime_filter_shared_build b
+            on cast(p1.pk as bigint) + 6 = b.pk
+            and cast(p2.pk as bigint) - 1 = b.pk
+    '''
+    assertEquals([[1L]], sql(sharedCteRuntimeFilterSql))
+
+    sql "set runtime_filter_type=''"
+    assertEquals([[1L]], sql(sharedCteRuntimeFilterSql))
+    sql "set runtime_filter_wait_infinitely=false"
+}


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

Reply via email to