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

morrySnow 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 36d2e852fea [fix](local shuffle) Require hash input for distinct 
finalize agg without group keys (#66570)
36d2e852fea is described below

commit 36d2e852feaa872c224664297c3155034eb530ac
Author: 924060929 <[email protected]>
AuthorDate: Tue Aug 18 19:55:10 2026 +0800

    [fix](local shuffle) Require hash input for distinct finalize agg without 
group keys (#66570)
    
    ## Problem
    
    With the FE local-shuffle planner enabled (default
    `enable_local_shuffle_planner=true`), a scalar `COUNT(DISTINCT k)` over
    joins can return a wrong result that grows linearly with
    `parallel_pipeline_task_num` (e.g. expected 10, got 30 with 3 tasks).
    
    The bad plan shape:
    
    ```
    VAGGREGATE (merge finalize)  output: sum0(multi_distinct_count(k))   -- 
sums per-instance values
    VAGGREGATE (merge finalize)  output: multi_distinct_count(k)         -- no 
group keys
    VHASH JOIN (LEFT OUTER BROADCAST)
      VLOCAL-EXCHANGE (PASSTHROUGH)                                      -- 
scatters hash-partitioned rows
    VHASH JOIN (RIGHT OUTER PARTITIONED)  output: hash-partitioned by k  -- 
key-aligned here
    ```
    
    `AggregationNode.enforceAndDeriveLocalExchange` gave a **NoRequire**
    distribution to a finalize merge agg with no group keys, treating it
    like `COUNT(*)`. Unlike `COUNT(*)`, a `multi_distinct_count` finalize
    agg emits per-instance **scalar values** that the parent `sum0` adds up
    — correctness requires the input to be hash-partitioned by the distinct
    key. When a PASSTHROUGH local exchange (broadcast-join probe fan-out)
    scatters same-key rows across instances, the parent double-counts
    overlapping keys. The result equals `correct × local task count`.
    
    The BE-native path was already protected
    (`AggSinkOperatorX::required_data_distribution` checks
    `_partition_exprs`, and `child_breaks_local_key_distribution` from a
    prior fix), so `enable_local_shuffle_planner=false` was unaffected —
    only the FE-planned path was wrong.
    
    ## Root cause
    
    The FE planner used `hasKeys` (grouping exprs empty?) as the
    partition-requirement test, but BE's `_partition_exprs` is non-empty
    whenever the agg has group keys **or DISTINCT aggregates**
    (`distribute_expr_lists` + `has_distinct`). The FE fell back to
    NoRequire for the distinct case, skipping the hash local exchange that
    the agg needs.
    
    ## Changes
    
    - `AggregationNode` now mirrors BE's `_partition_exprs` semantics via
    `hasPartitionRequirement()` (grouping exprs or `multi_distinct_*`
    functions): a finalize agg with a partition requirement demands HASH
    from its child; only partition-less aggs (`COUNT(*)`-style) keep
    NoRequire.
    - The finalize branch that previously trusted the child's distribution
    now requires HASH explicitly — when the child already provides hash
    distribution the `satisfy()` check passes and no LE is inserted, so the
    common case is unchanged and free.
    - `requiresShuffleForCorrectness()` now covers DISTINCT aggregates to
    match BE's `is_shuffled_operator()`.
    
    ## Tests
    
    - `LocalShuffleNodeCoverageTest`: unit coverage for `AggregationNode`
    across finalize/LOCAL/FIRST_MERGE phases × distinct/no-distinct ×
    `enable_local_exchange_before_agg` on/off, plus
    `requiresShuffleForCorrectness` cases. Pre-fix the distinct-finalize
    case asserted NoRequire; post-fix it asserts RequireHash.
    - `LocalExchangePlannerTest`: sql-level distributed-plan test — the
    RQG-shaped query (`count(distinct)` over a shuffle join + a broadcast
    join with probe forced to PASSTHROUGH) must contain a
    `LOCAL_EXECUTION_HASH_SHUFFLE` local exchange below the distinct
    finalize agg. Verified this test fails without the fix (plan only has
    `PASSTHROUGH`) and passes with it.
    - End-to-end on a 3-BE cluster with the RQG dataset: expected 10; the
    pre-fix behavior (30, scaling with `parallel_pipeline_task_num`) now
    returns 10 under all session-var combinations, including
    `parallel_pipeline_task_num=1/2/4/6`.
---
 .../org/apache/doris/planner/AggregationNode.java  |  75 ++++--
 .../planner/LocalShuffleNodeCoverageTest.java      | 295 +++++++++++++++++++++
 .../apache/doris/qe/LocalExchangePlannerTest.java  | 106 ++++++++
 .../local_shuffle/test_local_shuffle_rqg_bugs.out  |   6 +
 .../test_local_shuffle_rqg_bugs.groovy             |  49 ++++
 5 files changed, 511 insertions(+), 20 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java 
b/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java
index 786e026660a..98df0eae1d1 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java
@@ -282,6 +282,8 @@ public class AggregationNode extends PlanNode {
         // PR #62438: when false, non-finalize agg falls back to BE base class.
         boolean enableLeBeforeAgg = 
sessionVariable.enableLocalExchangeBeforeAgg;
         boolean hasKeys = !aggInfo.getGroupingExprs().isEmpty();
+        boolean selfOrInheritedShuffled = 
translatorContext.hasShuffleForCorrectnessAncestor(this)
+                || requiresShuffleForCorrectness();
 
         // Each branch mirrors the corresponding BE operator's 
required_data_distribution()
         // check order 1:1. The helper baseClassRequire() expands BE's base 
class behavior.
@@ -335,7 +337,16 @@ public class AggregationNode extends PlanNode {
             // early return also catches FIRST_MERGE, dropping the HASH 
requirement and
             // causing wrong-result (e.g. PASSTHROUGH over serial child breaks 
the
             // group-by-key invariant — DORIS-25413).
-            if (!hasKeys) {
+            if (!hasPartitionRequirement(selfOrInheritedShuffled)) {
+                // No effective partition key (no group keys, and no child 
distribute
+                // exprs set for a DISTINCT / followed-by-shuffle agg): the 
input
+                // distribution is irrelevant. A finalize agg with an 
effective key
+                // emits per-instance scalar values 
(sum0(multi_distinct_count(...))
+                // above) that the parent sums, so same-key rows must stay in 
a single
+                // instance — this mirrors BE's `_partition_exprs` exactly, 
and keeps
+                // a directly called multi_distinct_count(col) (no distribute 
exprs)
+                // on the no-requirement path instead of collapsing it onto a 
zero-key
+                // HASH exchange.
                 requireChild = needsFinalize
                         ? LocalExchangeTypeRequire.noRequire()
                         : baseClassRequire(connectContext);
@@ -348,13 +359,16 @@ public class AggregationNode extends PlanNode {
                 // FIRST_MERGE (correctness) or finalize+colocate → HASH.
                 requireChild = parentRequire.autoRequireHash();
             } else if (hasPartitionExprs(parentRequire)) {
-                // FE-only heuristic: finalize non-colocate with parent hash 
requirement
-                // → inherit parent's specific hash type.
+                // finalize non-colocate with a parent hash requirement → 
inherit the
+                // parent's specific hash type.
                 requireChild = parentRequire.autoRequireHash();
             } else {
-                // FE-only heuristic: finalize non-colocate without parent 
hash → skip
-                // LE (child Exchange already provides hash distribution).
-                requireChild = LocalExchangeTypeRequire.noRequire();
+                // finalize non-colocate without a parent hash requirement: 
the input
+                // must still be key-aligned (group/distinct key), so require 
HASH
+                // explicitly instead of trusting the child's distribution. 
When the
+                // child already provides hash distribution, satisfy() passes 
and no
+                // LE is inserted, so this is safe and free in the common case.
+                requireChild = LocalExchangeTypeRequire.requireHash();
             }
         }
 
@@ -371,6 +385,34 @@ public class AggregationNode extends PlanNode {
                 : LocalExchangeTypeRequire.noRequire();
     }
 
+    /**
+     * Whether this agg needs key-aligned (hash-partitioned) input from its 
child.
+     * Mirrors BE AggSinkOperatorX::update_operator's `_partition_exprs` 
exactly:
+     * non-empty grouping exprs, or the child distribute exprs when the plan 
set
+     * them for a DISTINCT (or followed-by-shuffle) agg. The test is on the
+     * *effective* key, not the function name: a directly called
+     * multi_distinct_count(col) has neither distribute exprs nor grouping 
exprs,
+     * so it stays on the no-requirement path — a zero-key HASH exchange would
+     * collapse the whole input onto one task per BE. A finalize agg with an
+     * effective key emits per-instance scalar values (the
+     * sum0(multi_distinct_count(...)) above) that the parent sums, so same-key
+     * rows must stay in a single instance.
+     */
+    private boolean hasPartitionRequirement(boolean followedByShuffled) {
+        return !getLocalExchangeDistributeExprs(0, 
followedByShuffled).isEmpty();
+    }
+
+    private boolean hasDistinctAggregate() {
+        // Multi-distinct aggregates are detected by function name. Nereids 
rewrites
+        // count/sum/group_concat(distinct ...) into dedicated MultiDistinct* 
functions
+        // constructed with distinct=false, so by this legacy FunctionCallExpr 
layer
+        // isDistinct() is already false and the function name is the only 
signal.
+        return aggInfo.getAggregateExprs().stream()
+                .map(FunctionCallExpr::getFnName)
+                .map(name -> name.getFunction())
+                .anyMatch(name -> name.startsWith("multi_distinct_"));
+    }
+
     @Override
     protected List<Expr> getSemanticPartitionExprs() {
         return aggInfo.getGroupingExprs();
@@ -386,18 +428,7 @@ public class AggregationNode extends PlanNode {
         // chain scatters same-group rows across N instances, leaving 
partial_preagg essentially a
         // no-op and breaking row-arrival order at downstream merge-finalize 
(e.g. group_concat).
         List<Expr> childDist = getChildDistributeExprList(childIndex);
-        // Multi-distinct aggregates are detected by function name. Nereids 
rewrites
-        // count/sum(distinct ...) into dedicated MultiDistinct* functions 
constructed with
-        // distinct=false and a "multi_distinct_" name, so by this legacy 
FunctionCallExpr layer
-        // isDistinct() is already false and the function name is the only 
remaining signal —
-        // there is no structural flag to test here.
-        boolean hasDistinct = aggInfo.getAggregateExprs().stream()
-                .map(FunctionCallExpr::getFnName)
-                .filter(name -> name != null)
-                .map(name -> name.getFunction())
-                .filter(name -> name != null)
-                .anyMatch(name -> name.startsWith("multi_distinct_"));
-        if (childDist != null && !childDist.isEmpty() && (followedByShuffled 
|| hasDistinct)) {
+        if (childDist != null && !childDist.isEmpty() && (followedByShuffled 
|| hasDistinctAggregate())) {
             return childDist;
         }
         return Lists.newArrayList(aggInfo.getGroupingExprs());
@@ -406,13 +437,17 @@ public class AggregationNode extends PlanNode {
     @Override
     public boolean requiresShuffleForCorrectness() {
         // Mirrors BE's AggSinkOperatorX::is_shuffled_operator() exactly:
-        //   finalize agg with group keys needs hash-distributed input for 
correctness.
+        //   finalize agg with partition exprs (group keys, or child distribute
+        //   exprs set for a DISTINCT aggregate) needs hash-distributed input 
for
+        //   correctness. The effective-key test is the node's own requirement
+        //   (followedByShuffled=false); inherited shuffle state is added by 
the
+        //   caller via selfOrInheritedShuffled.
         // GLOBAL dedup (!needsFinalize) is intentionally NOT included here — 
if a
         // GLOBAL dedup exists, a finalize agg always sits above it (e.g. 
DISTINCT_GLOBAL
         // above DISTINCT_LOCAL/GLOBAL_DEDUP), and the finalize agg propagates 
the flag
         // down via inheritedShuffled. A solo finalize agg satisfies hash 
distribution
         // through its own child requirement.
-        return needsFinalize && !aggInfo.getGroupingExprs().isEmpty();
+        return needsFinalize && !getLocalExchangeDistributeExprs(0, 
false).isEmpty();
     }
 
     private boolean canUseDistinctStreamingAgg(SessionVariable 
sessionVariable) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java
index 02e68ba6b59..59c26166c3f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java
@@ -17,9 +17,11 @@
 
 package org.apache.doris.planner;
 
+import org.apache.doris.analysis.AggregateInfo;
 import org.apache.doris.analysis.AssertNumRowsElement;
 import org.apache.doris.analysis.BinaryPredicate;
 import org.apache.doris.analysis.Expr;
+import org.apache.doris.analysis.FunctionCallExpr;
 import org.apache.doris.analysis.GroupingInfo;
 import org.apache.doris.analysis.JoinOperator;
 import org.apache.doris.analysis.OrderByElement;
@@ -28,6 +30,7 @@ import org.apache.doris.analysis.SlotRef;
 import org.apache.doris.analysis.SortInfo;
 import org.apache.doris.analysis.TupleDescriptor;
 import org.apache.doris.analysis.TupleId;
+import org.apache.doris.catalog.FunctionName;
 import org.apache.doris.common.Pair;
 import org.apache.doris.common.UserException;
 import org.apache.doris.nereids.glue.translator.PlanTranslatorContext;
@@ -35,6 +38,8 @@ import 
org.apache.doris.nereids.trees.plans.PartitionTopnPhase;
 import org.apache.doris.nereids.trees.plans.WindowFuncType;
 import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType;
 import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
 import org.apache.doris.thrift.TExplainLevel;
 import org.apache.doris.thrift.TPartitionType;
 import org.apache.doris.thrift.TPlanNode;
@@ -855,6 +860,296 @@ public class LocalShuffleNodeCoverageTest {
         Assertions.assertEquals(LocalExchangeType.NOOP, noopOutput.second);
     }
 
+    @Test
+    public void testAggregationNodeDistinctFinalizeRequiresHash() {
+        // count(distinct k) without group-by: the finalize merge agg emits 
per-instance
+        // scalar values that the parent sums (sum0(multi_distinct_count(...)) 
above), so
+        // the input must be hash-partitioned by the distinct key. Pre-fix 
this agg got
+        // NoRequire and a PASSTHROUGH local exchange below scattered same-key 
rows across
+        // instances → the parent double-counted (result = correct × task 
count).
+        for (String fn : new String[] {"multi_distinct_count", 
"multi_distinct_sum",
+                "multi_distinct_group_concat"}) {
+            AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction(fn)), /* 
groupByExprs */ true,
+                    /* merge */ true, /* needsFinalize */ true, 
LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+            Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                    agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+            Assertions.assertEquals(LocalExchangeNode.RequireHash.class, 
agg.child.lastRequire.getClass(),
+                    fn + " finalize agg must require hash input");
+            
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second);
+            assertChildLocalExchangeType(agg.node, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+        }
+    }
+
+    @Test
+    public void testAggregationNodeDistinctFinalizeWithParentHashRequirement() 
{
+        // A parent that already requires hash must not change the agg's own 
hash demand.
+        AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
 /* groupByExprs */ true,
+                /* merge */ true, /* needsFinalize */ true, 
LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.requireHash());
+        Assertions.assertEquals(LocalExchangeNode.RequireHash.class, 
agg.child.lastRequire.getClass());
+        
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second);
+        assertChildLocalExchangeType(agg.node, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    @Test
+    public void testAggregationNodeDirectMultiDistinctNoKeyStaysNoRequire() {
+        // A directly called scalar multi_distinct_count(col) has 
isDistinct=false and
+        // no child distribute exprs (SplitAggWithoutDistinct builds a LOCAL 
aggregate
+        // without partition exprs). It must NOT be given a HASH requirement — 
a
+        // zero-key HASH exchange would collapse the whole input onto one task 
per BE.
+        AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
+                /* groupByExprs */ true, /* merge */ true, /* needsFinalize */ 
true,
+                LocalExchangeType.NOOP, null);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.NoRequire.class, 
agg.child.lastRequire.getClass(),
+                "direct multi_distinct with no effective key must stay 
NoRequire");
+        Assertions.assertEquals(LocalExchangeType.NOOP, output.second);
+        Assertions.assertSame(agg.child, agg.node.getChild(0));
+    }
+
+    @Test
+    public void testAggregationNodeNoPartitionNonFinalizeBaseClassRequire() {
+        // COUNT(*)-style non-finalize (LOCAL) agg: no partition requirement, 
so
+        // the non-finalize arm of the first branch falls back to base class
+        // behavior (NOOP for a non-serial child). The agg exprs are non-empty
+        // (a plain count function) so the AggSink branch is exercised rather
+        // than DistinctStreamingAgg.
+        AggContext agg = buildAggContext(
+                Collections.singletonList(plainAggregateFunction("count")), /* 
groupByExprs */ true,
+                /* merge */ false, /* needsFinalize */ false, 
LocalExchangeType.NOOP, null);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.NoRequire.class, 
agg.child.lastRequire.getClass());
+        Assertions.assertEquals(LocalExchangeType.NOOP, output.second);
+        Assertions.assertSame(agg.child, agg.node.getChild(0));
+    }
+
+    @Test
+    public void testAggregationNodeNoPartitionFinalizeStaysNoRequire() {
+        // COUNT(*)-style agg (no group keys, no DISTINCT aggregates) 
genuinely has no
+        // partition requirement: the input distribution is irrelevant.
+        AggContext agg = 
buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* 
groupByExprs */ true,
+                /* merge */ true, /* needsFinalize */ true, 
LocalExchangeType.NOOP, null);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.NoRequire.class, 
agg.child.lastRequire.getClass());
+        Assertions.assertEquals(LocalExchangeType.NOOP, output.second);
+        Assertions.assertSame(agg.child, agg.node.getChild(0));
+    }
+
+    @Test
+    public void testAggregationNodeDistinctLocalPhaseDefaultLeRequiresHash() {
+        // LOCAL (FIRST/SECOND, non-merge, non-finalize) phase of a distinct 
agg with the
+        // default enable_local_exchange_before_agg=true: BE requires HASH here
+        // (partition_exprs non-empty), so the FE must mirror that.
+        AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
 /* groupByExprs */ true,
+                /* merge */ false, /* needsFinalize */ false, 
LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.RequireHash.class, 
agg.child.lastRequire.getClass(),
+                "LOCAL distinct phase with default LE requires hash");
+        
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second);
+        assertChildLocalExchangeType(agg.node, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    @Test
+    public void 
testAggregationNodeDistinctLocalPhaseWithLeDisabledStaysNoRequire() {
+        // LOCAL distinct phase + enable_local_exchange_before_agg=false → 
base class
+        // behavior (NOOP for a non-serial child): user explicitly opted out 
of pre-agg LE.
+        AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
 /* groupByExprs */ true,
+                /* merge */ false, /* needsFinalize */ false, 
LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.enableLocalExchangeBeforeAgg = false;
+        
Mockito.when(agg.connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.NoRequire.class, 
agg.child.lastRequire.getClass(),
+                "LOCAL distinct phase with LE disabled keeps no alignment 
requirement");
+        Assertions.assertEquals(LocalExchangeType.NOOP, output.second);
+        Assertions.assertSame(agg.child, agg.node.getChild(0));
+    }
+
+    @Test
+    public void testAggregationNodeDistinctFirstMergeRequiresHash() {
+        // FIRST_MERGE (correctness-required) keeps the hash demand even when 
the
+        // user opts out of pre-agg local exchanges 
(enable_local_exchange_before_agg
+        // = false): removing the !isMerge() exemption must not weaken it.
+        AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
 /* groupByExprs */ true,
+                /* merge */ true, /* needsFinalize */ false, 
LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.enableLocalExchangeBeforeAgg = false;
+        
Mockito.when(agg.connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.RequireHash.class, 
agg.child.lastRequire.getClass(),
+                "FIRST_MERGE must keep the hash demand with 
enable_local_exchange_before_agg=false");
+        
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second);
+        assertChildLocalExchangeType(agg.node, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    @Test
+    public void testAggregationNodeGroupByFinalizeRequiresHash() {
+        // GROUP BY finalize agg requires hash input; when the parent has no 
hash
+        // requirement the semantic partition exprs (group keys) drive the 
decision.
+        AggContext agg = 
buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* 
groupByExprs */ false,
+                /* merge */ true, /* needsFinalize */ true, 
LocalExchangeType.NOOP, null);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.RequireHash.class, 
agg.child.lastRequire.getClass());
+        
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second);
+        assertChildLocalExchangeType(agg.node, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    @Test
+    public void 
testAggregationNodeGroupByLocalPhaseWithLeDisabledStaysNoRequire() {
+        // GROUP BY local phase + enable_local_exchange_before_agg=false → 
base class
+        // behavior (NOOP for a non-serial child): user explicitly opted out 
of pre-agg LE.
+        // aggExprs is non-empty so the AggSink branch is exercised (an empty 
aggExprs
+        // would route through DistinctStreamingAgg with its own hash logic).
+        AggContext agg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
 /* groupByExprs */ false,
+                /* merge */ false, /* needsFinalize */ false, 
LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.enableLocalExchangeBeforeAgg = false;
+        
Mockito.when(agg.connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.NoRequire.class, 
agg.child.lastRequire.getClass());
+        Assertions.assertEquals(LocalExchangeType.NOOP, output.second);
+        Assertions.assertSame(agg.child, agg.node.getChild(0));
+    }
+
+    @Test
+    public void testAggregationNodeRequiresShuffleForCorrectness() {
+        // Mirrors BE is_shuffled_operator(): finalize agg with partition exprs
+        // (group keys or DISTINCT aggregates) needs hash-distributed input.
+        AggContext distinctAgg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
+                /* groupByExprs */ true, /* merge */ true, /* needsFinalize */ 
true,
+                LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        Assertions.assertTrue(distinctAgg.node.requiresShuffleForCorrectness(),
+                "distinct finalize agg must require shuffle for correctness");
+
+        AggContext noPartitionAgg = 
buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* 
groupByExprs */ true,
+                /* merge */ true, /* needsFinalize */ true, 
LocalExchangeType.NOOP, null);
+        
Assertions.assertFalse(noPartitionAgg.node.requiresShuffleForCorrectness(),
+                "COUNT(*) finalize agg has no partition requirement");
+
+        AggContext groupByAgg = 
buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* 
groupByExprs */ false,
+                /* merge */ true, /* needsFinalize */ true, 
LocalExchangeType.NOOP, null);
+        Assertions.assertTrue(groupByAgg.node.requiresShuffleForCorrectness(),
+                "GROUP BY finalize agg must require shuffle for correctness");
+
+        AggContext localAgg = 
buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")),
+                /* groupByExprs */ true, /* merge */ false, /* needsFinalize 
*/ false,
+                LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS);
+        Assertions.assertFalse(localAgg.node.requiresShuffleForCorrectness(),
+                "non-finalize agg does not require shuffle for correctness");
+    }
+
+    @Test
+    public void testAggregationNodeInheritedShuffleUsesChildDistributeExprs() {
+        // An intermediate agg that inherits a shuffle-for-correctness 
ancestor (e.g.
+        // DISTINCT_GLOBAL/FIRST_MERGE chain above a Union) keeps the child 
distribute
+        // exprs as its hash key even though the agg itself has no DISTINCT 
functions.
+        // The grouping key is deliberately different from the child 
distribution key:
+        // dropping the inherited state or selecting the grouping key must 
fail this test.
+        Expr groupingExpr = Mockito.mock(Expr.class, "groupingExpr");
+        Expr childDistributeExpr = Mockito.mock(Expr.class, 
"childDistributeExpr");
+        List<Expr> childDistributeExprs = 
Collections.singletonList(childDistributeExpr);
+        AggContext agg = buildAggContext(
+                Collections.singletonList(plainAggregateFunction("count")),
+                Collections.singletonList(groupingExpr), /* merge */ true,
+                /* needsFinalize */ false, LocalExchangeType.NOOP, 
childDistributeExprs);
+        
Mockito.when(agg.ctx.hasShuffleForCorrectnessAncestor(agg.node)).thenReturn(true);
+        Pair<PlanNode, LocalExchangeType> output = 
agg.node.enforceAndDeriveLocalExchange(
+                agg.ctx, null, LocalExchangeTypeRequire.noRequire());
+        Assertions.assertEquals(LocalExchangeNode.RequireHash.class, 
agg.child.lastRequire.getClass(),
+                "inherited shuffle ancestor must keep the hash demand");
+        
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second);
+        assertChildLocalExchangeType(agg.node, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+        LocalExchangeNode exchangeNode = (LocalExchangeNode) 
agg.node.getChild(0);
+        Assertions.assertEquals(childDistributeExprs, 
exchangeNode.getDistributeExprLists(),
+                "inherited intermediate agg must hash by the child's 
distribution key");
+        Assertions.assertNotEquals(Collections.singletonList(groupingExpr), 
exchangeNode.getDistributeExprLists(),
+                "the grouping key must not replace the inherited child 
distribution key");
+    }
+
+    /** A non-empty child distribute expr list, as fragment planning sets for 
a keyed DISTINCT agg. */
+    private static final List<Expr> KEYED_DISTRIBUTE_EXPRS =
+            Collections.singletonList(Mockito.mock(Expr.class));
+
+    private static class AggContext {
+        final AggregationNode node;
+        final PlanTranslatorContext ctx;
+        final TrackingPlanNode child;
+        final ConnectContext connectContext;
+
+        AggContext(AggregationNode node, PlanTranslatorContext ctx, 
TrackingPlanNode child,
+                ConnectContext connectContext) {
+            this.node = node;
+            this.ctx = ctx;
+            this.child = child;
+            this.connectContext = connectContext;
+        }
+    }
+
+    /**
+     * noGroupByExprs == true → no group keys (mirrors the scalar 
COUNT(DISTINCT));
+     * distributeExprs != null → the plan set child distribute exprs for this 
agg
+     * (as fragment planning does for a DISTINCT agg), which is what makes
+     * hasPartitionRequirement() true for a keyed agg.
+     */
+    private static AggContext buildAggContext(List<FunctionCallExpr> aggExprs, 
boolean noGroupByExprs,
+            boolean merge, boolean needsFinalize, LocalExchangeType 
childProvided,
+            List<Expr> distributeExprs) {
+        List<Expr> groupingExprs = noGroupByExprs
+                ? Collections.emptyList() : 
Collections.singletonList(Mockito.mock(Expr.class));
+        return buildAggContext(aggExprs, groupingExprs, merge, needsFinalize,
+                childProvided, distributeExprs);
+    }
+
+    private static AggContext buildAggContext(List<FunctionCallExpr> aggExprs, 
List<Expr> groupingExprs,
+            boolean merge, boolean needsFinalize, LocalExchangeType 
childProvided,
+            List<Expr> distributeExprs) {
+        PlanTranslatorContext ctx = Mockito.mock(PlanTranslatorContext.class);
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        Mockito.when(connectContext.getSessionVariable()).thenReturn(new 
SessionVariable());
+        Mockito.when(ctx.getConnectContext()).thenReturn(connectContext);
+
+        AggregateInfo aggInfo = Mockito.mock(AggregateInfo.class);
+        Mockito.when(aggInfo.getOutputTupleId()).thenReturn(new 
TupleId(NEXT_ID.getAndIncrement()));
+        Mockito.when(aggInfo.getGroupingExprs()).thenReturn(new 
ArrayList<>(groupingExprs));
+        Mockito.when(aggInfo.getAggregateExprs()).thenReturn(new 
ArrayList<>(aggExprs));
+        Mockito.when(aggInfo.isMerge()).thenReturn(merge);
+
+        TrackingPlanNode child = new TrackingPlanNode(nextPlanNodeId(), 
childProvided);
+        AggregationNode agg = new AggregationNode(nextPlanNodeId(), child, 
aggInfo);
+        if (distributeExprs != null) {
+            
agg.setChildrenDistributeExprLists(Collections.singletonList(distributeExprs));
+        }
+        if (!needsFinalize) {
+            agg.unsetNeedsFinalize();
+        }
+        return new AggContext(agg, ctx, child, connectContext);
+    }
+
+    private static FunctionCallExpr plainAggregateFunction(String 
functionName) {
+        FunctionCallExpr fce = Mockito.mock(FunctionCallExpr.class);
+        FunctionName fnName = Mockito.mock(FunctionName.class);
+        Mockito.when(fnName.getFunction()).thenReturn(functionName);
+        Mockito.when(fce.getFnName()).thenReturn(fnName);
+        return fce;
+    }
+
+    private static FunctionCallExpr multiDistinctFunction(String functionName) 
{
+        FunctionCallExpr fce = Mockito.mock(FunctionCallExpr.class);
+        FunctionName fnName = Mockito.mock(FunctionName.class);
+        Mockito.when(fnName.getFunction()).thenReturn(functionName);
+        Mockito.when(fce.getFnName()).thenReturn(fnName);
+        return fce;
+    }
+
     private static PlanNodeId nextPlanNodeId() {
         return new PlanNodeId(NEXT_ID.getAndIncrement());
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java
index 275df40cffa..d691028666f 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java
@@ -22,6 +22,7 @@ import org.apache.doris.analysis.TupleId;
 import org.apache.doris.common.UserException;
 import org.apache.doris.nereids.NereidsPlanner;
 import org.apache.doris.planner.AddLocalExchange;
+import org.apache.doris.planner.AggregationNode;
 import org.apache.doris.planner.LocalExchangeNode;
 import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType;
 import org.apache.doris.planner.PlanFragment;
@@ -77,6 +78,11 @@ public class LocalExchangePlannerTest extends 
TestWithFeService implements PlanS
         sv.setIgnoreStorageDataDistribution(true);
         sv.setPipelineTaskNum("4");
         sv.setForceToLocalShuffle(false);
+        // The test class keeps one ConnectContext across methods, so reset 
the knobs
+        // that the per-test tweaks below may have changed (and restore 
agg_phase to
+        // the default strategy) before applying this test's own tweaks.
+        sv.aggPhase = 0;
+        sv.enableBroadcastJoinForcePassthrough = false;
         if (tweaks != null) {
             tweaks.accept(sv);
         }
@@ -163,6 +169,106 @@ public class LocalExchangePlannerTest extends 
TestWithFeService implements PlanS
                                                         olapScan("t1")))))));
     }
 
