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

morningman 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 52362a455cd [fix](local shuffle) Keep local exchanges before 
pre-aggregations over key-breaking children (#70296)
52362a455cd is described below

commit 52362a455cd0a584431648f2ff16d429ec3bb8d1
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Nov 5 17:26:44 2026 +0000

    [fix](local shuffle) Keep local exchanges before pre-aggregations over 
key-breaking children (#70296)
    
    ### Background
    
    On master the FE plans the local exchanges that redistribute rows among
    the pipeline tasks of one BE (`enable_local_shuffle_planner`, on by
    default). Each plan node follows the rule of the BE operator it becomes,
    `required_data_distribution()`, which BE applies itself when the FE
    planner is off. `enable_local_exchange_before_agg` (default true)
    controls the exchange before a LOCAL (first-phase) aggregation. Since
    #63766, BE skips it with the switch off only while the child keeps the
    grouping-key distribution; a child breaks it when it is serial or asks
    for a non-hash local exchange itself, such as the probe side of a nested
    loop join or a table function.
    
    ### What problem does this PR solve?
    
    Issue Number: None
    Related PR: #63366 (introduced the FE rule without the child check),
    #63766 (the BE rule), #70177 (the same fix for streaming aggregation;
    this PR reuses its `childBreaksLocalKeyDistribution()`), #62438 (added
    the switch)
    PR Type: Bugfix
    
    Problem Summary:
    
    With `enable_local_exchange_before_agg=false`, the FE planner dropped
    the local exchange below a LOCAL `AggSink` and below a streaming
    distinct pre-aggregation whatever their child was, while BE keeps it
    after a child that breaks the key distribution. Queries planned by the
    FE therefore got fewer local exchanges than BE planning gives, for
    example no hash exchange between a nested loop join and the
    pre-aggregation above it. This PR adds BE's child check to both cases.
    Results do not change; most of the diff is tests.
    
    ### Details
    
    - **Change map:** production code +15 / -7 in `AggregationNode` (two
    conditions and their comments) and a `PlanNode` javadoc; tests +345 /
    -34: cases in `LocalShuffleNodeCoverageTest` and
    `LocalExchangePlannerTest`, and the new regression suite
    `nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child`
    with its generated `.out` (+169).
    - **Frequency:** Always, when `enable_local_exchange_before_agg=false`
    (not the default; P0 randomizes it) and a LOCAL aggregation sits on a
    child that breaks the key distribution. Only plans and speed change: the
    FE always requires hash input at FIRST_MERGE and at the non-streaming
    DISTINCT dedup (a deliberate divergence from BE), so results stay right.
    - **Root cause:** `AggregationNode.enforceAndDeriveLocalExchange()`
    mirrors the BE operators, but its DistinctStreamingAgg and AggSink LOCAL
    branches fall back to the base class on `!enableLeBeforeAgg` alone,
    without BE's `child_breaks_local_key_distribution()`. #63366 wrote them
    after #63766 had merged, from the rule before it.
    - **Why existing tests missed it:** Unit tests: the two "LE disabled"
    cases in `LocalShuffleNodeCoverageTest` use a child that keeps the key
    distribution, no case reached the DistinctStreamingAgg branch, and
    `LocalExchangePlannerTest` had no plan with the switch off. Regression:
    `query_p0/join/test_agg_after_nested_loop_join_local_exchange` (#63766's
    suite) and
    `nereids_p0/local_shuffle/test_enable_local_exchange_before_agg` compare
    only results, which were right, and no `.out` shows local exchanges.
    - **Fix:** Both branches fall back to the base class only when
    `childBreaksLocalKeyDistribution()` is false. After a key-breaking
    child, the AggSink LOCAL phase goes on to its hash requirement, and the
    streaming distinct pre-aggregation to
    `enable_distinct_streaming_agg_force_passthrough` (PASSTHROUGH, else the
    base class), as on BE; it never asks for hash.
    - **Other paths and branches:** Streaming aggregation got the check in
    #70177; BE needs no change. One known difference from BE planning
    remains, with the switch on or off: the FE lets an ADAPTIVE_PASSTHROUGH
    or PASSTHROUGH child output satisfy a PASSTHROUGH requirement, where BE
    `Pipeline::need_to_local_exchange()` always adds one (last row of the
    table in Testing). branch-4.1 and branch-4.2 have no FE local shuffle
    planner and are not affected.
    
    ### Testing
    
    - `LocalShuffleNodeCoverageTest` and `LocalExchangePlannerTest`, two new
    cases each: all four fail without the fix (for example, a serial child
    got `PASSTHROUGH` instead of `LOCAL_EXECUTION_HASH_SHUFFLE`) and pass
    with it. The 12 fe-core test classes that plan local exchanges or print
    distributed plans pass (168 tests). Run locally.
    - A planner probe over 288 plans (18 query shapes × pooling on and off ×
    `agg_phase` 0 and 2 × `disable_streaming_preaggregations` on and off ×
    the switch): only 32 with the switch off change. 30 of them now equal
    the plan with the switch on; the other 2 keep the distribution of a
    `UNION ALL`, as the switch asks.
    - Local exchanges in the query profiles on a local 1 FE + 1 BE cluster
    (master `4530f761fa1`; `doris-fe.jar` from `7c4b4462dcc` with #70177,
    and for "after" with only `AggregationNode.class` replaced), switch off,
    `parallel_pipeline_task_num=4`, `agg_phase=2`. The `AggSink` rows set
    `disable_streaming_preaggregations=true`; the distinct rows are a `GROUP
    BY` without aggregate functions:
    
    | Query | BE planner | FE planner before | FE planner after |
    |---|---|---|---|
    | LOCAL `AggSink` over a nested loop join | ADAPTIVE_PASSTHROUGH,
    LOCAL_HASH_SHUFFLE | ADAPTIVE_PASSTHROUGH | ADAPTIVE_PASSTHROUGH,
    LOCAL_HASH_SHUFFLE |
    | LOCAL `AggSink` over `explode` | PASSTHROUGH, LOCAL_HASH_SHUFFLE |
    PASSTHROUGH | PASSTHROUGH, LOCAL_HASH_SHUFFLE |
    | LOCAL `AggSink` over a one-tablet scan (serial) | PASSTHROUGH,
    LOCAL_HASH_SHUFFLE | PASSTHROUGH | PASSTHROUGH, LOCAL_HASH_SHUFFLE |
    | LOCAL `AggSink` over `UNION ALL` | none | none | none |
    | distinct pre-aggregation over `explode` | PASSTHROUGH ×2 | PASSTHROUGH
    | PASSTHROUGH ×2 |
    | distinct pre-aggregation over a nested loop join |
    ADAPTIVE_PASSTHROUGH, PASSTHROUGH | ADAPTIVE_PASSTHROUGH |
    ADAPTIVE_PASSTHROUGH |
    
    - The new regression suite fails on the FE without the fix at its first
    plan check (`nlj: [VNESTED]`) and passes with it; its 16 result blocks
    (4 queries × both planners × the switch on and off) match results
    computed independently from the inserted rows.
    `nereids_p0/local_shuffle` (11 suites),
    `query_p0/join/test_agg_after_nested_loop_join_local_exchange` and
    `nereids_syntax_p0/agg_4_phase` pass on the same cluster with the fix.
    
    ### Notes for Reviewers
    
    - `baseClassRequire()` stays in both branches although only a non-serial
    child reaches them now, since a serial child always breaks the key
    distribution. It keeps the branches shaped like BE's call to the base
    class, as #70177 does for streaming aggregation.
    - In `LocalShuffleNodeCoverageTest`, #70177's five key-breaking children
    moved into `childrenThatBreakKeyDistribution()` and its aggregate
    builder into `buildLocalPhaseAgg()`, so the new cases reuse them;
    #70177's case is otherwise unchanged.
    - The `PlanNode#requiresShuffleForCorrectness` javadoc still named
    `enable_local_exchange_before_agg` for streaming aggregation, which
    reads `enable_local_exchange_before_streaming_agg` since #70177.
