This is an automated email from the ASF dual-hosted git repository.
924060929 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 589fd3e9a1e [opt](local shuffle) support bucket shuffle for set
operation (#65129)
589fd3e9a1e is described below
commit 589fd3e9a1e8bd05d2baba5533e20f164db9854c
Author: 924060929 <[email protected]>
AuthorDate: Fri Jul 17 11:19:10 2026 +0800
[opt](local shuffle) support bucket shuffle for set operation (#65129)
Related PR: #59006, #60823
Problem Summary:
Re-enable bucket shuffle for set operation (union / intersect / except).
This optimization was introduced by #59006 and later disabled by #60823,
because at that time the backend could not plan the correct local
shuffle type for set operation, which produced wrong results. Now that
planning local shuffle in the frontend is supported, the frontend plans
the correct local shuffle type, so it is safe to re-enable this feature.
When `enable_local_shuffle_planner` is enabled (the default), the
optimizer keeps the distribution of the largest natural /
storage-bucketed child of a set operation and bucket-shuffles the other
children onto it (the same idea as bucket shuffle join), which avoids a
full reshuffle of the largest side. When it is disabled, the previous
full-shuffle behavior is kept, so this change is gated and only takes
effect with the frontend local shuffle planner.
Besides re-enabling the planning, two local-exchange alignment problems
that the re-enabled shapes expose are fixed (both verified on a 4-BE
cluster with a stable wrong-result reproduction before the fix and
correct results after):
1. `SetOperationNode.enforceAndDeriveLocalExchange` only handled the
colocate mode. A bucket-shuffle intersect / except whose basic child is
a join output (instead of a direct scan) fell into the partitioned
branch, so the basic side was locally re-partitioned by execution hash
while the other side stayed bucket-distributed, and the set operation
lost rows. Now the bucket-shuffle mode takes the same
`requireBucketHash` path as colocate, mirroring `HashJoinNode`.
2. `RequireSpecific.autoRequireHash()` degraded
`LOCAL_EXECUTION_HASH_SHUFFLE` to the generic hash requirement.
Pass-through operators (union / streaming agg / sort) forward this
requirement to children while claiming the specific type to their
parent, so under a bucket join upgraded to local hash, a
bucket-distributed child could satisfy the generic requirement and keep
its bucket placement while the parent skipped its re-align local
exchange, and the mixed placements computed wrong results. A specific
hash requirement is now forwarded as-is.
The regression suite disables the bucket shuffle downgrade so the chosen
shapes do not depend on the backend count / parallelism of the
environment, and adds a case where the basic child of a bucket-shuffle
intersect is a join output.
---
.../glue/translator/PhysicalPlanTranslator.java | 36 ++-
.../properties/ChildOutputPropertyDeriver.java | 121 +++++---
.../properties/ChildrenPropertiesRegulator.java | 195 ++++++++-----
.../nereids/properties/RequestPropertyDeriver.java | 55 +++-
.../job/UnassignedScanBucketOlapTableJob.java | 12 +-
.../trees/plans/physical/PhysicalSetOperation.java | 1 -
.../apache/doris/planner/LocalExchangeNode.java | 11 +-
.../org/apache/doris/planner/SetOperationNode.java | 10 +-
.../job/UnassignedScanBucketOlapTableJobTest.java | 40 +++
.../planner/LocalShuffleNodeCoverageTest.java | 49 ++++
.../bucket_shuffle_set_operation.out | 185 ++++++++++++
.../bucket_shuffle_set_operation.groovy | 315 ++++++++++++++++++++-
12 files changed, 881 insertions(+), 149 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
index 01d91395775..62e35f74473 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
@@ -2487,14 +2487,34 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
setOperationNode.setColocate(true);
}
- // TODO: open comment when support `enable_local_shuffle_planner`
- // for (Plan child : setOperation.children()) {
- // PhysicalPlan childPhysicalPlan = (PhysicalPlan) child;
- // if
(JoinUtils.isStorageBucketed(childPhysicalPlan.getPhysicalProperties())) {
- //
setOperationNode.setDistributionMode(DistributionMode.BUCKET_SHUFFLE);
- // break;
- // }
- // }
+ // Storage-bucketed children only appear when the FE local shuffle
planner is active:
+ // ChildrenPropertiesRegulator and RequestPropertyDeriver both gate
the bucket-shuffle
+ // alternative on enableLocalShufflePlanner. Gate the marker on the
same flag so the
+ // dependency is explicit and a future planner change that produced a
STORAGE_BUCKETED
+ // distribution outside the local-shuffle planner cannot silently mark
BUCKET_SHUFFLE here.
+ //
+ // Within that gate a storage-bucketed child means the regulator chose
the bucket shuffle
+ // alternative (it enforces the other children onto the basic child's
buckets), so the marker
+ // simply follows that decision. It must not re-check the table id
independently: the basic
+ // child selection in the regulator is the single place that vets the
layout, and re-checking
+ // here could suppress a bucket shuffle the property model already
committed to and desync a
+ // parent that aligned to the set operation output.
+ //
+ // Unlike hash join, BUCKET_SHUFFLE is not exclusive with isColocate
above: for a set
+ // operation isColocate describes the bucket-aligned scheduling of the
fragment (the
+ // basic child scans buckets directly), while BUCKET_SHUFFLE describes
how the other
+ // children arrive (bucket-shuffle exchanges). Both routes converge to
the same
+ // bucket-hash local exchange requirement in
SetOperationNode.enforceAndDeriveLocalExchange.
+ if (context.getSessionVariable() != null
+ && context.getSessionVariable().isEnableLocalShufflePlanner())
{
+ for (Plan child : setOperation.children()) {
+ PhysicalPlan childPhysicalPlan = (PhysicalPlan) child;
+ if
(JoinUtils.isStorageBucketed(childPhysicalPlan.getPhysicalProperties())) {
+
setOperationNode.setDistributionMode(DistributionMode.BUCKET_SHUFFLE);
+ break;
+ }
+ }
+ }
return setOperationFragment;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
index 616bd387616..823231aa317 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
@@ -71,9 +71,11 @@ import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -447,53 +449,78 @@ public class ChildOutputPropertyDeriver extends
PlanVisitor<PhysicalProperties,
return PhysicalProperties.GATHER;
}
- // TODO: open comment when support `enable_local_shuffle_planner`
- // int distributeToChildIndex
- // =
setOperation.<Integer>getMutableState(PhysicalSetOperation.DISTRIBUTE_TO_CHILD_INDEX).orElse(-1);
- // if (distributeToChildIndex >= 0
- // && childrenDistribution.get(distributeToChildIndex)
instanceof DistributionSpecHash) {
- // DistributionSpecHash childDistribution
- // = (DistributionSpecHash)
childrenDistribution.get(distributeToChildIndex);
- // List<SlotReference> childToIndex =
setOperation.getRegularChildrenOutputs().get(distributeToChildIndex);
- // Map<ExprId, Integer> idToOutputIndex = new LinkedHashMap<>();
- // for (int j = 0; j < childToIndex.size(); j++) {
- // idToOutputIndex.put(childToIndex.get(j).getExprId(), j);
- // }
- //
- // List<ExprId> orderedShuffledColumns =
childDistribution.getOrderedShuffledColumns();
- // List<ExprId> setOperationDistributeColumnIds = new
ArrayList<>();
- // for (ExprId tableDistributeColumnId : orderedShuffledColumns) {
- // Integer index =
idToOutputIndex.get(tableDistributeColumnId);
- // if (index == null) {
- // break;
- // }
- //
setOperationDistributeColumnIds.add(setOperation.getOutput().get(index).getExprId());
- // }
- // // check whether the set operation output all distribution
columns of the child
- // if (setOperationDistributeColumnIds.size() ==
orderedShuffledColumns.size()) {
- // boolean isUnion = setOperation instanceof Union;
- // boolean shuffleToRight = distributeToChildIndex > 0;
- // if (!isUnion && shuffleToRight) {
- // return new PhysicalProperties(
- // new DistributionSpecHash(
- // setOperationDistributeColumnIds,
- // ShuffleType.EXECUTION_BUCKETED
- // )
- // );
- // } else {
- // // keep the distribution as the child
- // return new PhysicalProperties(
- // new DistributionSpecHash(
- // setOperationDistributeColumnIds,
- // childDistribution.getShuffleType(),
- // childDistribution.getTableId(),
- // childDistribution.getSelectedIndexId(),
- // childDistribution.getPartitionIds()
- // )
- // );
- // }
- // }
- // }
+ // After ChildrenPropertiesRegulator the children distributions are
already legal, so the
+ // output is derived by describing what the children provide:
+ // 1. one or more NATURAL children: output the first NATURAL child's
distribution
+ // (several NATURAL children behave like colocate);
+ // 2. no NATURAL but some STORAGE_BUCKETED child: output its
STORAGE_BUCKETED distribution;
+ // 3. all EXECUTION_BUCKETED children: output the execution hash (the
generic loop below);
+ // 4. anything else (e.g. random): output a non-specific property
(also below).
+ // When the basic child does not directly output its shuffle columns,
or the children do
+ // not agree, this falls through to the generic loop below, which is
equivalence-set aware
+ // and checks that every child maps its shuffle columns to the same
set-operation output
+ // positions before it claims a bucketed output. The basic child is
recomputed from the
+ // children distributions instead of being carried as mutable planner
state, because mutable
+ // state does not survive the with-copies in chooseBestPlan() and the
+ // RecomputePhysicalPropertiesPostProcessor re-derivation, while this
recomputation is
+ // deterministic on any copy of the plan.
+ int distributeToChildIndex = -1;
+ int firstStorageBucketedIndex = -1;
+ for (int i = 0; i < childrenDistribution.size(); i++) {
+ if (childrenDistribution.get(i) instanceof DistributionSpecHash) {
+ ShuffleType childShuffleType
+ = ((DistributionSpecHash)
childrenDistribution.get(i)).getShuffleType();
+ if (childShuffleType == ShuffleType.NATURAL) {
+ distributeToChildIndex = i;
+ break;
+ } else if (childShuffleType == ShuffleType.STORAGE_BUCKETED
+ && firstStorageBucketedIndex < 0) {
+ firstStorageBucketedIndex = i;
+ }
+ }
+ }
+ if (distributeToChildIndex < 0) {
+ distributeToChildIndex = firstStorageBucketedIndex;
+ }
+ if (distributeToChildIndex >= 0) {
+ DistributionSpecHash childDistribution
+ = (DistributionSpecHash)
childrenDistribution.get(distributeToChildIndex);
+ List<SlotReference> childToIndex =
setOperation.getRegularChildrenOutputs().get(distributeToChildIndex);
+ Map<ExprId, Integer> idToOutputIndex = new LinkedHashMap<>();
+ for (int j = 0; j < childToIndex.size(); j++) {
+ idToOutputIndex.put(childToIndex.get(j).getExprId(), j);
+ }
+
+ List<ExprId> orderedShuffledColumns =
childDistribution.getOrderedShuffledColumns();
+ List<ExprId> setOperationDistributeColumnIds = new ArrayList<>();
+ for (ExprId tableDistributeColumnId : orderedShuffledColumns) {
+ Integer index = idToOutputIndex.get(tableDistributeColumnId);
+ if (index == null) {
+ break;
+ }
+
setOperationDistributeColumnIds.add(setOperation.getOutput().get(index).getExprId());
+ }
+ // check whether the set operation output all distribution columns
of the child
+ if (setOperationDistributeColumnIds.size() ==
orderedShuffledColumns.size()) {
+ // Keep the basic child's specific storage layout as the set
operation output. When
+ // the basic child is on the right (shuffleToRight) the output
rows are physically
+ // placed by the right child's storage bucket function, so
advertising that layout is
+ // truthful: a parent can co-locate this set operation against
the same layout, while
+ // a sibling on a different layout is re-aligned rather than
wrongly co-located. (The
+ // earlier "Can not find tablet ... in the bucket" failure
came from advertising a
+ // layout-less EXECUTION_BUCKETED here, which erased the table
id so two different-
+ // layout set operations looked co-locatable; preserving the
layout fixes that.)
+ return new PhysicalProperties(
+ new DistributionSpecHash(
+ setOperationDistributeColumnIds,
+ childDistribution.getShuffleType(),
+ childDistribution.getTableId(),
+ childDistribution.getSelectedIndexId(),
+ childDistribution.getPartitionIds()
+ )
+ );
+ }
+ }
for (int i = 0; i < childrenDistribution.size(); i++) {
DistributionSpec childDistribution = childrenDistribution.get(i);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
index e5e0d9d1bd0..6aa9d01c7cc 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
@@ -57,6 +57,7 @@ import org.apache.doris.statistics.util.StatisticsUtil;
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 org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -651,83 +652,103 @@ public class ChildrenPropertiesRegulator extends
PlanVisitor<List<List<PhysicalP
} else if (requiredDistributionSpec instanceof DistributionSpecHash) {
// TODO: should use the most common hash spec as basic
DistributionSpecHash basic = (DistributionSpecHash)
requiredDistributionSpec;
- // TODO: open comment when support `enable_local_shuffle_planner`
- // int bucketShuffleBasicIndex = -1;
- // double basicRowCount = -1;
-
- // find the bucket shuffle basic index
- // try {
- // ImmutableSet<ShuffleType> supportBucketShuffleTypes =
ImmutableSet.of(
- // ShuffleType.NATURAL,
- // ShuffleType.STORAGE_BUCKETED
- // );
- // for (int i = 0; i < originChildrenProperties.size(); i++) {
- // PhysicalProperties originChildrenProperty =
originChildrenProperties.get(i);
- // DistributionSpec childDistribution =
originChildrenProperty.getDistributionSpec();
- // if (childDistribution instanceof DistributionSpecHash
- // && supportBucketShuffleTypes.contains(
- // ((DistributionSpecHash)
childDistribution).getShuffleType())
- // &&
!(isBucketShuffleDownGrade(setOperation.child(i)))) {
- // Statistics stats = setOperation.child(i).getStats();
- // double rowCount = stats.getRowCount();
- // if (rowCount > basicRowCount) {
- // basicRowCount = rowCount;
- // bucketShuffleBasicIndex = i;
- // }
- // }
- // }
- // } catch (Throwable t) {
- // // catch stats exception
- // LOG.warn("Can not find the most (bucket num, rowCount): " +
t, t);
- // bucketShuffleBasicIndex = -1;
- // }
-
- // use bucket shuffle
- // if (bucketShuffleBasicIndex >= 0) {
- // DistributionSpecHash notShuffleSideRequire
- // = (DistributionSpecHash)
requiredProperties.get(bucketShuffleBasicIndex)
- // .getDistributionSpec();
- //
- // DistributionSpecHash notNeedShuffleOutput
- // = (DistributionSpecHash)
originChildrenProperties.get(bucketShuffleBasicIndex)
- // .getDistributionSpec();
- //
- // for (int i = 0; i < originChildrenProperties.size(); i++) {
- // DistributionSpecHash current
- // = (DistributionSpecHash)
originChildrenProperties.get(i).getDistributionSpec();
- // if (i == bucketShuffleBasicIndex) {
- // continue;
- // }
- //
- // DistributionSpecHash currentRequire
- // = (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec();
- //
- // PhysicalProperties target = calAnotherSideRequired(
- // ShuffleType.STORAGE_BUCKETED,
- // notNeedShuffleOutput, current,
- // notShuffleSideRequire,
- // currentRequire);
- // updateChildEnforceAndCost(i, target);
- // }
- // setOperation.setMutableState(
- // PhysicalSetOperation.DISTRIBUTE_TO_CHILD_INDEX,
bucketShuffleBasicIndex);
- // use partitioned shuffle
- // } else {
- for (int i = 0; i < originChildrenProperties.size(); i++) {
- DistributionSpecHash current
- = (DistributionSpecHash)
originChildrenProperties.get(i).getDistributionSpec();
- if (current.getShuffleType() != ShuffleType.EXECUTION_BUCKETED
- || !bothSideShuffleKeysAreSameOrder(basic, current,
- (DistributionSpecHash)
requiredProperties.get(0).getDistributionSpec(),
- (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec())) {
- PhysicalProperties target = calAnotherSideRequired(
- ShuffleType.EXECUTION_BUCKETED, basic, current,
- (DistributionSpecHash)
requiredProperties.get(0).getDistributionSpec(),
- (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec());
+ int bucketShuffleBasicIndex = -1;
+ double basicRowCount = -1;
+
+ // find the bucket shuffle basic index: the largest natural /
storage-bucketed child
+ // keeps its bucket distribution, every other child is
bucket-shuffled to it.
+ // RequestPropertyDeriver only asks ShuffleType.REQUIRE when
set-op bucket shuffle
+ // is allowed, so the required shuffle type is the single source
of truth here:
+ // for any other required type keep bucketShuffleBasicIndex = -1
and fall back to
+ // the execution-bucketed (partitioned) shuffle below.
+ // isBucketShuffleDownGrade reuses the join-side heuristics on
purpose, including
+ // the enable_bucket_shuffle_join switch and
bucket_shuffle_downgrade_ratio: bucket
+ // shuffle for set operation belongs to the same optimization
family as bucket
+ // shuffle join, so the join switches govern both instead of
introducing a separate
+ // session variable.
+ if (basic.getShuffleType() == ShuffleType.REQUIRE) {
+ try {
+ ImmutableSet<ShuffleType> supportBucketShuffleTypes =
ImmutableSet.of(
+ ShuffleType.NATURAL,
+ ShuffleType.STORAGE_BUCKETED
+ );
+ for (int i = 0; i < originChildrenProperties.size(); i++) {
+ PhysicalProperties originChildrenProperty =
originChildrenProperties.get(i);
+ DistributionSpec childDistribution =
originChildrenProperty.getDistributionSpec();
+ // The table id is deliberately not checked here:
DistributionSpecHash.satisfy
+ // aligns the other children by shuffle type and
columns regardless of the table
+ // id, and a basic child with an unknown layout (a
hash join output with the table
+ // id cleared to -1 by
withShuffleTypeAndForbidColocateJoin) produces a
+ // STORAGE_BUCKETED output, which couldColocateJoin
never co-locates (it requires
+ // NATURAL on both sides) so it cannot mislead a
parent into a wrong co-location.
+ if (childDistribution instanceof DistributionSpecHash
+ && supportBucketShuffleTypes.contains(
+ ((DistributionSpecHash)
childDistribution).getShuffleType())
+ &&
canMapBucketKeysToRequire((DistributionSpecHash) childDistribution,
+ (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec())
+ &&
!(isBucketShuffleDownGrade(setOperation.child(i)))) {
+ Statistics stats =
setOperation.child(i).getStats();
+ double rowCount = stats.getRowCount();
+ if (rowCount > basicRowCount) {
+ basicRowCount = rowCount;
+ bucketShuffleBasicIndex = i;
+ }
+ }
+ }
+ } catch (Throwable t) {
+ // catch stats exception
+ LOG.warn("Can not find the most (bucket num, rowCount): "
+ t, t);
+ bucketShuffleBasicIndex = -1;
+ }
+ }
+
+ if (bucketShuffleBasicIndex >= 0) {
+ // use bucket shuffle
+ DistributionSpecHash notShuffleSideRequire
+ = (DistributionSpecHash)
requiredProperties.get(bucketShuffleBasicIndex)
+ .getDistributionSpec();
+
+ DistributionSpecHash notNeedShuffleOutput
+ = (DistributionSpecHash)
originChildrenProperties.get(bucketShuffleBasicIndex)
+ .getDistributionSpec();
+
+ for (int i = 0; i < originChildrenProperties.size(); i++) {
+ if (i == bucketShuffleBasicIndex) {
+ continue;
+ }
+
+ DistributionSpecHash currentRequire
+ = (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec();
+
+ // The enforced child is bucket-shuffled to the basic
child's buckets by the
+ // storage hash on shuffleSideIds. Only the shuffle type
and the column order
+ // carry the alignment: DistributionSpecHash.satisfy
compares shuffle type and
+ // columns (the equivalence set), never the storage
layout, and the set operation
+ // output is STORAGE_BUCKETED which couldColocateJoin
never co-locates, so the
+ // basic child's table / index / partition ids are inert
here.
+ List<ExprId> shuffleSideIds =
calAnotherSideRequiredShuffleIds(
+ notNeedShuffleOutput, notShuffleSideRequire,
currentRequire);
+ PhysicalProperties target = new PhysicalProperties(
+ new DistributionSpecHash(shuffleSideIds,
ShuffleType.STORAGE_BUCKETED));
updateChildEnforceAndCost(i, target);
}
+ } else {
+ // use partitioned shuffle
+ for (int i = 0; i < originChildrenProperties.size(); i++) {
+ DistributionSpecHash current
+ = (DistributionSpecHash)
originChildrenProperties.get(i).getDistributionSpec();
+ if (current.getShuffleType() !=
ShuffleType.EXECUTION_BUCKETED
+ || !bothSideShuffleKeysAreSameOrder(basic, current,
+ (DistributionSpecHash)
requiredProperties.get(0).getDistributionSpec(),
+ (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec())) {
+ PhysicalProperties target = calAnotherSideRequired(
+ ShuffleType.EXECUTION_BUCKETED, basic, current,
+ (DistributionSpecHash)
requiredProperties.get(0).getDistributionSpec(),
+ (DistributionSpecHash)
requiredProperties.get(i).getDistributionSpec());
+ updateChildEnforceAndCost(i, target);
+ }
+ }
}
- // }
}
return ImmutableList.of(originChildrenProperties);
}
@@ -801,6 +822,32 @@ public class ChildrenPropertiesRegulator extends
PlanVisitor<List<List<PhysicalP
}
}
+ /**
+ * Whether every bucket key of the candidate basic child can be mapped
into the child's
+ * required hash columns (directly or through its equivalence sets). When
the candidate's
+ * bucket key is wider than the set operation output (e.g. a table
bucketed by (k, v)
+ * feeding INTERSECT on k only), the mapping is impossible and choosing it
as the basic
+ * child would fail calAnotherSideRequiredShuffleIds, so the caller falls
back to the
+ * execution-bucketed shuffle instead.
+ */
+ private boolean canMapBucketKeysToRequire(DistributionSpecHash
childOutput, DistributionSpecHash childRequired) {
+ for (ExprId scanId : childOutput.getOrderedShuffledColumns()) {
+ int index =
childRequired.getOrderedShuffledColumns().indexOf(scanId);
+ if (index == -1) {
+ for (ExprId alternativeExpr :
childOutput.getEquivalenceExprIdsOf(scanId)) {
+ index =
childRequired.getOrderedShuffledColumns().indexOf(alternativeExpr);
+ if (index != -1) {
+ break;
+ }
+ }
+ }
+ if (index == -1) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* calculate the shuffle side hash key right orders.
* For example,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
index 70f2b51665b..6ea2601bbee 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java
@@ -67,6 +67,7 @@ import
org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
import org.apache.doris.nereids.util.AggregateUtils;
import org.apache.doris.nereids.util.JoinUtils;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
import org.apache.doris.statistics.Statistics;
import com.google.common.base.Preconditions;
@@ -333,20 +334,18 @@ public class RequestPropertyDeriver extends
PlanVisitor<Void, PlanContext> {
if (distributionRequestFromParent instanceof DistributionSpecHash) {
// shuffle according to parent require
DistributionSpecHash distributionSpecHash = (DistributionSpecHash)
distributionRequestFromParent;
- addRequestPropertyToChildren(createHashRequestAccordingToParent(
- setOperation, distributionSpecHash, context));
+
addRequestPropertyToChildren(downgradeRequireWhenBucketShuffleNotAllowed(
+ createHashRequestAccordingToParent(setOperation,
distributionSpecHash, context)));
} else {
// shuffle all column
// TODO: for wide table, may be we should add a upper limit of
shuffle columns
-
- // TODO: open comment when support `enable_local_shuffle_planner`
and change to REQUIRE
- // intersect/except always need hash distribution, we use REQUIRE
to auto select
- // bucket shuffle or execution shuffle
+ ShuffleType setOperationShuffleType =
setOperationBucketShuffleAllowed()
+ ? ShuffleType.REQUIRE : ShuffleType.EXECUTION_BUCKETED;
addRequestPropertyToChildren(setOperation.getRegularChildrenOutputs().stream()
.map(childOutputs -> childOutputs.stream()
.map(SlotReference::getExprId)
.collect(ImmutableList.toImmutableList()))
- .map(l -> PhysicalProperties.createHash(l,
ShuffleType.EXECUTION_BUCKETED))
+ .map(l -> PhysicalProperties.createHash(l,
setOperationShuffleType))
.collect(Collectors.toList()));
}
return null;
@@ -366,9 +365,8 @@ public class RequestPropertyDeriver extends
PlanVisitor<Void, PlanContext> {
DistributionSpec distributionRequestFromParent =
requestPropertyFromParent.getDistributionSpec();
if (distributionRequestFromParent instanceof DistributionSpecHash)
{
DistributionSpecHash distributionSpecHash =
(DistributionSpecHash) distributionRequestFromParent;
- List<PhysicalProperties> requestHash
- = createHashRequestAccordingToParent(union,
distributionSpecHash, context);
- addRequestPropertyToChildren(requestHash);
+
addRequestPropertyToChildren(downgradeRequireWhenBucketShuffleNotAllowed(
+ createHashRequestAccordingToParent(union,
distributionSpecHash, context)));
}
}
@@ -581,6 +579,43 @@ public class RequestPropertyDeriver extends
PlanVisitor<Void, PlanContext> {
return combinedNdv > AggregateUtils.LOW_NDV_THRESHOLD;
}
+ /**
+ * A ShuffleType.REQUIRE request lets ChildrenPropertiesRegulator choose
the bucket
+ * shuffle alternative for the set operation. That needs either no local
shuffle at all
+ * (every pipeline runs a single task per instance, so the bucket
alignment holds
+ * naturally) or the FE local-shuffle planner (which plans the correct
bucket-hash
+ * local exchanges): with the BE-planned local shuffle the backend cannot
infer the
+ * correct local shuffle type for the set sink/probe and computes wrong
results.
+ * It also requires the nereids distribute planner: the legacy coordinator
only
+ * supports bucket-shuffle-partitioned sinks whose dest fragment contains
a bucket
+ * shuffle join.
+ */
+ private boolean setOperationBucketShuffleAllowed() {
+ return connectContext != null
+ &&
SessionVariable.canUseNereidsDistributePlanner(connectContext)
+ && (!connectContext.getSessionVariable().isEnableLocalShuffle()
+ ||
connectContext.getSessionVariable().isEnableLocalShufflePlanner());
+ }
+
+ /**
+ * The parent may pass ShuffleType.REQUIRE down through
+ * {@link #createHashRequestAccordingToParent}; downgrade it to
EXECUTION_BUCKETED so the
+ * regulator does not pick the bucket shuffle alternative when it is not
allowed.
+ */
+ private List<PhysicalProperties>
downgradeRequireWhenBucketShuffleNotAllowed(
+ List<PhysicalProperties> requests) {
+ if (setOperationBucketShuffleAllowed()) {
+ return requests;
+ }
+ return requests.stream().map(request -> {
+ DistributionSpecHash requestHash = (DistributionSpecHash)
request.getDistributionSpec();
+ return requestHash.getShuffleType() == ShuffleType.REQUIRE
+ ? PhysicalProperties.createHash(
+ requestHash.getOrderedShuffledColumns(),
ShuffleType.EXECUTION_BUCKETED)
+ : request;
+ }).collect(Collectors.toList());
+ }
+
private List<PhysicalProperties> createHashRequestAccordingToParent(
SetOperation setOperation, DistributionSpecHash
distributionRequestFromParent, PlanContext context) {
List<PhysicalProperties> requiredPropertyList =
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java
index 390b66076d4..d4d65dfd225 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java
@@ -24,7 +24,6 @@ import org.apache.doris.catalog.Replica;
import org.apache.doris.catalog.Tablet;
import org.apache.doris.common.Pair;
import org.apache.doris.nereids.StatementContext;
-import org.apache.doris.nereids.trees.plans.algebra.Intersect;
import org.apache.doris.nereids.trees.plans.distribute.DistributeContext;
import
org.apache.doris.nereids.trees.plans.distribute.worker.DistributedPlanWorker;
import
org.apache.doris.nereids.trees.plans.distribute.worker.DistributedPlanWorkerManager;
@@ -32,12 +31,14 @@ import
org.apache.doris.nereids.trees.plans.distribute.worker.ScanWorkerSelector
import org.apache.doris.nereids.util.Utils;
import org.apache.doris.planner.ExchangeNode;
import org.apache.doris.planner.HashJoinNode;
+import org.apache.doris.planner.IntersectNode;
import org.apache.doris.planner.OlapScanNode;
import org.apache.doris.planner.PlanFragment;
import org.apache.doris.planner.ScanNode;
import org.apache.doris.planner.SetOperationNode;
import org.apache.doris.qe.ConnectContext;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
@@ -352,7 +353,8 @@ public class UnassignedScanBucketOlapTableJob extends
AbstractUnassignedScanJob
}
}
- private boolean shouldFillUpInstances(List<HashJoinNode> hashJoinNodes,
List<SetOperationNode> setOperationNodes) {
+ @VisibleForTesting
+ static boolean shouldFillUpInstances(List<HashJoinNode> hashJoinNodes,
List<SetOperationNode> setOperationNodes) {
for (HashJoinNode hashJoinNode : hashJoinNodes) {
if (!hashJoinNode.isBucketShuffle()) {
continue;
@@ -368,7 +370,11 @@ public class UnassignedScanBucketOlapTableJob extends
AbstractUnassignedScanJob
}
for (SetOperationNode setOperationNode : setOperationNodes) {
- if (setOperationNode instanceof Intersect) {
+ // INTERSECT does not need missing-bucket receiver fill-up: a
bucket the basic child
+ // does not scan is empty in the anchor, so the intersect result
for that bucket is
+ // empty and the other children's rows shuffled there produce
nothing. Note the node
+ // here is the legacy planner IntersectNode, not the Nereids
algebra Intersect.
+ if (setOperationNode instanceof IntersectNode) {
continue;
}
if (setOperationNode.isBucketShuffle()) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalSetOperation.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalSetOperation.java
index a0af54c7ed0..1f5c843e292 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalSetOperation.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalSetOperation.java
@@ -44,7 +44,6 @@ import java.util.Optional;
* Physical SetOperation.
*/
public abstract class PhysicalSetOperation extends AbstractPhysicalPlan
implements SetOperation {
- public static final String DISTRIBUTE_TO_CHILD_INDEX =
"DistributeToChildIndex";
protected final Qualifier qualifier;
protected final List<NamedExpression> outputs;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java
b/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java
index f939102d699..66eda400799 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java
@@ -283,8 +283,15 @@ public class LocalExchangeNode extends PlanNode {
@Override
public LocalExchangeTypeRequire autoRequireHash() {
- if (requireType == LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE
- || requireType == LocalExchangeType.BUCKET_HASH_SHUFFLE) {
+ // Callers are pass-through operators (union / streaming agg /
sort) that report
+ // resolveExchangeType(requireChild) upward while leaving row
placement to their
+ // children. A specific hash require must therefore be forwarded
as-is: degrading
+ // LOCAL_EXECUTION_HASH_SHUFFLE to the generic RequireHash lets a
bucket-distributed
+ // child satisfy the requirement and keep its bucket placement,
while the operator
+ // still claims LOCAL_EXECUTION_HASH_SHUFFLE to its parent — the
parent (e.g. a
+ // bucket join upgraded to local hash) then skips its re-align
local exchange and
+ // the mixed placements compute wrong results.
+ if (requireType.isHashShuffle()) {
return this;
}
return RequireHash.INSTANCE;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java
b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java
index 4074ab3e85a..1396701d3c7 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java
@@ -223,7 +223,15 @@ public abstract class SetOperationNode extends PlanNode {
: LocalExchangeType.NOOP;
} else {
// Intersect / Except
- if (AddLocalExchange.isColocated(this)) {
+ if (AddLocalExchange.isColocated(this) || isBucketShuffle()) {
+ // COLOCATE / BUCKET_SHUFFLE: every child is distributed by
the basic child's
+ // storage bucket function (basic side scans buckets directly,
other sides come
+ // from bucket-shuffle exchanges), so all children must stay
aligned by that
+ // bucket function locally. requireBucketHash keeps
bucket-distributed children
+ // as-is and re-aligns a serial (NOOP-claim) child with a
BUCKET_HASH_SHUFFLE
+ // local exchange — same pattern as HashJoinNode's
colocate/bucket-shuffle
+ // branch. An execution-hash require here would locally
re-partition one side
+ // by a different hash function and break build/probe
alignment.
requireChild = LocalExchangeTypeRequire.requireBucketHash();
outputType = LocalExchangeType.BUCKET_HASH_SHUFFLE;
} else {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java
index db7f55fb2fd..e5020e2b8a6 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java
@@ -20,9 +20,14 @@ package
org.apache.doris.nereids.trees.plans.distribute.worker.job;
import org.apache.doris.nereids.StatementContext;
import
org.apache.doris.nereids.trees.plans.distribute.worker.ScanWorkerSelector;
import org.apache.doris.planner.DataPartition;
+import org.apache.doris.planner.ExceptNode;
import org.apache.doris.planner.ExchangeNode;
+import org.apache.doris.planner.HashJoinNode;
+import org.apache.doris.planner.IntersectNode;
import org.apache.doris.planner.OlapScanNode;
import org.apache.doris.planner.PlanFragment;
+import org.apache.doris.planner.SetOperationNode;
+import org.apache.doris.planner.UnionNode;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.OriginStatement;
import org.apache.doris.thrift.TUniqueId;
@@ -34,6 +39,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.BitSet;
+import java.util.List;
public class UnassignedScanBucketOlapTableJobTest {
@@ -114,4 +120,38 @@ public class UnassignedScanBucketOlapTableJobTest {
int result = unassignedJob.degreeOfParallelism(3, false);
Assertions.assertEquals(100, result);
}
+
+ @Test
+ public void testShouldFillUpInstancesSkipsBucketShuffleIntersectOnly() {
+ List<HashJoinNode> noJoins = ImmutableList.of();
+
+ // A bucket-shuffle INTERSECT must NOT trigger missing-bucket receiver
fill-up: a bucket the
+ // basic child does not scan is empty in the anchor, so the intersect
result for that bucket
+ // is empty and the other children's rows shuffled there produce
nothing. The skip must match
+ // the legacy planner IntersectNode; the translated node is never an
instance of the Nereids
+ // algebra Intersect, so checking that type would silently never skip.
+ IntersectNode bucketShuffleIntersect =
Mockito.mock(IntersectNode.class);
+
Mockito.when(bucketShuffleIntersect.isBucketShuffle()).thenReturn(true);
+
Assertions.assertFalse(UnassignedScanBucketOlapTableJob.shouldFillUpInstances(
+ noJoins,
ImmutableList.<SetOperationNode>of(bucketShuffleIntersect)));
+
+ // Bucket-shuffle UNION / EXCEPT still need fill-up: the other
children's rows shuffled into
+ // buckets the basic child does not scan must be received to be
produced (union) or to
+ // subtract against (except) correctly.
+ UnionNode bucketShuffleUnion = Mockito.mock(UnionNode.class);
+ Mockito.when(bucketShuffleUnion.isBucketShuffle()).thenReturn(true);
+
Assertions.assertTrue(UnassignedScanBucketOlapTableJob.shouldFillUpInstances(
+ noJoins,
ImmutableList.<SetOperationNode>of(bucketShuffleUnion)));
+
+ ExceptNode bucketShuffleExcept = Mockito.mock(ExceptNode.class);
+ Mockito.when(bucketShuffleExcept.isBucketShuffle()).thenReturn(true);
+
Assertions.assertTrue(UnassignedScanBucketOlapTableJob.shouldFillUpInstances(
+ noJoins,
ImmutableList.<SetOperationNode>of(bucketShuffleExcept)));
+
+ // A union that did not choose bucket shuffle does not trigger fill-up.
+ UnionNode plainUnion = Mockito.mock(UnionNode.class);
+ Mockito.when(plainUnion.isBucketShuffle()).thenReturn(false);
+
Assertions.assertFalse(UnassignedScanBucketOlapTableJob.shouldFillUpInstances(
+ noJoins, ImmutableList.<SetOperationNode>of(plainUnion)));
+ }
}
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 5ffe61ce8f5..e171ebe166c 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
@@ -53,6 +53,33 @@ import java.util.concurrent.atomic.AtomicInteger;
public class LocalShuffleNodeCoverageTest {
private static final AtomicInteger NEXT_ID = new AtomicInteger(1);
+ @Test
+ public void testRequireSpecificAutoRequireHashPreservesSpecificHash() {
+ // Pass-through operators (union / streaming agg / sort) forward their
parent's specific
+ // hash requirement downward via autoRequireHash() while leaving row
placement to their
+ // children. Every hash flavour must be forwarded unchanged: degrading
a specific
+ // LOCAL_EXECUTION_HASH_SHUFFLE requirement to the generic RequireHash
would let a
+ // bucket-distributed child satisfy it and keep its bucket placement
while the operator
+ // still advertised LOCAL_EXECUTION_HASH_SHUFFLE upward, so a bucket
join upgraded to
+ // local hash above it would skip its realign local exchange and
compute wrong results.
+ for (LocalExchangeType hashType : new LocalExchangeType[] {
+ LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE,
+ LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE,
+ LocalExchangeType.BUCKET_HASH_SHUFFLE}) {
+ LocalExchangeNode.RequireSpecific require = new
LocalExchangeNode.RequireSpecific(hashType);
+ LocalExchangeTypeRequire forwarded = require.autoRequireHash();
+ Assertions.assertSame(require, forwarded,
+ "specific hash require " + hashType + " must be forwarded
unchanged");
+ Assertions.assertEquals(hashType, forwarded.preferType());
+ }
+
+ // A non-hash specific require still relaxes to the generic
RequireHash, whose preferType
+ // is GLOBAL_EXECUTION_HASH_SHUFFLE.
+ LocalExchangeTypeRequire relaxed =
+ new
LocalExchangeNode.RequireSpecific(LocalExchangeType.PASSTHROUGH).autoRequireHash();
+
Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE,
relaxed.preferType());
+ }
+
@Test
public void testSelectNode() {
PlanTranslatorContext ctx = new PlanTranslatorContext();
@@ -613,6 +640,28 @@ public class LocalShuffleNodeCoverageTest {
Assertions.assertSame(exceptLeft, exceptNode.getChild(0));
Assertions.assertSame(exceptRight, exceptNode.getChild(1));
+ // Bucket-shuffle IntersectNode (not colocated): exercises the `||
isBucketShuffle()` leg.
+ // Every child is distributed by the basic child's storage bucket
function (via bucket-shuffle
+ // exchanges), so the output is BUCKET_HASH_SHUFFLE and each serial
(NOOP) child is re-aligned
+ // with a BUCKET_HASH_SHUFFLE local exchange. Without the
isBucketShuffle() branch this would
+ // fall into the partitioned (GLOBAL hash) leg and re-partition one
side by a different hash
+ // function, breaking alignment. setColocate(false) keeps
isColocated() false (SetOperationNode
+ // returns false immediately when isColocate() is false), so the
branch is reached through
+ // isBucketShuffle() alone.
+ IntersectNode bucketIntersect = new IntersectNode(nextPlanNodeId(),
+ new TupleId(NEXT_ID.getAndIncrement()));
+ bucketIntersect.setColocate(false);
+ bucketIntersect.setDistributionMode(DistributionMode.BUCKET_SHUFFLE);
+ TrackingPlanNode bucketLeft = new TrackingPlanNode(nextPlanNodeId(),
LocalExchangeType.NOOP);
+ TrackingPlanNode bucketRight = new TrackingPlanNode(nextPlanNodeId(),
LocalExchangeType.NOOP);
+ bucketIntersect.addChild(bucketLeft);
+ bucketIntersect.addChild(bucketRight);
+ Pair<PlanNode, LocalExchangeType> bucketIntersectOutput =
bucketIntersect.enforceAndDeriveLocalExchange(
+ ctx, null, LocalExchangeTypeRequire.requireHash());
+ Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE,
bucketIntersectOutput.second);
+ assertChildLocalExchangeType(bucketIntersect, 0,
LocalExchangeType.BUCKET_HASH_SHUFFLE);
+ assertChildLocalExchangeType(bucketIntersect, 1,
LocalExchangeType.BUCKET_HASH_SHUFFLE);
+
TrackingPlanNode assertChild = new TrackingPlanNode(nextPlanNodeId(),
LocalExchangeType.NOOP);
AssertNumRowsElement assertElement =
Mockito.mock(AssertNumRowsElement.class);
Mockito.when(assertElement.getDesiredNumOfRows()).thenReturn(1L);
diff --git
a/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out
b/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out
index 50a3c5cc131..1ec75925ec2 100644
---
a/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out
+++
b/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out
@@ -117,3 +117,188 @@ PhysicalResultSink
2
3
+-- !bucket_shuffle_join_as_basic_child_shape --
+PhysicalResultSink
+--PhysicalIntersect[bucketShuffle]
+----PhysicalProject
+------hashJoin[INNER_JOIN broadcast] hashCondition=((a.id = b.id))
otherCondition=()
+--------PhysicalProject
+----------PhysicalOlapScan[bucket_shuffle_set_operation1(a)]
+--------PhysicalProject
+----------PhysicalOlapScan[bucket_shuffle_set_operation2(b)]
+----PhysicalDistribute[DistributionSpecHash]
+------PhysicalProject
+--------PhysicalOlapScan[bucket_shuffle_set_operation3]
+
+-- !bucket_shuffle_join_as_basic_child_result --
+1
+2
+3
+
+-- !bucket_shuffle_nested_set_operation_shape --
+PhysicalResultSink
+--PhysicalUnion
+----PhysicalDistribute[DistributionSpecExecutionAny]
+------PhysicalProject
+--------PhysicalOlapScan[bucket_shuffle_set_operation3]
+----PhysicalDistribute[DistributionSpecExecutionAny]
+------PhysicalIntersect[bucketShuffle]
+--------PhysicalProject
+----------hashJoin[INNER_JOIN broadcast] hashCondition=((a.id = b.id))
otherCondition=()
+------------PhysicalProject
+--------------PhysicalOlapScan[bucket_shuffle_set_operation1(a)]
+------------PhysicalProject
+--------------PhysicalOlapScan[bucket_shuffle_set_operation2(b)]
+--------PhysicalDistribute[DistributionSpecHash]
+----------PhysicalProject
+------------PhysicalOlapScan[bucket_shuffle_set_operation2]
+
+-- !bucket_shuffle_nested_set_operation_result --
+1
+1
+2
+2
+3
+3
+
+-- !bucket_shuffle_when_local_shuffle_off_shape --
+PhysicalResultSink
+--PhysicalIntersect[bucketShuffle]
+----PhysicalProject
+------PhysicalOlapScan[bucket_shuffle_set_operation1]
+----PhysicalDistribute[DistributionSpecHash]
+------PhysicalProject
+--------PhysicalOlapScan[bucket_shuffle_set_operation2]
+
+-- !bucket_shuffle_when_local_shuffle_off_result --
+1
+2
+3
+
+-- !union_parent_hash_shape_when_local_shuffle_planner_off --
+PhysicalResultSink
+--PhysicalProject
+----hashJoin[INNER_JOIN bucketShuffle] hashCondition=((u.id = b.id))
otherCondition=()
+------PhysicalUnion
+--------PhysicalDistribute[DistributionSpecHash]
+----------PhysicalProject
+------------PhysicalOlapScan[bucket_shuffle_set_operation1]
+--------PhysicalDistribute[DistributionSpecHash]
+----------PhysicalProject
+------------PhysicalOlapScan[bucket_shuffle_set_operation2]
+------PhysicalProject
+--------PhysicalOlapScan[bucket_shuffle_set_operation3(b)]
+
+Hint log:
+Used: [shuffle]_1
+UnUsed:
+SyntaxError:
+
+-- !union_parent_hash_when_local_shuffle_planner_off --
+1
+1
+2
+2
+3
+3
+
+-- !plain_intersect_when_local_shuffle_planner_off --
+1
+2
+3
+
+-- !plain_intersect_when_nereids_distribute_planner_off --
+1
+2
+3
+
+-- !union_parent_hash_when_nereids_distribute_planner_off --
+1
+1
+2
+2
+3
+3
+
+-- !intersect_right_basic_parent_hash --
+1
+2
+3
+
+-- !bucket_shuffle_union_fill_up --
+0
+0
+1
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+2
+2
+3
+4
+4
+5
+6
+6
+7
+8
+9
+
+-- !bucket_shuffle_equivalent_key_fill_up --
+0
+1
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+2
+2
+3
+4
+5
+6
+7
+8
+9
+
+-- !bucket_shuffle_two_equivalent_keys --
+0 0
+1 1
+10 10
+11 11
+12 12
+13 13
+14 14
+15 15
+16 16
+17 17
+18 18
+19 19
+2 2
+2 2
+3 3
+4 4
+5 5
+6 6
+7 7
+8 8
+9 9
+
+-- !multi_column_bucket_key_intersect --
+1
+2
+3
+
diff --git
a/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy
b/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy
index 5533853eaa9..ee118bccbd5 100644
---
a/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy
+++
b/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy
@@ -16,9 +16,6 @@
// under the License.
suite("bucket_shuffle_set_operation") {
- // TODO: open comment when support `enable_local_shuffle_planner` and
change to REQUIRE
- return
-
multi_sql """
drop table if exists bucket_shuffle_set_operation1;
create table bucket_shuffle_set_operation1(id int, value int)
distributed by hash(id) buckets 10 properties('replication_num'='1');
@@ -37,6 +34,9 @@ suite("bucket_shuffle_set_operation") {
// make bucket shuffle set operation stable
sql "set parallel_pipeline_task_num=5"
+ // disable the bucket shuffle downgrade so the chosen shapes do not depend
on the
+ // backend count / parallelism of the environment running this suite
+ sql "set bucket_shuffle_downgrade_ratio=0"
def checkShapeAndResult = { String tag, String sqlStr ->
quickTest(tag + "_shape", "explain shape plan " + sqlStr)
@@ -95,6 +95,315 @@ suite("bucket_shuffle_set_operation") {
select id from bucket_shuffle_set_operation2 where id=1
""")
+ // The basic child of a bucket-shuffle set operation can be a join output
instead of a
+ // direct scan. In that shape the local exchange planned for the basic
side must still
+ // partition by the storage bucket function: an execution-hash local
exchange would not
+ // align with the bucket-distributed side and the set operation would
compute wrong results.
+ checkShapeAndResult("bucket_shuffle_join_as_basic_child", """
+ select a.id from bucket_shuffle_set_operation1 a
+ join bucket_shuffle_set_operation2 b on a.id = b.id
+ intersect
+ select id from bucket_shuffle_set_operation3""")
+
+ // a set operation child can itself be a set operation whose output claims
a bucket
+ // distribution; the outer set operation must only treat its children as
bucket-aligned
+ // when they share the same storage layout
+ checkShapeAndResult("bucket_shuffle_nested_set_operation", """
+ select id from bucket_shuffle_set_operation3
+ union all
+ (select a.id from bucket_shuffle_set_operation1 a
+ join bucket_shuffle_set_operation2 b on a.id = b.id
+ intersect
+ select id from bucket_shuffle_set_operation2)""")
+
+ // when local shuffle is disabled entirely, every pipeline runs a single
task per
+ // instance so the bucket alignment holds naturally and bucket shuffle is
still allowed
+ sql "set enable_local_shuffle=false"
+ checkShapeAndResult("bucket_shuffle_when_local_shuffle_off", """
+ select id from bucket_shuffle_set_operation1
+ intersect
+ select id from bucket_shuffle_set_operation2""")
+ sql "set enable_local_shuffle=true"
+
+ // A shuffle join above the union pushes a hash request into the union
+ // (createHashRequestAccordingToParent, the parent-hash request path).
When the FE does not
+ // plan the local shuffle, that request must be downgraded so the union
does not choose
+ // bucket shuffle, while the result stays correct.
+ def unionParentHashSql = """
+ select b.id from (
+ select id from bucket_shuffle_set_operation1
+ union all
+ select id from bucket_shuffle_set_operation2
+ ) u join[shuffle] bucket_shuffle_set_operation3 b on u.id = b.id
+ """
+ sql "set enable_local_shuffle_planner=false"
+ // Golden shape: the union must stay a plain PhysicalUnion whose two
children are each a
+ // PhysicalDistribute[DistributionSpecHash]. That is the actual proof that
the parent hash
+ // request was pushed down into the union
(createHashRequestAccordingToParent) and downgraded
+ // to execution hash, not merely that the PhysicalUnion line lacks the
[bucketShuffle] tag.
+ // If that path regressed, the optimizer could instead keep an unbucketed
union and add a
+ // single PhysicalDistribute[DistributionSpecHash] above it for the
shuffle join; the golden
+ // shape below (union children are distributes, not direct scans) would
catch that.
+ qt_union_parent_hash_shape_when_local_shuffle_planner_off("explain shape
plan " + unionParentHashSql)
+ explain {
+ sql "shape plan " + unionParentHashSql
+ check { String e ->
+ def unionIndex = e.indexOf("PhysicalUnion")
+ assertTrue(unionIndex >= 0)
+ // the union must not be a bucket shuffle union when the FE local
shuffle planner is off
+ assertFalse(e.substring(unionIndex,
+ Math.min(unionIndex + "PhysicalUnion".length() + 20,
e.length())).contains("bucketShuffle"))
+ // and the parent hash request must have been pushed into the
union: each union child
+ // arrives through a PhysicalDistribute[DistributionSpecHash], so
no union child is a
+ // direct scan.
+ def afterUnion = e.substring(unionIndex)
+ def joinProbeIndex =
afterUnion.indexOf("bucket_shuffle_set_operation3")
+ def unionSubtree = joinProbeIndex >= 0 ? afterUnion.substring(0,
joinProbeIndex) : afterUnion
+ assertEquals(2,
unionSubtree.split("PhysicalDistribute\\[DistributionSpecHash\\]", -1).length -
1)
+ }
+ }
+ order_qt_union_parent_hash_when_local_shuffle_planner_off
unionParentHashSql
+ sql "set enable_local_shuffle_planner=true"
+
+ // A plain intersect/except without a parent hash request goes through
+ // visitPhysicalSetOperation directly (not the parent-hash request path);
with the FE local
+ // shuffle planner disabled it must not choose bucket shuffle either, and
the result stays
+ // correct.
+ sql "set enable_local_shuffle_planner=false"
+ explain {
+ sql "shape plan select id from bucket_shuffle_set_operation1 intersect
select id from bucket_shuffle_set_operation2"
+ check { String e ->
+ assertFalse(e.contains("bucketShuffle"))
+ }
+ }
+ order_qt_plain_intersect_when_local_shuffle_planner_off "select id from
bucket_shuffle_set_operation1 intersect select id from
bucket_shuffle_set_operation2"
+ sql "set enable_local_shuffle_planner=true"
+
+ // Set operation bucket shuffle is gated on
setOperationBucketShuffleAllowed() =
+ // enableLocalShufflePlanner && canUseNereidsDistributePlanner. The
enable_local_shuffle_planner
+ // =false cases above only exercise the first conjunct; this block guards
the second one. With
+ // local shuffle and the FE local-shuffle planner both enabled but the
Nereids distribute planner
+ // disabled, the set operation must still downgrade (no [bucketShuffle])
and keep correct results.
+ sql "set enable_local_shuffle=true"
+ sql "set enable_local_shuffle_planner=true"
+ sql "set enable_nereids_distribute_planner=false"
+ // plain intersect goes through visitPhysicalSetOperation directly
+ explain {
+ sql "shape plan select id from bucket_shuffle_set_operation1 intersect
select id from bucket_shuffle_set_operation2"
+ check { String e ->
+ assertFalse(e.contains("bucketShuffle"))
+ }
+ }
+ order_qt_plain_intersect_when_nereids_distribute_planner_off "select id
from bucket_shuffle_set_operation1 intersect select id from
bucket_shuffle_set_operation2"
+ // the parent-hash union path (createHashRequestAccordingToParent) must
downgrade too
+ explain {
+ sql "shape plan " + unionParentHashSql
+ check { String e ->
+ def unionIndex = e.indexOf("PhysicalUnion")
+ assertTrue(unionIndex >= 0)
+ assertFalse(e.substring(unionIndex,
+ Math.min(unionIndex + "PhysicalUnion".length() + 20,
e.length())).contains("bucketShuffle"))
+ }
+ }
+ order_qt_union_parent_hash_when_nereids_distribute_planner_off
unionParentHashSql
+ sql "set enable_nereids_distribute_planner=true"
+
+ // The right child can be selected as the bucket-shuffle basic child
(larger row count). The
+ // set operation output must then advertise a non-specific distribution
rather than a plain
+ // execution hash: otherwise two such set operations with different
storage layouts are
+ // co-located under a join and fail bucket assignment ("Can not find
tablet ... in the
+ // bucket"). r1 / r2 are larger than the small tables so they become the
basic child on the
+ // right, and they are different tables so their bucket layouts differ.
+ sql "drop table if exists bucket_shuffle_set_operation_r1"
+ sql "create table bucket_shuffle_set_operation_r1(id int) distributed by
hash(id) buckets 10 properties('replication_num'='1')"
+ sql "insert into bucket_shuffle_set_operation_r1 select number from
numbers('number'='20')"
+ sql "drop table if exists bucket_shuffle_set_operation_r2"
+ sql "create table bucket_shuffle_set_operation_r2(id int) distributed by
hash(id) buckets 10 properties('replication_num'='1')"
+ sql "insert into bucket_shuffle_set_operation_r2 select number from
numbers('number'='20')"
+ sql "alter table bucket_shuffle_set_operation_r1 modify column id set
stats ('row_count'='1000', 'ndv'='1000', 'min_value'='0', 'max_value'='19')"
+ sql "alter table bucket_shuffle_set_operation_r2 modify column id set
stats ('row_count'='1000', 'ndv'='1000', 'min_value'='0', 'max_value'='19')"
+ def intersectRightBasicSql = """
+ select t.id from
+ (select id from bucket_shuffle_set_operation1 intersect select id
from bucket_shuffle_set_operation_r1) t
+ join[shuffle]
+ (select id from bucket_shuffle_set_operation1 intersect select id
from bucket_shuffle_set_operation_r2) s
+ on t.id = s.id"""
+ // Prove the shuffleToRight branch (distributeToChildIndex > 0) is
actually covered, under a
+ // parent hash consumer. The join[shuffle] hint forces the parent to be a
hash consumer of the
+ // two set-operation outputs. Each INTERSECT must keep the right table (r1
/ r2, the larger row
+ // count) as the direct bucketed basic child while the left op1 is the
enforced
+ // PhysicalDistribute[DistributionSpecHash] side. Because
visitPhysicalSetOperation keeps the
+ // right basic child's specific storage layout, the two intersects carry
different table ids
+ // (r1 vs r2), so the parent re-aligns them with a bucket-shuffle join
instead of co-locating
+ // them (which, if the layout were erased to a layout-less execution hash,
would fail bucket
+ // assignment with "Can not find tablet"). If the right side stopped being
selected as basic, or
+ // the layout were dropped so the join co-located, these assertions fail
even though the result
+ // stays 1, 2, 3.
+ explain {
+ sql "shape plan " + intersectRightBasicSql
+ check { String e ->
+ assertTrue(e.contains("PhysicalIntersect[bucketShuffle]"))
+ // the parent join re-aligns the two different-layout outputs
(bucket-shuffle hash
+ // consumer) and does not co-locate them
+ assertTrue(e.contains("hashJoin[INNER_JOIN bucketShuffle]"),
+ "parent must re-align the two different-layout outputs
with a bucket-shuffle join")
+ assertFalse(e.contains("colocated"), "parent must not co-locate
the two right-basic intersects")
+ def lines = e.split("\n").findAll { it =~ /Physical|hashJoin/ }
+ def depth = { String s -> (s =~ /^-*/)[0].length() }
+ def parentOf = { int idx ->
+ for (int k = idx - 1; k >= 0; k--) {
+ if (depth(lines[k]) < depth(lines[idx])) {
+ return lines[k]
+ }
+ }
+ return ""
+ }
+ // each INTERSECT keeps r1 / r2 (the larger, right side) as its
direct bucketed basic child
+ ["bucket_shuffle_set_operation_r1",
"bucket_shuffle_set_operation_r2"].each { rt ->
+ int i = lines.findIndexOf { it.contains("PhysicalOlapScan[" +
rt + "]") }
+ assertTrue(i >= 0, rt + " scan not found in shape")
+
assertTrue(parentOf(i).contains("PhysicalIntersect[bucketShuffle]"),
+ rt + " must be the direct bucketed basic child of the
intersect (right basic)")
+ }
+ // and each INTERSECT's other (left) child arrives through an
enforced
+ // PhysicalDistribute[DistributionSpecHash] directly under the
intersect
+ int enforcedLeftCount = 0
+ lines.eachWithIndex { String ln, int i ->
+ if (ln.contains("PhysicalDistribute[DistributionSpecHash]")
+ &&
parentOf(i).contains("PhysicalIntersect[bucketShuffle]")) {
+ enforcedLeftCount++
+ }
+ }
+ assertTrue(enforcedLeftCount >= 2,
+ "each intersect must shuffle its left child through a
PhysicalDistribute[DistributionSpecHash]")
+ }
+ }
+ order_qt_intersect_right_basic_parent_hash intersectRightBasicSql
+
+ // A non-intersect bucket-shuffle set operation (UNION ALL) whose basic /
anchor child is a
+ // direct bucketed scan that is bucket-pruned by an IN predicate on the
distribution key, so
+ // it only scans a subset of the buckets. The other child is shuffled onto
the anchor's
+ // storage layout and has rows in the buckets the pruned anchor does not
scan. Because the
+ // union is a non-intersect bucket-shuffle set operation,
UnassignedScanBucketOlapTableJob
+ // must fill up receiver instances for those missing buckets; without the
fill-up the other
+ // child's rows in the missing buckets would have no destination instance
and be lost.
+ //
+ // The setup forces the pruned scan to be the anchor: fill_anchor has huge
injected stats so
+ // the IN-filtered branch still wins the largest-row-count basic-child
selection, while
+ // fill_spread has a mismatched bucket count (11 vs 10) so it cannot be
the natural anchor and
+ // must be shuffled. The bucket-shuffle join above the union supplies the
parent hash request
+ // that makes the union choose bucket shuffle, and the join probe
fill_probe has yet another
+ // bucket count (7) so the join is a real shuffle in its own fragment and
does not co-locate a
+ // full-bucket scan into the union fragment (which would otherwise cover
the missing buckets
+ // and hide the fill-up).
+ sql "drop table if exists bucket_shuffle_set_operation_fill_anchor"
+ sql """create table bucket_shuffle_set_operation_fill_anchor(id int)
+ distributed by hash(id) buckets 10
properties('replication_num'='1')"""
+ sql "insert into bucket_shuffle_set_operation_fill_anchor select number
from numbers('number'='20')"
+ sql "drop table if exists bucket_shuffle_set_operation_fill_spread"
+ sql """create table bucket_shuffle_set_operation_fill_spread(id int)
+ distributed by hash(id) buckets 11
properties('replication_num'='1')"""
+ sql "insert into bucket_shuffle_set_operation_fill_spread select number
from numbers('number'='20')"
+ sql "drop table if exists bucket_shuffle_set_operation_fill_probe"
+ sql """create table bucket_shuffle_set_operation_fill_probe(id int)
+ distributed by hash(id) buckets 7
properties('replication_num'='1')"""
+ sql "insert into bucket_shuffle_set_operation_fill_probe select number
from numbers('number'='20')"
+ sql """alter table bucket_shuffle_set_operation_fill_anchor modify column
id
+ set stats ('row_count'='1000000', 'ndv'='20', 'min_value'='0',
'max_value'='19')"""
+ sql """alter table bucket_shuffle_set_operation_fill_spread modify column
id
+ set stats ('row_count'='50', 'ndv'='20', 'min_value'='0',
'max_value'='19')"""
+ sql """alter table bucket_shuffle_set_operation_fill_probe modify column id
+ set stats ('row_count'='30', 'ndv'='20', 'min_value'='0',
'max_value'='19')"""
+ def bucketShuffleUnionFillUpSql = """
+ select t.id from (
+ select id from bucket_shuffle_set_operation_fill_anchor where id
in (0, 2, 4, 6)
+ union all
+ select id from bucket_shuffle_set_operation_fill_spread
+ ) t join[shuffle] bucket_shuffle_set_operation_fill_probe c on t.id =
c.id"""
+ explain {
+ sql "shape plan " + bucketShuffleUnionFillUpSql
+ check { String e ->
+ assertTrue(e.contains("PhysicalUnion[bucketShuffle]"))
+ }
+ }
+ order_qt_bucket_shuffle_union_fill_up bucketShuffleUnionFillUpSql
+
+ // Same missing-bucket fill-up contract, but the pruned basic child
exposes its storage bucket
+ // key only through an equivalent slot: the union's basic child is a
bucket-shuffle join output
+ // that projects the join key bucket_shuffle_set_operation2.id AS k, so
the storage bucket column
+ // (fill_anchor.id) is hidden and only the equivalent k is visible in the
set-operation output.
+ // The alignment proof must resolve the bucket key through its hash
equivalence set (mirroring
+ // ChildrenPropertiesRegulator.canMapBucketKeysToRequire); a direct ExprId
lookup would report
+ // the union as not bucket-aligned, drop the BUCKET_SHUFFLE marker, and
skip
+ // UnassignedScanBucketOlapTableJob.fillUpInstances(), losing the shuffled
side's rows in the
+ // buckets the pruned basic child does not scan.
+ def bucketShuffleEquivalentKeyFillUpSql = """
+ select t.k from (
+ select b.id as k from bucket_shuffle_set_operation_fill_anchor a
+ join[shuffle] bucket_shuffle_set_operation2 b on a.id = b.id
+ where a.id in (0, 2, 4, 6)
+ union all
+ select id from bucket_shuffle_set_operation_fill_spread
+ ) t join[shuffle] bucket_shuffle_set_operation_fill_probe c on t.k =
c.id"""
+ explain {
+ sql "shape plan " + bucketShuffleEquivalentKeyFillUpSql
+ check { String e ->
+ assertTrue(e.contains("PhysicalUnion[bucketShuffle]"))
+ }
+ }
+ order_qt_bucket_shuffle_equivalent_key_fill_up
bucketShuffleEquivalentKeyFillUpSql
+
+ // Same shape but the hidden bucket key has two visible equivalent
set-output columns: the join
+ // chain a.id = b.id = c.id makes fill_anchor.id equivalent to both
projected columns x (c.id)
+ // and y (b.id), and the parent hashes the union output on the later one
(y). The bucket key
+ // alignment must resolve to the same output position across children (the
enforced sibling is
+ // shuffled by y), which is why the proof intersects each child's
candidate equivalent positions
+ // rather than letting the basic child pick its first visible equivalent
(x) and disagree with
+ // the sibling on y.
+ def bucketShuffleTwoEquivalentKeysSql = """
+ select t.x, t.y from (
+ select c.id as x, b.id as y from
bucket_shuffle_set_operation_fill_anchor a
+ join[shuffle] bucket_shuffle_set_operation2 b on a.id = b.id
+ join[shuffle] bucket_shuffle_set_operation3 c on a.id = c.id
+ where a.id in (0, 2, 4, 6)
+ union all
+ select id, id from bucket_shuffle_set_operation_fill_spread
+ ) t join[shuffle] bucket_shuffle_set_operation_fill_probe p on t.y =
p.id"""
+ explain {
+ sql "shape plan " + bucketShuffleTwoEquivalentKeysSql
+ check { String e ->
+ assertTrue(e.contains("PhysicalUnion[bucketShuffle]"))
+ }
+ }
+ order_qt_bucket_shuffle_two_equivalent_keys
bucketShuffleTwoEquivalentKeysSql
+
+ // The basic child candidate can be bucketed by MORE columns than the set
operation distributes
+ // on: a table distributed by hash(k, v) feeding an INTERSECT on only k.
The (k, v) bucket key is
+ // wider than the single-column requirement, so
canMapBucketKeysToRequire() must reject it as the
+ // bucket-shuffle basic and the set operation falls back to execution-hash
shuffle. Without that
+ // guard the planner hits a checkState in calAnotherSideRequiredShuffleIds
and fails to plan. Two
+ // such tables (different bucket counts so neither is a natural colocate
basic) force the fallback
+ // on both sides.
+ sql "drop table if exists bucket_shuffle_set_operation_kv"
+ sql """create table bucket_shuffle_set_operation_kv(k int, v int)
+ distributed by hash(k, v) buckets 10
properties('replication_num'='1')"""
+ sql "insert into bucket_shuffle_set_operation_kv values (1, 1), (2, 2),
(3, 3)"
+ sql "drop table if exists bucket_shuffle_set_operation_kv2"
+ sql """create table bucket_shuffle_set_operation_kv2(k int, v int)
+ distributed by hash(k, v) buckets 11
properties('replication_num'='1')"""
+ sql "insert into bucket_shuffle_set_operation_kv2 values (1, 1), (2, 2),
(3, 3)"
+ def multiColumnBucketKeyIntersectSql = """
+ select k from bucket_shuffle_set_operation_kv
+ intersect
+ select k from bucket_shuffle_set_operation_kv2"""
+ explain {
+ sql "shape plan " + multiColumnBucketKeyIntersectSql
+ check { String e ->
+ assertFalse(e.contains("bucketShuffle"))
+ }
+ }
+ order_qt_multi_column_bucket_key_intersect multiColumnBucketKeyIntersectSql
+
explain {
sql """
select id, id as id2 from (select nullable(id) as id from
bucket_shuffle_set_operation1)a
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]