+    @Test
+    public void testCountDistinctNoGroupByRequiresHashBeforeAgg() throws 
Exception {
+        // count(distinct k2) without group-by: the finalize merge agg emits 
per-instance
+        // scalar values that the parent sums 
(sum0(multi_distinct_count(...))), so its
+        // input must be hash-partitioned by the distinct key. With the 
broadcast-join
+        // probe forced to PASSTHROUGH, rows are scattered below the join — 
the agg must
+        // still get a LOCAL_HASH exchange directly beneath it. Pre-fix this 
agg received
+        // NoRequire (no hash LE) and the parent double-counted overlapping 
keys.
+        // agg_phase=1 forces the multi_distinct_count two-phase shape (the 
default
+        // strategy splits into a group-by + count shape which is already 
safe).
+        setupLocalShuffleSession(sv -> {
+            sv.enableBroadcastJoinForcePassthrough = true;
+            sv.aggPhase = 1;
+        });
+        assertFinalizeDistinctAggChildHashKeyedBy("select count(distinct a.k2) 
from test.t1 a "
+                        + "left join [shuffle] test.t2 b on a.k2 = b.k2 "
+                        + "left join [broadcast] test.t2 c on b.k1 = c.k1",
+                "k2");
+
+    }
+
+    @Test
+    public void 
testCountDistinctNoGroupByWithoutForcePassthroughNoRedundantLe() throws 
Exception {
+        // Same multi_distinct shape but without broadcast-join 
force-passthrough: the
+        // shuffle join's probe output is already hash-partitioned by k2, 
which satisfies
+        // the finalize agg's hash demand — so no LOCAL_HASH local exchange 
may appear.
+        // The explicit aggPhase=1 + force-passthrough=false (reset in setup) 
pins the
+        // exact shape this test means to verify.
+        setupLocalShuffleSession(sv -> {
+            sv.enableBroadcastJoinForcePassthrough = false;
+            sv.aggPhase = 1;
+        });
+        assertNoLocalExchangeOfType("select count(distinct a.k2) from test.t1 
a "
+                        + "left join [shuffle] test.t2 b on a.k2 = b.k2 "
+                        + "left join [broadcast] test.t2 c on b.k1 = c.k1",
+                LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    @Test
+    public void testDirectMultiDistinctNoKeyHasNoHashLe() throws Exception {
+        // A directly called scalar multi_distinct_count(k2) has no distribute 
exprs
+        // and no group keys: a zero-key LOCAL_HASH exchange would collapse 
the whole
+        // input onto one task per BE. The plan must not contain any 
LOCAL_HASH.
+        setupLocalShuffleSession(sv -> sv.aggPhase = 1);
+        assertNoLocalExchangeOfType("select multi_distinct_count(k2) from 
test.t1",
+                LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    @Test
+    public void testCountStarNoGroupByHasNoHashLe() throws Exception {
+        // COUNT(*) has no partition requirement: no LOCAL_HASH local exchange 
may appear
+        // anywhere in the plan (the two-phase agg only gets the PASSTHROUGH 
fan-out of
+        // the pooling scan).
+        setupLocalShuffleSession(null);
+        assertNoLocalExchangeOfType("select count(*) from test.t1",
+                LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+    }
+
+    /**
+     * Assert that every finalize DISTINCT agg (multi_distinct_* output) has a
+     * LOCAL_EXECUTION_HASH_SHUFFLE local exchange directly beneath it, keyed 
by
+     * {@code keyName}. This pins the agg-to-exchange edge and its partition
+     * expressions — a flatten-to-enum check could not distinguish a keyless or
+     * wrong-key HASH exchange.
+     */
+    protected void assertFinalizeDistinctAggChildHashKeyedBy(String sql, 
String keyName) throws Exception {
+        StmtExecutor executor = executeNereidsSql("explain distributed plan " 
+ sql);
+        NereidsPlanner planner = (NereidsPlanner) executor.planner();
+        List<AggregationNode> finalizeAggs = new ArrayList<>();
+        for (PlanFragment fragment : planner.getFragments()) {
+            collectFinalizeDistinctAggs(fragment.getPlanRoot(), finalizeAggs);
+        }
+        Assertions.assertFalse(finalizeAggs.isEmpty(), "no finalize DISTINCT 
agg found in plan");
+        for (AggregationNode agg : finalizeAggs) {
+            PlanNode child = agg.getChild(0);
+            Assertions.assertTrue(child instanceof LocalExchangeNode,
+                    "expected LocalExchangeNode directly below finalize 
DISTINCT agg, got: " + child);
+            LocalExchangeNode le = (LocalExchangeNode) child;
+            
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
le.getExchangeType(),
+                    "hash LE below finalize DISTINCT agg must be 
LOCAL_EXECUTION_HASH_SHUFFLE");
+            Assertions.assertFalse(le.getDistributeExprLists().isEmpty(),
+                    "hash LE below finalize DISTINCT agg must be keyed");
+            Assertions.assertTrue(le.getDistributeExprLists().stream()
+                            .anyMatch(e -> e.toString().contains(keyName)),
+                    "hash LE must be keyed by " + keyName + ", actual: " + 
le.getDistributeExprLists());
+        }
+    }
+
+    private void collectFinalizeDistinctAggs(PlanNode node, 
List<AggregationNode> found) {
+        // "output: multi_distinct_count(...)" pins the merge/finalize 
DISTINCT agg;
+        // the sum0(multi_distinct_count(...)) parent above it must not match.
+        if (node instanceof AggregationNode && node.getNodeExplainString("", 
TExplainLevel.NORMAL)
+                .contains("output: multi_distinct_count")) {
+            found.add((AggregationNode) node);
+        }
+        for (PlanNode child : node.getChildren()) {
+            collectFinalizeDistinctAggs(child, found);
+        }
+    }
+
     @Test
     public void testBroadcastJoinPoolingShapeDsl() throws Exception {
         // doc rule "HashJoin / BROADCAST / 池化":
diff --git 
a/regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out 
b/regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out
new file mode 100644
index 00000000000..507ebcdcc12
--- /dev/null
+++ 
b/regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out
@@ -0,0 +1,6 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !bug26_be_native --
+1
+
+-- !bug26_fe_planned --
+1
diff --git 
a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy
 
b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy
index 68c21941344..4401f0362d3 100644
--- 
a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy
+++ 
b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy
@@ -1614,5 +1614,54 @@ suite("test_local_shuffle_rqg_bugs") {
         assertTrue(false, "Bug 25: COLOCATE+NLJ CROSS probe: ${t.message}")
     }
 
+
+    // ============================================================
+    //  Bug 26: scalar count(distinct) over shuffle+broadcast joins returns
+    //  correct-value × task-count when agg_phase=1 + broadcast-join
+    //  force-passthrough with the FE local-shuffle planner.
+    //  Root cause (FE-planned): AggregationNode handed NoRequire to a finalize
+    //  merge agg with no group keys but DISTINCT aggregates; the PASSTHROUGH
+    //  local exchange below the broadcast-join probe scattered same-key rows,
+    //  and sum0(multi_distinct_count(...)) summed the overlapping per-instance
+    //  values. Fixed by keying the hash requirement on the effective partition
+    //  exprs (mirrors BE `_partition_exprs`).
+    // ============================================================
+    try {
+        logger.info("Bug 26: count(distinct) under agg_phase=1 + broadcast 
force-passthrough")
+        sql "DROP TABLE IF EXISTS rqg_local_shuffle_distinct_t1"
+        sql "DROP TABLE IF EXISTS rqg_local_shuffle_distinct_t2"
+        sql """CREATE TABLE rqg_local_shuffle_distinct_t1 (pk INT NOT NULL, k2 
INT NOT NULL)
+               ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 5
+               PROPERTIES ("replication_num"="1")"""
+        sql """CREATE TABLE rqg_local_shuffle_distinct_t2 (pk INT NOT NULL, k2 
INT NOT NULL, other INT NOT NULL)
+               ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 5
+               PROPERTIES ("replication_num"="1")"""
+        // Two rows sharing the same distinct key. batch_size=1 with 4 local 
tasks
+        // forces the PASSTHROUGH exchange to send separate blocks to different
+        // channels, so the pre-fix plan counts the shared key once per task.
+        sql "INSERT INTO rqg_local_shuffle_distinct_t1 VALUES (1, 5), (2, 5)"
+        sql "INSERT INTO rqg_local_shuffle_distinct_t2 VALUES (1, 5, 10), (2, 
5, 20)"
+
+        def distinctJoinQuery = { vars -> """
+            SELECT /*+SET_VAR(${vars})*/
+                count(distinct t1.k2) AS cnt_distinct
+            FROM rqg_local_shuffle_distinct_t1 t1
+            LEFT JOIN [shuffle] rqg_local_shuffle_distinct_t2 t2 ON t1.k2 = 
t2.k2
+            LEFT JOIN [broadcast] rqg_local_shuffle_distinct_t2 t3 ON t2.pk = 
t3.pk
+        """ }
+        def distinctJoinVariables = "enable_sql_cache=false, agg_phase=1, " +
+                "enable_broadcast_join_force_passthrough=true, 
parallel_pipeline_task_num=4, batch_size=1"
+        // Pin both implementations to the mathematically correct result (1). 
Using
+        // one implementation as the other's oracle would let a shared bug 
pass.
+        order_qt_bug26_be_native(distinctJoinQuery(
+                "${distinctJoinVariables}, 
enable_local_shuffle_planner=false"))
+        order_qt_bug26_fe_planned(distinctJoinQuery(
+                "${distinctJoinVariables}, enable_local_shuffle_planner=true"))
+        logger.info("Bug 26: PASSED")
+    } catch (Throwable t) {
+        logger.error("Bug 26 FAILED: ${t.message}")
+        assertTrue(false, "Bug 26: ${t.message}")
+    }
+
     logger.info("=== All RQG bug reproduction tests completed ===")
 }


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

Reply via email to