---
 .../org/apache/doris/planner/AggregationNode.java  |  18 ++-
 .../java/org/apache/doris/planner/PlanNode.java    |   4 +-
 .../planner/LocalShuffleNodeCoverageTest.java      | 141 +++++++++++++----
 .../apache/doris/qe/LocalExchangePlannerTest.java  |  62 ++++++++
 ...st_local_exchange_before_agg_breaking_child.out | 169 ++++++++++++++++++++
 ...local_exchange_before_agg_breaking_child.groovy | 176 +++++++++++++++++++++
 6 files changed, 529 insertions(+), 41 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 8dd12e0a8b4..ed3e9322e35 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
@@ -280,7 +280,8 @@ public class AggregationNode extends PlanNode {
 
         ConnectContext connectContext = translatorContext.getConnectContext();
         SessionVariable sessionVariable = connectContext.getSessionVariable();
-        // PR #62438: when false, non-finalize agg falls back to BE base class.
+        // PR #62438: when false, a LOCAL-phase agg falls back to BE base 
class while its child
+        // keeps the local key distribution (PR #63766).
         boolean enableLeBeforeAgg = 
sessionVariable.enableLocalExchangeBeforeAgg;
         boolean hasKeys = !aggInfo.getGroupingExprs().isEmpty();
         boolean selfOrInheritedShuffled = 
translatorContext.hasShuffleForCorrectnessAncestor(this)
@@ -292,7 +293,8 @@ public class AggregationNode extends PlanNode {
         if (canUseDistinctStreamingAgg(sessionVariable)) {
             // DistinctStreamingAggOperatorX.  Two flavors share this operator 
class:
             //   - streaming preagg (useStreamingPreagg=true): 
performance-only,
-            //     flag controls
+            //     flag controls while the child keeps the key distribution; 
after a
+            //     child that breaks it, the force-passthrough rule below 
applies
             //   - non-streaming dedup (useStreamingPreagg=false): 
correctness-required,
             //     always HASH regardless of flag
             // Diverges from BE: BE's `!_needs_finalize && 
!enable_local_exchange_before_agg`
@@ -300,7 +302,8 @@ public class AggregationNode extends PlanNode {
             // wrong-result bug as AggSink (DORIS-25413).
             if (needsFinalize && !hasKeys) {
                 requireChild = LocalExchangeTypeRequire.noRequire();
-            } else if (!needsFinalize && useStreamingPreagg && 
!enableLeBeforeAgg) {
+            } else if (!needsFinalize && useStreamingPreagg && 
!enableLeBeforeAgg
+                    && !childBreaksLocalKeyDistribution(connectContext)) {
                 requireChild = baseClassRequire(connectContext);
             } else if (needsFinalize || (hasKeys && !useStreamingPreagg)) {
                 requireChild = AddLocalExchange.isColocated(this)
@@ -335,6 +338,7 @@ public class AggregationNode extends PlanNode {
             //
             // Phase semantics for !needsFinalize:
             //   - FIRST / SECOND (LOCAL phase, !isMerge): performance-only, 
flag controls
+            //     while the child keeps the key distribution
             //   - FIRST_MERGE (correctness-required): always HASH regardless 
of flag
             //
             // Diverges from BE here: BE's `!_needs_finalize && 
!enable_local_exchange_before_agg`
@@ -354,10 +358,12 @@ public class AggregationNode extends PlanNode {
                 requireChild = needsFinalize
                         ? LocalExchangeTypeRequire.noRequire()
                         : baseClassRequire(connectContext);
-            } else if (!needsFinalize && !aggInfo.isMerge() && 
!enableLeBeforeAgg) {
+            } else if (!needsFinalize && !aggInfo.isMerge() && 
!enableLeBeforeAgg
+                    && !childBreaksLocalKeyDistribution(connectContext)) {
                 // LOCAL phase (FIRST preagg / SECOND distinct local) + user 
opted out
-                // of pre-agg LE → base class decides: serial child → 
PASSTHROUGH
-                // (parallelism), non-serial child → NOOP (no LE).
+                // of pre-agg LE → base class decides. A serial child breaks 
the key
+                // distribution, so only a non-serial child gets here → NOOP 
(no LE);
+                // otherwise the next branch still requires HASH, as BE does.
                 requireChild = baseClassRequire(connectContext);
             } else if (!needsFinalize || AddLocalExchange.isColocated(this)) {
                 // FIRST_MERGE (correctness) or finalize+colocate → HASH.
diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java 
b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java
index d680afac953..3fa8a9698a2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java
@@ -895,7 +895,9 @@ public abstract class PlanNode extends TreeNode<PlanNode> {
      * Default is false; only operators that truly need hash for correctness 
override
      * (finalize AggSink with group keys, HashJoin PARTITIONED/BUCKET_SHUFFLE, 
Intersect,
      * Except, analytic SortNode, partition-by AnalyticEvalNode).  Operators 
that request
-     * hash for performance only (StreamingAgg pre-agg with 
enable_local_exchange_before_agg)
+     * hash for performance only (the pre-aggregations: StreamingAgg with
+     * enable_local_exchange_before_streaming_agg, the LOCAL phase of AggSink 
with
+     * enable_local_exchange_before_agg)
      * MUST NOT override — that would cause SetOperationNode to over-insert 
HASH LE on
      * every union branch even when nothing downstream actually needs 
correctness shuffling.
      *
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 eef6fbbe0e2..571b1319268 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
@@ -999,7 +999,8 @@ public class LocalShuffleNodeCoverageTest {
     @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.
+        // behavior (NOOP for a non-serial child that keeps the key 
distribution): 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();
@@ -1047,7 +1048,8 @@ public class LocalShuffleNodeCoverageTest {
     @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.
+        // behavior (NOOP for a non-serial child that keeps the key 
distribution): 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,
@@ -1161,37 +1163,30 @@ public class LocalShuffleNodeCoverageTest {
         sessionVariable.enableStreamingAggHashJoinForcePassthrough = false;
         sessionVariable.enableBroadcastJoinForcePassthrough = true;
 
-        SerialTrackingPlanNode serialChild = new 
SerialTrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP);
-        serialChild.fragment = Mockito.mock(PlanFragment.class);
-        
Mockito.when(serialChild.fragment.useSerialSource(Mockito.any())).thenReturn(true);
-
-        NestedLoopJoinNode nestedLoopJoin = new 
NestedLoopJoinNode(nextPlanNodeId(),
-                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
-                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
-                Lists.newArrayList(new TupleId(NEXT_ID.getAndIncrement())), 
JoinOperator.INNER_JOIN, false);
-        nestedLoopJoin.fragment = Mockito.mock(PlanFragment.class);
-        
Mockito.when(nestedLoopJoin.fragment.useSerialSource(Mockito.any())).thenReturn(false);
-
-        HashJoinNode broadcastJoin = new HashJoinNode(nextPlanNodeId(),
-                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
-                new TrackingPlanNode(nextPlanNodeId(), 
LocalExchangeType.NOOP), JoinOperator.INNER_JOIN,
-                
Collections.singletonList(Mockito.mock(BinaryPredicate.class)), 
Collections.emptyList(),
-                null, null, false);
-        broadcastJoin.setDistributionMode(DistributionMode.BROADCAST);
-
-        TableFunctionNode tableFunction = new 
TableFunctionNode(nextPlanNodeId(),
-                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
-                new TupleId(NEXT_ID.getAndIncrement()), new ArrayList<>(), new 
ArrayList<>(), new ArrayList<>());
+        for (PlanNode child : childrenThatBreakKeyDistribution()) {
+            AggregationNode agg = buildStreamingPreAgg(child);
+            Pair<PlanNode, LocalExchangeType> output = 
agg.enforceAndDeriveLocalExchange(
+                    mockTranslatorContext(sessionVariable), null, 
LocalExchangeTypeRequire.noRequire());
+            String name = child.getClass().getSimpleName();
+            
Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, 
output.second, name);
+            assertChildLocalExchangeType(agg, 0, 
LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE);
+            Assertions.assertSame(child, agg.getChild(0).getChild(0), name);
+        }
+    }
 
-        AssertNumRowsElement assertElement = 
Mockito.mock(AssertNumRowsElement.class);
-        
Mockito.when(assertElement.getAssertion()).thenReturn(AssertNumRowsElement.Assertion.EQ);
-        AssertNumRowsNode assertNumRows = new 
AssertNumRowsNode(nextPlanNodeId(),
-                new TrackingPlanNode(nextPlanNodeId(), 
LocalExchangeType.NOOP), assertElement,
-                new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement())));
+    @Test
+    public void 
testAggSinkLocalPhaseRequiresHashWhenChildBreaksKeyDistribution() {
+        // Mirrors BE AggSinkOperatorX after #63766: with 
enable_local_exchange_before_agg=false the
+        // LOCAL (non-merge, non-finalize) phase falls back to the base class 
only while the child
+        // keeps the local key distribution. After a child that is serial, or 
whose own BE operator
+        // requires a non-hash local exchange, it still hashes by its grouping 
keys.
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.enableLocalExchangeBeforeAgg = false;
+        sessionVariable.enableBroadcastJoinForcePassthrough = true;
 
-        for (PlanNode child : new PlanNode[] {serialChild, nestedLoopJoin, 
broadcastJoin, tableFunction,
-                assertNumRows}) {
-            AggregationNode agg = buildStreamingPreAgg(child);
+        for (PlanNode child : childrenThatBreakKeyDistribution()) {
+            AggregationNode agg = buildLocalPhaseAgg(child,
+                    
Collections.singletonList(plainAggregateFunction("count")), /* streaming */ 
false);
             Pair<PlanNode, LocalExchangeType> output = 
agg.enforceAndDeriveLocalExchange(
                     mockTranslatorContext(sessionVariable), null, 
LocalExchangeTypeRequire.noRequire());
             String name = child.getClass().getSimpleName();
@@ -1201,6 +1196,38 @@ public class LocalShuffleNodeCoverageTest {
         }
     }
 
+    @Test
+    public void 
testDistinctStreamingPreAggFollowsForcePassthroughWhenChildBreaksKeyDistribution()
 {
+        // Mirrors BE DistinctStreamingAggOperatorX after #63766: with
+        // enable_local_exchange_before_agg=false a streaming distinct preagg 
falls back to the base
+        // class only while the child keeps the local key distribution. After 
a child that breaks
+        // it, the preagg follows the same rule as with the switch on, which 
is never a hash:
+        // PASSTHROUGH when enable_distinct_streaming_agg_force_passthrough is 
on, else the base
+        // class. The joins and AssertNumRows already report a passthrough 
output that satisfies a
+        // PASSTHROUGH requirement, so only the table function (NOOP output) 
gets a new exchange.
+        for (boolean forcePassthrough : new boolean[] {true, false}) {
+            SessionVariable sessionVariable = new SessionVariable();
+            sessionVariable.enableLocalExchangeBeforeAgg = false;
+            sessionVariable.enableBroadcastJoinForcePassthrough = true;
+            sessionVariable.enableDistinctStreamingAggForcePassthrough = 
forcePassthrough;
+
+            for (PlanNode child : childrenThatBreakKeyDistribution()) {
+                AggregationNode agg = buildLocalPhaseAgg(child, 
Collections.emptyList(), /* streaming */ true);
+                agg.enforceAndDeriveLocalExchange(
+                        mockTranslatorContext(sessionVariable), null, 
LocalExchangeTypeRequire.noRequire());
+                String name = child.getClass().getSimpleName() + ", force 
passthrough " + forcePassthrough;
+                // The base class also asks for PASSTHROUGH over the serial 
child.
+                if (child instanceof SerialTrackingPlanNode
+                        || (forcePassthrough && child instanceof 
TableFunctionNode)) {
+                    assertChildLocalExchangeType(agg, 0, 
LocalExchangeType.PASSTHROUGH);
+                    Assertions.assertSame(child, agg.getChild(0).getChild(0), 
name);
+                } else {
+                    Assertions.assertSame(child, agg.getChild(0), name);
+                }
+            }
+        }
+    }
+
     @Test
     public void testStreamingPreAggKeepsDistributionOfJoinsThatKeepKeys() {
         // The join probes that BE leaves on NOOP do not break the key 
distribution, so the
@@ -1307,17 +1334,63 @@ public class LocalShuffleNodeCoverageTest {
 
     /** A LOCAL-phase streaming preagg with one grouping key over {@code 
child}. */
     private static AggregationNode buildStreamingPreAgg(PlanNode child) {
-        FunctionCallExpr count = plainAggregateFunction("count");
+        return buildLocalPhaseAgg(child, 
Collections.singletonList(plainAggregateFunction("count")),
+                /* streaming */ true);
+    }
+
+    /**
+     * A LOCAL-phase (non-merge, non-finalize) agg with one grouping key over 
{@code child}: a
+     * DistinctStreamingAgg without aggregate functions, otherwise a streaming 
preagg or an AggSink.
+     */
+    private static AggregationNode buildLocalPhaseAgg(PlanNode child, 
List<FunctionCallExpr> aggExprs,
+            boolean streaming) {
         AggregateInfo aggInfo = Mockito.mock(AggregateInfo.class);
         Mockito.when(aggInfo.getOutputTupleId()).thenReturn(new 
TupleId(NEXT_ID.getAndIncrement()));
         
Mockito.when(aggInfo.getGroupingExprs()).thenReturn(Lists.newArrayList(Mockito.mock(Expr.class)));
-        
Mockito.when(aggInfo.getAggregateExprs()).thenReturn(Lists.newArrayList(count));
+        Mockito.when(aggInfo.getAggregateExprs()).thenReturn(new 
ArrayList<>(aggExprs));
         AggregationNode agg = new AggregationNode(nextPlanNodeId(), child, 
aggInfo);
         agg.unsetNeedsFinalize();
-        agg.setUseStreamingPreagg(true);
+        agg.setUseStreamingPreagg(streaming);
         return agg;
     }
 
+    /**
+     * One child of each kind that breaks the local key distribution on BE: a 
serial child, and the
+     * operators whose own BE required_data_distribution() is a non-hash local 
exchange. The
+     * broadcast hash join breaks it only with 
enable_broadcast_join_force_passthrough on.
+     */
+    private static List<PlanNode> childrenThatBreakKeyDistribution() {
+        SerialTrackingPlanNode serialChild = new 
SerialTrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP);
+        serialChild.fragment = Mockito.mock(PlanFragment.class);
+        
Mockito.when(serialChild.fragment.useSerialSource(Mockito.any())).thenReturn(true);
+
+        NestedLoopJoinNode nestedLoopJoin = new 
NestedLoopJoinNode(nextPlanNodeId(),
+                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
+                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
+                Lists.newArrayList(new TupleId(NEXT_ID.getAndIncrement())), 
JoinOperator.INNER_JOIN, false);
+        nestedLoopJoin.fragment = Mockito.mock(PlanFragment.class);
+        
Mockito.when(nestedLoopJoin.fragment.useSerialSource(Mockito.any())).thenReturn(false);
+
+        HashJoinNode broadcastJoin = new HashJoinNode(nextPlanNodeId(),
+                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
+                new TrackingPlanNode(nextPlanNodeId(), 
LocalExchangeType.NOOP), JoinOperator.INNER_JOIN,
+                
Collections.singletonList(Mockito.mock(BinaryPredicate.class)), 
Collections.emptyList(),
+                null, null, false);
+        broadcastJoin.setDistributionMode(DistributionMode.BROADCAST);
+
+        TableFunctionNode tableFunction = new 
TableFunctionNode(nextPlanNodeId(),
+                new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP),
+                new TupleId(NEXT_ID.getAndIncrement()), new ArrayList<>(), new 
ArrayList<>(), new ArrayList<>());
+
+        AssertNumRowsElement assertElement = 
Mockito.mock(AssertNumRowsElement.class);
+        
Mockito.when(assertElement.getAssertion()).thenReturn(AssertNumRowsElement.Assertion.EQ);
+        AssertNumRowsNode assertNumRows = new 
AssertNumRowsNode(nextPlanNodeId(),
+                new TrackingPlanNode(nextPlanNodeId(), 
LocalExchangeType.NOOP), assertElement,
+                new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement())));
+
+        return Lists.newArrayList(serialChild, nestedLoopJoin, broadcastJoin, 
tableFunction, assertNumRows);
+    }
+
     private static FunctionCallExpr plainAggregateFunction(String 
functionName) {
         FunctionCallExpr fce = Mockito.mock(FunctionCallExpr.class);
         FunctionName fnName = Mockito.mock(FunctionName.class);
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 6a474b8896f..4f6446c0cf1 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
@@ -86,6 +86,8 @@ public class LocalExchangePlannerTest extends 
TestWithFeService implements PlanS
         sv.enableBroadcastJoinForcePassthrough = false;
         sv.enableShareHashTableForBroadcastJoin = true;
         sv.enableLocalExchangeBeforeStreamingAgg = false;
+        sv.enableLocalExchangeBeforeAgg = true;
+        sv.disableStreamPreaggregations = false;
         if (tweaks != null) {
             tweaks.accept(sv);
         }
@@ -693,6 +695,66 @@ public class LocalExchangePlannerTest extends 
TestWithFeService implements PlanS
                                                 localExchange(PT, 
olapScan("t1")))))));
     }
 
+    @Test
+    public void 
testLocalPhaseAggHashShufflesAfterChildThatBreaksKeyDistribution() throws 
Exception {
+        // With enable_local_exchange_before_agg=false a LOCAL-phase AggSink 
(streaming preagg off)
+        // keeps the inherited distribution only while its child keeps the key 
distribution, as BE
+        // AggSinkOperatorX decides since #63766. After the 
ADAPTIVE_PASSTHROUGH probe side of a
+        // nested loop join, the PASSTHROUGH input of a table function, or a 
serial pooling scan it
+        // still hashes its input by the grouping keys.
+        setupLocalShuffleSession(sv -> {
+            sv.setIgnoreStorageDataDistribution(false);
+            sv.enableLocalExchangeBeforeAgg = false;
+            sv.disableStreamPreaggregations = true;
+        });
+        assertPlanShape(
+                "select t1.k2, count(*) from test.t1 join test.t2 on t1.v1 > 
t2.v2 group by t1.k2",
+                anyTree(
+                        agg(
+                                localExchange(LOCAL_HASH,
+                                        nestedLoopJoin(
+                                                localExchange(ADAPTIVE_PT, 
olapScan()),
+                                                exchange())))));
+        assertPlanShape(
+                "select e, count(*) from test.t1 lateral view 
explode(array(k1, k2)) tmp as e group by e",
+                anyTree(
+                        agg(
+                                localExchange(LOCAL_HASH,
+                                        node(TableFunctionNode.class,
+                                                localExchange(PT, 
olapScan("t1")))))));
+        setupLocalShuffleSession(sv -> {
+            sv.enableLocalExchangeBeforeAgg = false;
+            sv.disableStreamPreaggregations = true;
+        });
+        assertPlanShape(
+                "select k2, count(*) from test.t1 group by k2",
+                anyTree(
+                        agg(
+                                localExchange(LOCAL_HASH,
+                                        localExchange(PT, olapScan("t1"))))));
+    }
+
+    @Test
+    public void testDistinctStreamingPreAggPassesThroughTableFunctionOutput() 
throws Exception {
+        // A GROUP BY without aggregate functions runs its LOCAL phase as a 
streaming distinct
+        // preagg. With enable_local_exchange_before_agg=false BE 
DistinctStreamingAggOperatorX
+        // still applies enable_distinct_streaming_agg_force_passthrough 
(default true) after a
+        // child that breaks the key distribution (#63766), so above a table 
function, whose
+        // output is NOOP, the FE planner adds the PASSTHROUGH local exchange 
it adds with the
+        // switch on.
+        setupLocalShuffleSession(sv -> {
+            sv.setIgnoreStorageDataDistribution(false);
+            sv.enableLocalExchangeBeforeAgg = false;
+        });
+        assertPlanShape(
+                "select e from test.t1 lateral view explode(array(k1, k2)) tmp 
as e group by e",
+                anyTree(
+                        agg(
+                                localExchange(PT,
+                                        node(TableFunctionNode.class,
+                                                localExchange(PT, 
olapScan("t1")))))));
+    }
+
     @Test
     public void testAnalyticPlanContainsPassthroughAndLocalHashShuffle() 
throws Exception {
         // doc rule "Analytic / 有 partition by / 池化": LE(LOCAL_HASH) for 
partition
diff --git 
a/regression-test/data/nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child.out
 
b/regression-test/data/nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child.out
new file mode 100644
index 00000000000..93ea7e85a13
--- /dev/null
+++ 
b/regression-test/data/nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child.out
@@ -0,0 +1,169 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !nlj_planner_true_le_false --
+10     7       25
+20     6       17
+30     3       4
+40     4       14
+
+-- !nlj_planner_true_le_true --
+10     7       25
+20     6       17
+30     3       4
+40     4       14
+
+-- !nlj_planner_false_le_false --
+10     7       25
+20     6       17
+30     3       4
+40     4       14
+
+-- !nlj_planner_false_le_true --
+10     7       25
+20     6       17
+30     3       4
+40     4       14
+
+-- !explode_planner_true_le_false --
+1      1
+10     3
+2      1
+20     2
+3      1
+30     2
+4      1
+40     1
+5      1
+6      1
+7      1
+8      1
+
+-- !explode_planner_true_le_true --
+1      1
+10     3
+2      1
+20     2
+3      1
+30     2
+4      1
+40     1
+5      1
+6      1
+7      1
+8      1
+
+-- !explode_planner_false_le_false --
+1      1
+10     3
+2      1
+20     2
+3      1
+30     2
+4      1
+40     1
+5      1
+6      1
+7      1
+8      1
+
+-- !explode_planner_false_le_true --
+1      1
+10     3
+2      1
+20     2
+3      1
+30     2
+4      1
+40     1
+5      1
+6      1
+7      1
+8      1
+
+-- !union_planner_true_le_false --
+10     5       21
+20     3       20
+30     2       5
+40     1       8
+50     1       2
+60     1       5
+
+-- !union_planner_true_le_true --
+10     5       21
+20     3       20
+30     2       5
+40     1       8
+50     1       2
+60     1       5
+
+-- !union_planner_false_le_false --
+10     5       21
+20     3       20
+30     2       5
+40     1       8
+50     1       2
+60     1       5
+
+-- !union_planner_false_le_true --
+10     5       21
+20     3       20
+30     2       5
+40     1       8
+50     1       2
+60     1       5
+
+-- !explode_distinct_planner_true_le_false --
+1
+10
+2
+20
+3
+30
+4
+40
+5
+6
+7
+8
+
+-- !explode_distinct_planner_true_le_true --
+1
+10
+2
+20
+3
+30
+4
+40
+5
+6
+7
+8
+
+-- !explode_distinct_planner_false_le_false --
+1
+10
+2
+20
+3
+30
+4
+40
+5
+6
+7
+8
+
+-- !explode_distinct_planner_false_le_true --
+1
+10
+2
+20
+3
+30
+4
+40
+5
+6
+7
+8
+
diff --git 
a/regression-test/suites/nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child.groovy
 
b/regression-test/suites/nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child.groovy
new file mode 100644
index 00000000000..f681732c545
--- /dev/null
+++ 
b/regression-test/suites/nereids_p0/local_shuffle/test_local_exchange_before_agg_breaking_child.groovy
@@ -0,0 +1,176 @@
+// 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.
+
+/**
+ * With enable_local_exchange_before_agg=false the FE local shuffle planner 
decides the local
+ * exchange below the LOCAL phase of an aggregation the way BE does since 
apache/doris#63766: the
+ * phase keeps the distribution it inherits only while its child keeps the 
grouping-key
+ * distribution. After a child that breaks it (a serial child, or one that 
spreads rows with a
+ * non-hash local exchange, such as the probe side of a nested loop join or 
the input of a table
+ * function):
+ *   - a LOCAL AggSink (streaming preaggregation off) still hashes its input 
by the grouping keys;
+ *   - a streaming distinct preaggregation (a GROUP BY without aggregate 
functions) applies
+ *     enable_distinct_streaming_agg_force_passthrough, as with the switch on.
+ *
+ * Local exchanges only show up in EXPLAIN DISTRIBUTED PLAN, where the node 
printed right below a
+ * VAGGREGATE (update serialize) is the child of that LOCAL phase.
+ */
+suite("test_local_exchange_before_agg_breaking_child", "p0") {
+    sql "DROP TABLE IF EXISTS le_agg_breaking_t1"
+    sql "DROP TABLE IF EXISTS le_agg_breaking_t2"
+
+    sql """
+        CREATE TABLE le_agg_breaking_t1 (
+            k1 INT NOT NULL,
+            k2 INT,
+            v1 INT
+        ) ENGINE=OLAP
+        DUPLICATE KEY(k1)
+        DISTRIBUTED BY HASH(k1) BUCKETS 16
+        PROPERTIES ("replication_num" = "1")
+    """
+
+    sql """
+        CREATE TABLE le_agg_breaking_t2 (
+            k1 INT NOT NULL,
+            k2 INT,
+            v2 INT
+        ) ENGINE=OLAP
+        DUPLICATE KEY(k1)
+        DISTRIBUTED BY HASH(k1) BUCKETS 16
+        PROPERTIES ("replication_num" = "1")
+    """
+
+    sql """INSERT INTO le_agg_breaking_t1 VALUES
+        (1, 10, 1), (2, 20, 5), (3, 10, 9), (4, 30, 2),
+        (5, 20, 7), (6, 10, 4), (7, 40, 8), (8, 30, 3)
+    """
+
+    sql """INSERT INTO le_agg_breaking_t2 VALUES
+        (1, 10, 6), (2, 50, 2), (3, 20, 8), (4, 10, 1), (5, 60, 5)
+    """
+
+    // Pin the fuzzy variables that change the aggregation or exchange shape. 
Non-pooling scans of
+    // 16 buckets stay parallel on every BE, so only the child under test can 
break the key
+    // distribution.
+    def hints = { String planner, String leBeforeAgg, String disableStreaming 
-> """/*+SET_VAR(
+        enable_local_shuffle_planner=${planner},
+        enable_local_exchange_before_agg=${leBeforeAgg},
+        disable_streaming_preaggregations=${disableStreaming},
+        enable_local_shuffle=true,
+        ignore_storage_data_distribution=false,
+        parallel_pipeline_task_num=4,
+        enable_bucketed_hash_agg=false,
+        use_serial_exchange=false,
+        enable_local_exchange_before_streaming_agg=false,
+        enable_distinct_streaming_aggregation=true,
+        enable_distinct_streaming_agg_force_passthrough=true,
+        disable_join_reorder=true,
+        enable_sql_cache=false
+    )*/""" }
+
+    // Each query with the disable_streaming_preaggregations value that gives 
the LOCAL phase under
+    // test: an AggSink for the first three, a streaming distinct 
preaggregation for the last.
+    def queries = [
+        // The probe side of a nested loop join is ADAPTIVE_PASSTHROUGH.
+        nlj: ["true", { String h -> """SELECT ${h} t1.k2, count(*), sum(t2.v2)
+            FROM le_agg_breaking_t1 t1 JOIN le_agg_breaking_t2 t2 ON t1.v1 > 
t2.v2
+            GROUP BY t1.k2""" }],
+        // A table function takes PASSTHROUGH input.
+        explode: ["true", { String h -> """SELECT ${h} e, count(*)
+            FROM le_agg_breaking_t1 LATERAL VIEW explode(array(k1, k2)) tmp AS 
e
+            GROUP BY e""" }],
+        // A union keeps the key distribution.
+        union: ["true", { String h -> """SELECT ${h} k2, count(*), sum(v)
+            FROM (SELECT k2, v1 AS v FROM le_agg_breaking_t1
+                  UNION ALL
+                  SELECT k2, v2 AS v FROM le_agg_breaking_t2) u
+            GROUP BY k2""" }],
+        explode_distinct: ["false", { String h -> """SELECT ${h} e
+            FROM le_agg_breaking_t1 LATERAL VIEW explode(array(k1, k2)) tmp AS 
e
+            GROUP BY e""" }],
+    ]
+    def query = { String name, String planner, String leBeforeAgg ->
+        def (disableStreaming, template) = queries[name]
+        return template(hints(planner, leBeforeAgg, disableStreaming))
+    }
+
+    // The plan node right below each LOCAL aggregate, with the type of a 
local exchange.
+    def belowLocalAggs = { String plan ->
+        def lines = plan.readLines()
+        def below = []
+        for (int i = 0; i < lines.size(); i++) {
+            if (!(lines[i] =~ /\d+:VAGGREGATE \(update serialize\)/)) {
+                continue
+            }
+            for (int j = i + 1; j < lines.size(); j++) {
+                def node = lines[j] =~ /^[\s|]*(?:----)?\d+:(V[A-Za-z-]+)/
+                if (node.find()) {
+                    def name = node.group(1)
+                    if (name == "VLOCAL-EXCHANGE" && j + 1 < lines.size()) {
+                        def type = lines[j + 1] =~ /type: (\S+)/
+                        name += type.find() ? ":" + type.group(1) : ""
+                    }
+                    below << name
+                    break
+                }
+            }
+        }
+        return below
+    }
+    def localHash = "VLOCAL-EXCHANGE:LOCAL_EXECUTION_HASH_SHUFFLE"
+    def passthrough = "VLOCAL-EXCHANGE:PASSTHROUGH"
+
+    // A LOCAL AggSink still hashes after a child that breaks the key 
distribution.
+    for (def name : ["nlj", "explode"]) {
+        explain {
+            sql "distributed plan ${query(name, 'true', 'false')}"
+            check { plan ->
+                def below = belowLocalAggs(plan)
+                assertTrue(below.contains(localHash), "${name}: 
${below}\n${plan}")
+            }
+        }
+    }
+
+    // It keeps the distribution of a child that does not break it.
+    explain {
+        sql "distributed plan ${query('union', 'true', 'false')}"
+        check { plan ->
+            def below = belowLocalAggs(plan)
+            assertTrue(below.contains("VUNION") && !below.contains(localHash), 
"${below}\n${plan}")
+        }
+    }
+
+    // A streaming distinct preaggregation above a table function, whose 
output distribution is
+    // unknown, gets the PASSTHROUGH exchange of 
enable_distinct_streaming_agg_force_passthrough.
+    explain {
+        sql "distributed plan ${query('explode_distinct', 'true', 'false')}"
+        check { plan ->
+            def below = belowLocalAggs(plan)
+            assertTrue(below.contains(passthrough), "${below}\n${plan}")
+        }
+    }
+
+    // Same results from the FE and BE local shuffle planners, with the switch 
off and on.
+    for (def name : queries.keySet()) {
+        for (def planner : ["true", "false"]) {
+            for (def leBeforeAgg : ["false", "true"]) {
+                
"order_qt_${name}_planner_${planner}_le_${leBeforeAgg}"(query(name, planner, 
leBeforeAgg))
+            }
+        }
+    }
+}


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

Reply via email to