This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 02c41a39e8f Use a local exchange for UNION ALL inputs in the
multi-stage engine (#19330)
02c41a39e8f is described below
commit 02c41a39e8f0b8d41440696cf02f76c6955e5faa
Author: Yash Mayya <[email protected]>
AuthorDate: Thu Aug 27 12:04:52 2026 -0400
Use a local exchange for UNION ALL inputs in the multi-stage engine (#19330)
---
.../pinot/calcite/rel/hint/PinotHintOptions.java | 14 +-
.../rel/rules/PinotRelDistributionTraitRule.java | 21 +++
.../rules/PinotSetOpExchangeNodeInsertRule.java | 21 ++-
.../planner/physical/MailboxAssignmentVisitor.java | 62 +++++++--
.../apache/pinot/query/routing/WorkerManager.java | 57 +++++++-
.../apache/pinot/query/QueryCompilationTest.java | 148 +++++++++++++++++++--
.../pinot/query/QueryPlannerRuleOptionsTest.java | 12 +-
.../physical/MailboxAssignmentVisitorTest.java | 82 +++++++++++-
.../src/test/resources/queries/AggregatePlans.json | 4 +-
.../resources/queries/ExplainPhysicalPlans.json | 26 ++--
.../src/test/resources/queries/SetOpPlans.json | 34 ++---
.../src/test/resources/queries/QueryHints.json | 28 ++++
12 files changed, 428 insertions(+), 81 deletions(-)
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/hint/PinotHintOptions.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/hint/PinotHintOptions.java
index 4c3b73aa017..36b43b0f734 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/hint/PinotHintOptions.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/hint/PinotHintOptions.java
@@ -88,8 +88,9 @@ public class PinotHintOptions {
/// Hint options for set operations (UNION / UNION ALL / INTERSECT / EXCEPT).
public static class SetOpHintOptions {
- /// Forces (or disables) a colocated, pre-partitioned exchange on every
input of a set operation
- /// (UNION / UNION ALL / INTERSECT / EXCEPT), so the inputs are processed
in place without a network shuffle.
+ /// Forces (or disables) a colocated, pre-partitioned exchange on every
input of an INTERSECT, EXCEPT or
+ /// distinct UNION, so the inputs are processed in place without a network
shuffle. On a UNION ALL only
+ /// `'false'` has an effect; see below.
///
/// This is opt-in and honored only by the default (V1) query planner; the
V2 physical planner determines
/// colocation on its own. Like the join hint
[JoinHintOptions#IS_COLOCATED_BY_JOIN_KEYS], it trusts the user:
@@ -98,8 +99,13 @@ public class PinotHintOptions {
/// operation matches rows on the entire output row, so forcing `'true'`
is only correct when all inputs are
/// partitioned the same way (same partition function and count) on one or
more of the projected columns, so that
/// rows which are equal across all projected columns are guaranteed to
land on the same worker. Forcing it when
- /// that does not hold silently produces wrong results for INTERSECT,
EXCEPT and distinct UNION; UNION ALL only
- /// concatenates and is unaffected.
+ /// that does not hold silently produces wrong results for INTERSECT,
EXCEPT and distinct UNION.
+ ///
+ /// UNION ALL is different: it only concatenates, so it is correct under
ANY row-to-worker mapping and never
+ /// needs a shuffle. Its inputs always get a local exchange, and `'true'`
is therefore a no-op on a UNION ALL.
+ /// Only `'false'` changes anything there -- it restores the full-row
shuffle. Note that a partitioning assertion
+ /// made *above* a UNION ALL (e.g. `aggOptions` on an aggregation over it)
is never established by the union's own
+ /// exchanges; it must hold on the physical data.
public static final String IS_COLOCATED_BY_SET_OP_KEYS =
"is_colocated_by_set_op_keys";
/// Reads the hint from a hint list. Unlike
[JoinHintOptions#isColocatedByJoinKeys], this takes the hint list
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRelDistributionTraitRule.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRelDistributionTraitRule.java
index 985937281bd..09877167631 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRelDistributionTraitRule.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRelDistributionTraitRule.java
@@ -31,6 +31,7 @@ import org.apache.calcite.rel.RelDistributions;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.Exchange;
import org.apache.calcite.rel.core.Project;
+import org.apache.calcite.rel.core.SetOp;
import org.apache.calcite.rel.core.TableScan;
import org.apache.calcite.rel.logical.LogicalFilter;
import org.apache.calcite.rel.logical.LogicalJoin;
@@ -161,6 +162,26 @@ public class PinotRelDistributionTraitRule extends
RelOptRule {
if (inputRelDistribution != null) {
return inputRelDistribution;
}
+ } else if (node instanceof SetOp) {
+ // A set operation sits above the exchanges inserted by
PinotSetOpExchangeNodeInsertRule, and an exchange that
+ // genuinely redistributes describes the output: hash on the projected
columns leaves the output hash
+ // distributed on them, broadcast leaves every worker holding all rows
of every branch. That holds for a
+ // pre-partitioned hash exchange too, because
is_colocated_by_set_op_keys asserts that rows equal across all
+ // projected columns already share a worker -- exactly the claimed
distribution. We take the hint at its word
+ // here, just as the exchange itself does.
+ // A local (SINGLETON) exchange, which is what UNION ALL gets, is the
exception: it redistributes nothing, so
+ // the output keeps whatever placement the inputs happened to have and
SINGLETON ("everything on one worker")
+ // is not true of it. Claiming anything there would let a deduplicating
consumer above it -- for example the
+ // aggregate UnionToDistinctRule puts over a distinct UNION -- skip a
shuffle it needs.
+ // All inputs are checked so a future per-branch decision cannot
silently invalidate this.
+ for (RelNode setOpInput : inputs) {
+ RelNode unboxedInput = PinotRuleUtils.unboxRel(setOpInput);
+ if (!(unboxedInput instanceof PinotLogicalExchange)
+ || ((PinotLogicalExchange)
unboxedInput).getDistribution().getType() == RelDistribution.Type.SINGLETON) {
+ return RelDistributions.of(RelDistribution.Type.RANDOM_DISTRIBUTED,
RelDistributions.EMPTY);
+ }
+ }
+ return ((PinotLogicalExchange) input).getDistribution();
}
// TODO: add the rest of the nodes.
return computeCurrentDistribution(node);
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotSetOpExchangeNodeInsertRule.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotSetOpExchangeNodeInsertRule.java
index e0e90f05331..03537068ca1 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotSetOpExchangeNodeInsertRule.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotSetOpExchangeNodeInsertRule.java
@@ -26,6 +26,7 @@ import org.apache.calcite.plan.RelOptRuleCall;
import org.apache.calcite.rel.RelDistributions;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
import org.apache.calcite.rel.hint.Hintable;
import org.apache.calcite.tools.RelBuilderFactory;
import org.apache.calcite.util.ImmutableIntList;
@@ -53,13 +54,27 @@ public class PinotSetOpExchangeNodeInsertRule extends
RelOptRule {
SetOp setOp = call.rel(0);
List<RelNode> inputs = setOp.getInputs();
// When the colocation hint is set, force a pre-partitioned (direct,
no-shuffle) exchange on every input; otherwise
- // leave it null so the planner auto-detects pre-partitioning from the
inputs' distribution. See
+ // leave it null so the planner auto-detects pre-partitioning from the
inputs' distribution. On a UNION ALL only
+ // 'false' has an effect, because its inputs already get a local exchange
either way. See
// PinotHintOptions.SetOpHintOptions.IS_COLOCATED_BY_SET_OP_KEYS for the
correctness contract.
Boolean prePartitioned = resolveColocationHint(setOp);
+ // UNION ALL only concatenates its inputs, so any row-to-worker mapping
produces correct results and no
+ // redistribution is required. Use a local (SINGLETON) exchange so the
union stage inherits its inputs' worker
+ // assignment and the rows are handed over in place, with no shuffle and
no network hop when sender and receiver
+ // land on the same server.
+ // The projected columns are still attached as keys. They are unused while
the exchange stays local, but they let
+ // the mailbox layer promote it to a real HASH distribution when the
inputs do not resolve to the same workers --
+ // the same idiom PinotJoinExchangeNodeInsertRule uses for
distribution_type='local'. Keeping keys also means a
+ // UNION ALL input is never a KEYLESS local exchange, so the "local
exchange with parallelism requires keys"
+ // guard continues to protect the colocated semi-join build side untouched.
+ // An explicit is_colocated_by_set_op_keys='false' hint opts out and
restores the full-row shuffle.
+ List<Integer> keys = ImmutableIntList.range(0,
setOp.getRowType().getFieldCount());
+ boolean useLocalExchange = setOp instanceof Union && ((Union) setOp).all
&& !Boolean.FALSE.equals(prePartitioned);
List<RelNode> newInputs = new ArrayList<>(inputs.size());
for (RelNode input : inputs) {
- RelNode exchange = PinotLogicalExchange.create(input,
- RelDistributions.hash(ImmutableIntList.range(0,
setOp.getRowType().getFieldCount())), prePartitioned);
+ RelNode exchange = useLocalExchange
+ ? PinotLogicalExchange.create(input, RelDistributions.SINGLETON,
keys, null)
+ : PinotLogicalExchange.create(input, RelDistributions.hash(keys),
prePartitioned);
newInputs.add(exchange);
}
call.transformTo(setOp.copy(setOp.getTraitSet(), newInputs));
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitor.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitor.java
index a1e0363e608..0036d8f8e81 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitor.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitor.java
@@ -44,6 +44,9 @@ public class MailboxAssignmentVisitor extends
DefaultPostOrderTraversalVisitor<V
MailboxSendNode sendNode = (MailboxSendNode) node;
// NOTE: Using Integer to avoid boxing
Integer senderStageId = sendNode.getStageId();
+ // Captured before the loop because the branches below rewrite it, and
one send node can serve several
+ // receiver stages (spools) -- reading it per iteration would make the
wiring depend on visit order.
+ RelDistribution.Type originalDistributionType =
sendNode.getDistributionType();
for (Integer receiverStageId : sendNode.getReceiverStageIds()) {
Map<Integer, DispatchablePlanMetadata> metadataMap =
context.getDispatchablePlanMetadataMap();
DispatchablePlanMetadata senderMetadata =
metadataMap.get(senderStageId);
@@ -55,23 +58,37 @@ public class MailboxAssignmentVisitor extends
DefaultPostOrderTraversalVisitor<V
int numSenders = senderServerMap.size();
int numReceivers = receiverServerMap.size();
- if (sendNode.getDistributionType() == RelDistribution.Type.SINGLETON) {
- // NOTE: We use SINGLETON to represent a local exchange. The actual
distribution type is determined by the
- // parallelism: 1-to-1 when sender and receiver have the same
number of workers, otherwise the data is
- // hash distributed to the parallel receivers on each server.
computeDirectExchange handles the
- // co-location assumption and its cross-server fallback.
- if (numSenders != numReceivers) {
- // Local exchange with parallelism: hash distribute to the
parallel receivers, so keys are required.
+ if (originalDistributionType == RelDistribution.Type.SINGLETON) {
+ // NOTE: We use SINGLETON to represent a local exchange. 1-to-1 when
sender and receiver have the same
+ // number of workers, otherwise each sender fans out to the
parallel receivers on its OWN server.
+ if (numSenders == 0) {
+ // A fully pruned sender stage has nothing to wire, and the
parallelism below would divide by zero.
+ connectWorkers(receiverStageId, receiverServerMap,
senderMailboxesMap, numSenders);
+ connectWorkers(senderStageId, senderServerMap,
receiverMailboxesMap, numReceivers);
+ } else if (numSenders == numReceivers) {
+ computeDirectExchange(senderMailboxesMap, receiverMailboxesMap,
senderStageId, receiverStageId,
+ senderServerMap, receiverServerMap, numSenders, 1,
senderMetadata, receiverMetadata);
+ } else {
+ // Redistributing across several receivers needs keys. A keyless
local exchange here is the colocated
+ // dynamic-broadcast semi-join build side, which needs EVERY
receiver to see the whole build side.
// TODO: Support local exchange with parallelism but no key
Preconditions.checkState(!sendNode.getKeys().isEmpty(), "Local
exchange with parallelism requires keys");
sendNode.setDistributionType(RelDistribution.Type.HASH_DISTRIBUTED);
- Preconditions.checkState(numReceivers % numSenders == 0,
- "Number of receivers: %s should be a multiple of number of
senders: %s for local exchange",
- numReceivers, numSenders);
+ // The parallel wiring addresses a whole receiver range at the
range's first host, which only holds when
+ // the receiver map was derived from the sender by
WorkerManager#assignWorkersForLocalExchange. Otherwise
+ // a range spans hosts and blocks would land on the wrong one,
stalling the receiver until the deadline.
+ // So verify co-residency, and fall back to a hash shuffle when it
does not hold -- correct for every
+ // local exchange kind, since HashExchange routes each key
consistently.
+ if (numReceivers % numSenders == 0
+ && isEachRangeCoResident(senderServerMap, receiverServerMap,
numSenders, numReceivers / numSenders)) {
+ computeDirectExchange(senderMailboxesMap, receiverMailboxesMap,
senderStageId, receiverStageId,
+ senderServerMap, receiverServerMap, numSenders, numReceivers
/ numSenders, senderMetadata,
+ receiverMetadata);
+ } else {
+ connectWorkers(receiverStageId, receiverServerMap,
senderMailboxesMap, numSenders);
+ connectWorkers(senderStageId, senderServerMap,
receiverMailboxesMap, numReceivers);
+ }
}
- int parallelism = numReceivers / numSenders;
- computeDirectExchange(senderMailboxesMap, receiverMailboxesMap,
senderStageId, receiverStageId,
- senderServerMap, receiverServerMap, numSenders, parallelism,
senderMetadata, receiverMetadata);
} else if (senderMetadata.isPrePartitioned() &&
isDirectExchangeCompatible(senderMetadata, receiverMetadata)) {
// Direct exchange: the data is already pre-partitioned, so send it
1-to-1 to the worker with the same worker
// id (with parallelism, fan out each sender worker to a contiguous
range of receiver workers). The
@@ -181,6 +198,21 @@ public class MailboxAssignmentVisitor extends
DefaultPostOrderTraversalVisitor<V
Arrays.toString(receiverPartitionClassIds));
}
+ /// Whether every sender's contiguous receiver range sits entirely on that
sender's own server, which is what
+ /// [#computeDirectExchangeWithParallelism] assumes when it addresses a
whole range at a single host.
+ private static boolean isEachRangeCoResident(Map<Integer,
QueryServerInstance> senderServerMap,
+ Map<Integer, QueryServerInstance> receiverServerMap, int numSenders, int
parallelism) {
+ for (int senderWorkerId = 0; senderWorkerId < numSenders;
senderWorkerId++) {
+ QueryServerInstance senderServer = senderServerMap.get(senderWorkerId);
+ for (int i = 0; i < parallelism; i++) {
+ if (!senderServer.equals(receiverServerMap.get(senderWorkerId *
parallelism + i))) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
/// Wires one sender worker of a direct exchange to the contiguous range of
`parallelism` receiver workers it fans out
/// to. See [#computeDirectExchange].
private void computeDirectExchangeWithParallelism(Map<Integer, Map<Integer,
MailboxInfos>> senderMailboxesMap,
@@ -205,7 +237,9 @@ public class MailboxAssignmentVisitor extends
DefaultPostOrderTraversalVisitor<V
DispatchablePlanMetadata receiver) {
int numSenders = sender.getWorkerIdToServerInstanceMap().size();
int numReceivers = receiver.getWorkerIdToServerInstanceMap().size();
- if (numSenders * sender.getPartitionParallelism() != numReceivers) {
+ // numSenders is 0 when every segment of a leaf stage was pruned. Guard
it: with numReceivers also 0 it
+ // passes the multiplication check and then divides by zero computing the
parallelism.
+ if (numSenders == 0 || numSenders * sender.getPartitionParallelism() !=
numReceivers) {
return false;
}
// A sender whose worker ids stand for partition classes may only be wired
1-to-1 to a receiver whose worker ids
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
index 917cbab89fe..6768fc3b306 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/WorkerManager.java
@@ -38,6 +38,7 @@ import javax.annotation.Nullable;
import org.apache.calcite.rel.RelDistribution;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
+import org.apache.commons.lang3.StringUtils;
import org.apache.pinot.calcite.rel.logical.PinotRelExchangeType;
import org.apache.pinot.calcite.rel.rules.ImmutableTableOptions;
import org.apache.pinot.calcite.rel.rules.TableOptions;
@@ -479,6 +480,43 @@ public class WorkerManager {
return false;
}
+ /// A stage adopts the worker assignment of the FIRST of its local exchange
children (every branch of a UNION ALL
+ /// is one); the others then send 1-to-1 by worker id. Only the worker COUNT
has to match, because
+ /// [MailboxAssignmentVisitor] already handles a child whose workers sit on
different servers -- it costs a network
+ /// hop rather than an in-process handover, still far cheaper than the
shuffle it replaces. A child with no worker
+ /// at all (a fully pruned leaf) can never anchor it: inheriting its empty
map would leave this stage with no
+ /// workers and silently drop every live sibling's rows.
+ private static boolean
canInheritWorkerAssignment(List<DispatchablePlanMetadata> children) {
+ Map<Integer, QueryServerInstance> anchor =
+ children.isEmpty() ? null :
children.get(0).getWorkerIdToServerInstanceMap();
+ if (anchor == null || anchor.isEmpty()) {
+ return false;
+ }
+ for (int i = 1; i < children.size(); i++) {
+ Map<Integer, QueryServerInstance> workers =
children.get(i).getWorkerIdToServerInstanceMap();
+ if (workers == null || workers.size() != anchor.size()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /// Whether the partition descriptor inherited from the first local exchange
child describes ALL of them. When it
+ /// does not, the stage still keeps their worker assignment -- a UNION ALL
only concatenates, so placement is free
+ /// -- but it must not advertise a descriptor that only some of its rows
satisfy, because exchanges above it read
+ /// it (see [MailboxAssignmentVisitor#isDirectExchangeCompatible]) to decide
whether they may skip a shuffle.
+ private static boolean shareOnePartitioning(List<DispatchablePlanMetadata>
children) {
+ DispatchablePlanMetadata first = children.get(0);
+ for (int i = 1; i < children.size(); i++) {
+ DispatchablePlanMetadata other = children.get(i);
+ if (!Arrays.equals(first.getPartitionClassIds(),
other.getPartitionClassIds())
+ || !StringUtils.equalsIgnoreCase(first.getPartitionFunction(),
other.getPartitionFunction())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private Map<Integer, QueryServerInstance>
assignWorkersForLocalExchange(DispatchablePlanMetadata childMetadata) {
int partitionParallelism = childMetadata.getPartitionParallelism();
Map<Integer, QueryServerInstance> childWorkerIdToServerInstanceMap =
childMetadata.getWorkerIdToServerInstanceMap();
@@ -554,15 +592,20 @@ public class WorkerManager {
}
// Assign workers for local exchange if there is one
- DispatchablePlanMetadata localExchangeChildMetadata = null;
- Map<Integer, QueryServerInstance> workerIdToServerInstanceMap = null;
+ List<DispatchablePlanMetadata> localExchangeChildren = new
ArrayList<>(children.size());
for (PlanFragment child : children) {
if (isLocalExchange(child, context)) {
- Preconditions.checkState(localExchangeChildMetadata == null, "Found
multiple local exchanges in the children");
- localExchangeChildMetadata = metadataMap.get(child.getFragmentId());
- workerIdToServerInstanceMap =
assignWorkersForLocalExchange(localExchangeChildMetadata);
+ localExchangeChildren.add(metadataMap.get(child.getFragmentId()));
}
}
+ DispatchablePlanMetadata localExchangeChildMetadata = null;
+ Map<Integer, QueryServerInstance> workerIdToServerInstanceMap = null;
+ boolean inheritPartitioning = false;
+ if (canInheritWorkerAssignment(localExchangeChildren)) {
+ localExchangeChildMetadata = localExchangeChildren.get(0);
+ workerIdToServerInstanceMap =
assignWorkersForLocalExchange(localExchangeChildMetadata);
+ inheritPartitioning = shareOnePartitioning(localExchangeChildren);
+ }
// If there is no local exchange, assign workers to the servers hosting
the tables
List<QueryServerInstance> candidateServers = null;
@@ -624,12 +667,12 @@ public class WorkerManager {
// With a local exchange peer the worker map is copied from it, so the
classes come along; without one it comes
// from the candidate servers, whose worker ids are not classes at all.
childMetadata.setPartitionClassIds(
- localExchangeChildMetadata != null ?
localExchangeChildMetadata.getPartitionClassIds() : null);
+ inheritPartitioning ?
localExchangeChildMetadata.getPartitionClassIds() : null);
}
}
metadata.setWorkerIdToServerInstanceMap(workerIdToServerInstanceMap);
- if (localExchangeChildMetadata != null) {
+ if (inheritPartitioning) {
metadata.setPartitionFunction(localExchangeChildMetadata.getPartitionFunction());
metadata.setPartitionClassIds(localExchangeChildMetadata.getPartitionClassIds());
} else {
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index 66391119edd..6376839d748 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -22,6 +22,7 @@ import com.google.common.base.Throwables;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -1274,8 +1275,8 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
/// The `setOpOptions(is_colocated_by_set_op_keys='true')` hint forces a
pre-partitioned (direct) exchange on
/// every input of a set operation, avoiding the shuffle. Here the inputs
project `col3`, which is neither
- /// table's partition column, so without the hint the planner would shuffle.
Covers UNION ALL, INTERSECT and EXCEPT,
- /// which all share
[org.apache.pinot.calcite.rel.rules.PinotSetOpExchangeNodeInsertRule].
+ /// table's partition column, so without the hint the planner would shuffle.
UNION ALL is not covered here: it
+ /// gets a local exchange with or without the hint (see
[#testUnionAllUsesLocalExchange]).
@Test(dataProvider = "setOpColocationHintQueries")
public void testSetOpColocationHintForcesPrePartitionedExchange(String
query) {
List<MailboxSendNode> sendNodes =
findSetOpInputSendNodes(_queryEnvironment.planQuery(query));
@@ -1290,7 +1291,6 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
private Object[][] setOpColocationHintQueries() {
String hint = "/*+ setOpOptions(is_colocated_by_set_op_keys='true') */";
return new Object[][]{
- {"SELECT " + hint + " col3 FROM a UNION ALL SELECT col3 FROM b"},
{"SELECT " + hint + " col3 FROM a INTERSECT SELECT col3 FROM b"},
{"SELECT " + hint + " col3 FROM a EXCEPT SELECT col3 FROM b"},
};
@@ -1301,7 +1301,7 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
@Test
public void testSetOpColocationHintViaOuterSelectWrap() {
String query = "SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='true') */ * FROM "
- + "(SELECT col3 FROM a UNION ALL SELECT col3 FROM b)";
+ + "(SELECT col3 FROM a INTERSECT SELECT col3 FROM b)";
for (MailboxSendNode sendNode :
findSetOpInputSendNodes(_queryEnvironment.planQuery(query))) {
assertTrue(sendNode.isPrePartitioned(), "Hint on the wrapping SELECT
should force a pre-partitioned exchange");
}
@@ -1313,24 +1313,140 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
@Test
public void testSetOpColocationHintFirstInputWins() {
String query = "SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='true') */ col3 FROM a "
- + "UNION ALL SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='false') */ col3 FROM a";
+ + "INTERSECT SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='false') */ col3 FROM a";
for (MailboxSendNode sendNode :
findSetOpInputSendNodes(_queryEnvironment.planQuery(query))) {
assertTrue(sendNode.isPrePartitioned(),
"The first input's hint value should win and apply to all inputs
when branches conflict");
}
}
- /// Without the hint and with inputs that are not partitioned by the
projected column, the exchanges below the set
- /// operation must be regular (shuffled) exchanges.
+ /// Without the hint and with inputs that are not partitioned by the
projected column, the exchanges below a
+ /// distinct set operation must be regular (shuffled) exchanges. UNION ALL
is exempt: it only concatenates, so it
+ /// gets a local exchange instead (see [#testUnionAllUsesLocalExchange]).
+ @Test(dataProvider = "shuffledSetOpQueries")
+ public void testDistinctSetOpWithoutHintIsNotPrePartitioned(String query) {
+ for (MailboxSendNode sendNode :
findSetOpInputSendNodes(_queryEnvironment.planQuery(query))) {
+ assertFalse(sendNode.isPrePartitioned(),
+ "Without the hint and matching partitioning, the set op exchanges
should be a full shuffle");
+ }
+ }
+
+ @DataProvider(name = "shuffledSetOpQueries")
+ private Object[][] shuffledSetOpQueries() {
+ return new Object[][]{
+ {"SELECT col3 FROM a INTERSECT SELECT col3 FROM b"},
+ {"SELECT col3 FROM a EXCEPT SELECT col3 FROM b"},
+ };
+ }
+
+ /// UNION ALL only concatenates, so any row-to-worker mapping is correct and
no redistribution is required. Its
+ /// input exchanges are local (SINGLETON) exchanges: the union stage
inherits its inputs' workers and the rows are
+ /// handed over in place. Note SINGLETON is Pinot's marker for a local
exchange, not a gather to one node.
+ /// The projected columns ride along as keys -- unused while the exchange
stays local, but they let the mailbox
+ /// layer promote it to a real hash shuffle when the branches cannot share a
worker assignment.
@Test
- public void testSetOpWithoutHintIsNotPrePartitioned() {
+ public void testUnionAllUsesLocalExchange() {
+ // Both branches read table a, so they resolve to the same workers and the
local exchange survives to the plan.
+ String query = "SELECT col3 FROM a UNION ALL SELECT col3 FROM a";
+ List<MailboxSendNode> sendNodes =
findSetOpInputSendNodes(_queryEnvironment.planQuery(query));
+ assertFalse(sendNodes.isEmpty());
+ for (MailboxSendNode sendNode : sendNodes) {
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.SINGLETON);
+ assertEquals(sendNode.getKeys(), List.of(0),
+ "A local UNION ALL exchange should carry the projected columns as
keys");
+ }
+ }
+
+ /// When the branches do not resolve to the same workers -- table a lives on
two servers and table b on one -- the
+ /// union stage cannot inherit both. It takes the two-worker layout, so
branch a is still wired 1-to-1 and stays
+ /// SINGLETON, while branch b is promoted to a real hash shuffle on the
projected columns. Correct either way for
+ /// a concatenation, and the aligned branch still pays nothing.
+ @Test
+ public void testUnionAllWithMisalignedBranchesShufflesOnlyTheMisalignedOne()
{
+ String query = "SELECT col3 FROM a UNION ALL SELECT col3 FROM b";
+ List<MailboxSendNode> sendNodes =
findSetOpInputSendNodes(_queryEnvironment.planQuery(query));
+ assertEquals(sendNodes.size(), 2);
+ Set<RelDistribution.Type> types = new HashSet<>();
+ for (MailboxSendNode sendNode : sendNodes) {
+ types.add(sendNode.getDistributionType());
+ }
+ assertEquals(types, Set.of(RelDistribution.Type.SINGLETON,
RelDistribution.Type.HASH_DISTRIBUTED),
+ "The aligned branch should stay local and the misaligned one should be
promoted to a hash shuffle");
+ }
+
+ /// Branches of the SAME WIDTH on DIFFERENT servers still stay local. The
union stage adopts one branch's layout,
+ /// and the other sends 1-to-1 by worker id -- correct for a concatenation
whichever server each worker sits on,
+ /// costing a network hop rather than a shuffle. Requiring identical worker
maps here would push this into a full
+ /// shuffle, which is worse than what it replaces.
+ @Test
+ public void testUnionAllWithEqualWidthBranchesOnDifferentServersStaysLocal()
{
+ // Table a lives only on server 1 and table b only on server 2, so both
resolve to one worker on disjoint servers.
+ QueryEnvironment queryEnvironment = getQueryEnvironment(3, 1, 2,
TABLE_SCHEMAS,
+ Map.of("a_REALTIME", List.of("a1")), Map.of("b_REALTIME",
List.of("b1")), null);
String query = "SELECT col3 FROM a UNION ALL SELECT col3 FROM b";
+ List<MailboxSendNode> sendNodes =
findSetOpInputSendNodes(queryEnvironment.planQuery(query));
+ assertEquals(sendNodes.size(), 2);
+ for (MailboxSendNode sendNode : sendNodes) {
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.SINGLETON,
+ "Equal-width branches should stay local even when their workers sit
on different servers");
+ }
+ }
+
+ /// `setOpOptions(is_colocated_by_set_op_keys='false')` opts UNION ALL out
of the local exchange and restores the
+ /// full-row hash shuffle.
+ @Test
+ public void testUnionAllHintFalseRestoresShuffle() {
+ String query = "SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='false') */ col3 FROM a "
+ + "UNION ALL SELECT col3 FROM b";
for (MailboxSendNode sendNode :
findSetOpInputSendNodes(_queryEnvironment.planQuery(query))) {
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.HASH_DISTRIBUTED);
assertFalse(sendNode.isPrePartitioned(),
- "Without the hint and matching partitioning, the set op exchanges
should be a full shuffle");
+ "setOpOptions(is_colocated_by_set_op_keys='false') should restore
the shuffle for UNION ALL");
}
}
+ /// A non-UNION-ALL set operation's input exchanges shuffle on the full row,
so its output is hash distributed on
+ /// all of its columns and a downstream exchange keyed on those columns is
auto-detected as pre-partitioned.
+ /// INTERSECT ALL is used (rather than INTERSECT) so the aggregate above
survives planning: a distinct set op's
+ /// output is unique on the group keys, which lets Calcite remove the
aggregate and its exchange entirely.
+ @Test
+ public void testSetOpOutputIsHashDistributedOnAllColumns() {
+ String query = "SELECT col1, col2, COUNT(*) FROM "
+ + "(SELECT col1, col2 FROM a INTERSECT ALL SELECT col1, col2 FROM b)
GROUP BY col1, col2";
+ MailboxSendNode sendNode =
findSendNodeAboveSetOp(_queryEnvironment.planQuery(query));
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.HASH_DISTRIBUTED);
+ assertTrue(sendNode.isPrePartitioned(),
+ "An exchange keyed on all columns of a shuffled set op should
auto-detect pre-partitioning");
+ }
+
+ /// UNION ALL's output carries no distribution guarantee: a local exchange
does not redistribute anything, so it
+ /// promises nothing about where equal rows land. A downstream exchange
keyed on all of its columns must NOT be
+ /// auto-detected as pre-partitioned -- the deduplicating aggregate above it
needs a real shuffle to be correct.
+ @Test
+ public void testUnionAllOutputIsNotTreatedAsHashDistributed() {
+ String query = "SELECT col1, col2, COUNT(*) FROM "
+ + "(SELECT col1, col2 FROM a UNION ALL SELECT col1, col2 FROM b) GROUP
BY col1, col2";
+ MailboxSendNode sendNode =
findSendNodeAboveSetOp(_queryEnvironment.planQuery(query));
+ assertFalse(sendNode.isPrePartitioned(),
+ "UNION ALL output must not be treated as hash distributed: its inputs
are not shuffled");
+ }
+
+ /// `is_colocated_by_set_op_keys='true'` asserts that rows equal across all
projected columns already share a
+ /// worker, which is exactly the all-column hash distribution. The set op's
output is therefore claimed as hash
+ /// distributed just as it is for genuinely shuffled inputs, so a downstream
exchange keyed on those columns is
+ /// auto-detected as pre-partitioned too and the colocation carries all the
way up. Same query as
+ /// [#testSetOpOutputIsHashDistributedOnAllColumns] plus the hint.
+ @Test
+ public void testHintedColocatedSetOpOutputIsHashDistributed() {
+ String query = "SELECT col1, col2, COUNT(*) FROM "
+ + "(SELECT /*+ setOpOptions(is_colocated_by_set_op_keys='true') */
col1, col2 FROM a "
+ + "INTERSECT ALL SELECT col1, col2 FROM b) GROUP BY col1, col2";
+ MailboxSendNode sendNode =
findSendNodeAboveSetOp(_queryEnvironment.planQuery(query));
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.HASH_DISTRIBUTED);
+ assertTrue(sendNode.isPrePartitioned(),
+ "A hint-forced colocated set op's output should be treated as hash
distributed on all columns");
+ }
+
/// When each input is declared partitioned by the projected column, the
single-column set-op exchange matches the
/// input partitioning, so the planner auto-detects a pre-partitioned
exchange even without the hint. This is the
/// baseline that [#testSetOpColocationHintFalseDisablesAutoDetected]
overrides.
@@ -1358,7 +1474,7 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
// op matches the input partitioning and the planner auto-detects
pre-partitioning.
private static final String AUTO_DETECTED_SET_OP =
"SELECT col2 FROM a /*+ tableOptions(partition_function='hashcode',
partition_key='col2', partition_size='4') */ "
- + "UNION ALL "
+ + "INTERSECT "
+ "SELECT col1 FROM b /*+
tableOptions(partition_function='hashcode', partition_key='col1', "
+ "partition_size='4') */";
@@ -1386,6 +1502,18 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
return sendNodes;
}
+ /// Finds the [MailboxSendNode] at the root of the fragment containing the
(single) set-op node, i.e. the exchange
+ /// that ships the set operation's output to its consumer stage.
+ private MailboxSendNode findSendNodeAboveSetOp(DispatchableSubPlan
dispatchableSubPlan) {
+ for (DispatchablePlanFragment fragment :
dispatchableSubPlan.getQueryStages()) {
+ PlanNode root = fragment.getPlanFragment().getFragmentRoot();
+ if (root instanceof MailboxSendNode && findNodeOfType(root,
SetOpNode.class) != null) {
+ return (MailboxSendNode) root;
+ }
+ }
+ throw new AssertionError("Expected a fragment rooted at a MailboxSendNode
containing a SetOp node");
+ }
+
/// When colocation hints are applied to a chain of operations that are all
keyed on the same (partition) column, the
/// whole chain executes without a data shuffle: every hash-distributed
exchange in the plan is pre-partitioned. This
/// covers combinations of colocated joins, window functions and set
operations (see [#colocatedChains]). The
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
index b9de8291778..5e8b28eeb1e 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
@@ -182,9 +182,9 @@ public class QueryPlannerRuleOptionsTest extends
QueryEnvironmentTestBase {
+ " PinotLogicalExchange(distribution=[hash[0]])\n"
+ " PinotLogicalAggregate(group=[{0}], aggType=[LEAF])\n"
+ " LogicalUnion(all=[true])\n"
- + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " PinotLogicalExchange(distribution=[single])\n"
+ " LogicalValues(tuples=[[]])\n"
- + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " PinotLogicalExchange(distribution=[single])\n"
+ " LogicalProject(col1=[$0])\n"
+ " PinotLogicalTableScan(table=[[default, b]])\n");
//@formatter:on
@@ -629,12 +629,12 @@ public class QueryPlannerRuleOptionsTest extends
QueryEnvironmentTestBase {
+ " PinotLogicalExchange(distribution=[hash[0]])\n"
+ " PinotLogicalAggregate(group=[{0}], aggType=[LEAF])\n"
+ " LogicalUnion(all=[true])\n"
- + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " PinotLogicalExchange(distribution=[single])\n"
+ " PinotLogicalAggregate(group=[{0}], aggType=[FINAL])\n"
+ " PinotLogicalExchange(distribution=[hash[0]])\n"
+ " PinotLogicalAggregate(group=[{0}],
aggType=[LEAF])\n"
+ " PinotLogicalTableScan(table=[[default, a]])\n"
- + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " PinotLogicalExchange(distribution=[single])\n"
+ " PinotLogicalAggregate(group=[{0}], aggType=[FINAL])\n"
+ " PinotLogicalExchange(distribution=[hash[0]])\n"
+ " PinotLogicalAggregate(group=[{0}],
aggType=[LEAF])\n"
@@ -664,10 +664,10 @@ public class QueryPlannerRuleOptionsTest extends
QueryEnvironmentTestBase {
+ " PinotLogicalExchange(distribution=[hash[0]])\n"
+ " PinotLogicalAggregate(group=[{0}], aggType=[LEAF])\n"
+ " LogicalUnion(all=[true])\n"
- + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " PinotLogicalExchange(distribution=[single])\n"
+ " LogicalProject(col1=[$0])\n"
+ " PinotLogicalTableScan(table=[[default, a]])\n"
- + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " PinotLogicalExchange(distribution=[single])\n"
+ " LogicalProject(col1=[$0])\n"
+ " PinotLogicalTableScan(table=[[default, b]])\n");
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitorTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitorTest.java
index 33476c72f87..9bc7a7eae1d 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitorTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/MailboxAssignmentVisitorTest.java
@@ -19,8 +19,10 @@
package org.apache.pinot.query.planner.physical;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.apache.calcite.rel.RelDistribution;
import org.apache.pinot.calcite.rel.logical.PinotRelExchangeType;
import org.apache.pinot.common.utils.DataSchema;
@@ -106,14 +108,69 @@ public class MailboxAssignmentVisitorTest extends
QueryEnvironmentTestBase {
assertEquals(singleMailbox(receiverMailboxes, 1,
SENDER_STAGE).getHostname(), "host_B");
}
- /// An unequal-but-non-multiple worker count (2 senders, 3 receivers) must
be rejected rather than rounding the
- /// parallelism down to 1 and silently dropping the extra receiver.
+ /// A KEYED local exchange (a UNION ALL input carries the projected columns)
whose worker counts do not divide
+ /// evenly is promoted to a real hash shuffle: HashExchange routes every key
consistently across the receivers, so
+ /// this is correct for a concatenation and for a keyed join alike.
+ @Test
+ public void testKeyedSingletonWithUnequalWorkersShuffles() {
+ DispatchablePlanMetadata sender = metadata(Map.of(0, server("A"), 1,
server("B")));
+ DispatchablePlanMetadata receiver = metadata(Map.of(0, server("A"), 1,
server("B"), 2, server("C")));
+ MailboxSendNode sendNode = singletonSendNode(List.of(0));
+ process(sendNode, sender, receiver);
+
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.HASH_DISTRIBUTED);
+ // Shuffled: every receiver worker reads from every sender worker.
+ for (int workerId = 0; workerId < 3; workerId++) {
+ assertEquals(expandedWorkerIds(receiver.getWorkerIdToMailboxesMap(),
workerId, SENDER_STAGE), List.of(0, 1));
+ }
+ }
+
+ /// The parallelism path addresses a whole receiver range at the range's
FIRST host, which is only valid when the
+ /// receiver map was derived from the sender. Here the single sender's two
receivers sit on DIFFERENT servers, so
+ /// that assumption does not hold: posting both to host_A would strand the
receiver on host_B until the deadline.
+ /// Co-residency is verified rather than assumed, and the exchange falls
back to a full shuffle.
+ @Test
+ public void testSingletonWithParallelismAcrossHostsShuffles() {
+ DispatchablePlanMetadata sender = metadata(Map.of(0, server("A")));
+ DispatchablePlanMetadata receiver = metadata(Map.of(0, server("A"), 1,
server("B")));
+ MailboxSendNode sendNode = singletonSendNode(List.of(0));
+ process(sendNode, sender, receiver);
+
+ assertEquals(sendNode.getDistributionType(),
RelDistribution.Type.HASH_DISTRIBUTED);
+ Set<String> hosts = new HashSet<>();
+ for (MailboxInfo mailboxInfo :
sender.getWorkerIdToMailboxesMap().get(0).get(RECEIVER_STAGE).getMailboxInfos())
{
+ hosts.add(mailboxInfo.getHostname());
+ }
+ assertEquals(hosts, Set.of("host_A", "host_B"), "Each receiver must be
addressed at its own host");
+ }
+
+ /// A KEYLESS local exchange must still fail loudly when the workers do not
line up. This is the colocated
+ /// dynamic-broadcast semi-join build side: every receiver needs the WHOLE
build side to build its filter
+ /// (the non-colocated variant broadcasts for exactly that reason), so
redistributing it would silently
+ /// drop matches. A UNION ALL input never reaches here because it carries
the projected columns as keys.
@Test(expectedExceptions = IllegalStateException.class,
- expectedExceptionsMessageRegExp = ".*multiple of number of senders.*")
- public void testSingletonRejectsNonMultipleReceiverCount() {
+ expectedExceptionsMessageRegExp = ".*requires keys.*")
+ public void testKeylessSingletonWithUnequalWorkersStillFails() {
DispatchablePlanMetadata sender = metadata(Map.of(0, server("A"), 1,
server("B")));
DispatchablePlanMetadata receiver = metadata(Map.of(0, server("A"), 1,
server("B"), 2, server("C")));
- process(singletonSendNode(List.of(0)), sender, receiver);
+ process(singletonSendNode(List.of()), sender, receiver);
+ }
+
+ /// A local exchange whose sender stage has no worker at all (a fully pruned
leaf) has nothing to wire 1-to-1, and
+ /// computing the parallelism would divide by zero. Every live receiver must
still get an entry, holding an empty
+ /// mailbox list, or its WorkerMetadata carries a null mailbox map and fails
during dispatch serialization.
+ @Test
+ public void testSingletonWithZeroSendersDoesNotDivideByZero() {
+ DispatchablePlanMetadata sender = metadata(Map.of());
+ DispatchablePlanMetadata receiver = metadata(Map.of(0, server("A"), 1,
server("B")));
+ process(singletonSendNode(List.of()), sender, receiver);
+
+ assertTrue(sender.getWorkerIdToMailboxesMap().isEmpty());
+ for (int workerId = 0; workerId < 2; workerId++) {
+ MailboxInfos mailboxInfos =
receiver.getWorkerIdToMailboxesMap().get(workerId).get(SENDER_STAGE);
+ assertNotNull(mailboxInfos, "Missing entry for worker: " + workerId);
+ assertTrue(mailboxInfos.getMailboxInfos().isEmpty());
+ }
}
/// A SINGLETON local exchange with parallelism (more receivers than
senders) does not assert co-location either: it
@@ -193,6 +250,21 @@ public class MailboxAssignmentVisitorTest extends
QueryEnvironmentTestBase {
assertTrue(receiver.getWorkerIdToMailboxesMap().isEmpty());
}
+ /// A pre-partitioned hash exchange whose sender stage has zero workers (all
its leaf segments were pruned) must not
+ /// be wired as a direct exchange: with the receiver stage also empty (as
when every branch of a UNION ALL is fully
+ /// pruned), the sender and receiver counts trivially "match" and computing
the fan-out parallelism would divide by
+ /// zero. It must fall back to the regular wiring, which is a no-op for
empty stages.
+ @Test
+ public void testPrePartitionedExchangeWithZeroWorkersFallsBackToShuffle() {
+ DispatchablePlanMetadata sender = metadata(Map.of());
+ sender.setPrePartitioned(true);
+ DispatchablePlanMetadata receiver = metadata(Map.of());
+ process(hashSendNode(), sender, receiver);
+
+ assertTrue(sender.getWorkerIdToMailboxesMap().isEmpty());
+ assertTrue(receiver.getWorkerIdToMailboxesMap().isEmpty());
+ }
+
private static QueryServerInstance server(String id) {
return new QueryServerInstance(id, "host_" + id, 1, 1);
}
diff --git a/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
b/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
index 2f78a35ef93..364a54fda9d 100644
--- a/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
+++ b/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
@@ -143,13 +143,13 @@
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[PERCENTILE($1,
50)], aggType=[LEAF])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1, 2]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col2=[$0], sum_of_runs=[$1], $f2=[50])",
"\n PinotLogicalAggregate(group=[{0}],
agg#0=[PERCENTILE($1, 50)], aggType=[DIRECT])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n LogicalProject(col2=[$1], col3=[$2], $f2=[50])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1, 2]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col2=[$0], sum_of_runs=[$1], $f2=[50])",
"\n PinotLogicalAggregate(group=[{0}],
agg#0=[PERCENTILE($1, 50)], aggType=[DIRECT])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
diff --git
a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
index 5f6c4b84e9d..fbb428bad31 100644
--- a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
@@ -737,42 +737,42 @@
]
},
{
- "description": "set op (UNION ALL) without the colocation hint
shuffles (hash redistributes) its inputs to all set-op-stage workers",
+ "description": "set op (UNION ALL) uses a local (SINGLETON) exchange
on every input: it only concatenates, so no redistribution is needed and each
sender is wired 1-to-1 to the union worker on its own server",
"sql": "EXPLAIN IMPLEMENTATION PLAN FOR SELECT col3 FROM a UNION ALL
SELECT col3 FROM a",
"output": [
"[0]@localhost:3|[0] MAIL_RECEIVE(BROADCAST_DISTRIBUTED)",
"\n├── [1]@localhost:2|[1]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@localhost:3|[0]} (Subtree Omitted)",
"\n└── [1]@localhost:1|[0]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@localhost:3|[0]}",
"\n └── [1]@localhost:1|[0] UNION_ALL",
- "\n └── [1]@localhost:1|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├── [2]@localhost:2|[1]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n └── [2]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]}",
+ "\n └── [1]@localhost:1|[0] MAIL_RECEIVE(SINGLETON)",
+ "\n ├── [2]@localhost:2|[1]
MAIL_SEND(SINGLETON)->{[1]@localhost:2|[1]} (Subtree Omitted)",
+ "\n └── [2]@localhost:1|[0]
MAIL_SEND(SINGLETON)->{[1]@localhost:1|[0]}",
"\n └── [2]@localhost:1|[0] PROJECT",
"\n └── [2]@localhost:1|[0] TABLE SCAN (a) null",
- "\n └── [1]@localhost:1|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├── [3]@localhost:2|[1]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n └── [3]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]}",
+ "\n └── [1]@localhost:1|[0] MAIL_RECEIVE(SINGLETON)",
+ "\n ├── [3]@localhost:2|[1]
MAIL_SEND(SINGLETON)->{[1]@localhost:2|[1]} (Subtree Omitted)",
+ "\n └── [3]@localhost:1|[0]
MAIL_SEND(SINGLETON)->{[1]@localhost:1|[0]}",
"\n └── [3]@localhost:1|[0] PROJECT",
"\n └── [3]@localhost:1|[0] TABLE SCAN (a) null",
"\n"
]
},
{
- "description": "set op (UNION ALL) with the
is_colocated_by_set_op_keys hint uses a pre-partitioned (direct, 1-to-1)
exchange on every input to avoid the shuffle",
- "sql": "EXPLAIN IMPLEMENTATION PLAN FOR SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='true') */ col3 FROM a UNION ALL
SELECT col3 FROM a",
+ "description": "set op (UNION ALL) with
is_colocated_by_set_op_keys='false' opts out of the local exchange and shuffles
(hash redistributes) its inputs to all set-op-stage workers",
+ "sql": "EXPLAIN IMPLEMENTATION PLAN FOR SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='false') */ col3 FROM a UNION ALL
SELECT col3 FROM a",
"output": [
"[0]@localhost:3|[0] MAIL_RECEIVE(BROADCAST_DISTRIBUTED)",
"\n├── [1]@localhost:2|[1]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@localhost:3|[0]} (Subtree Omitted)",
"\n└── [1]@localhost:1|[0]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@localhost:3|[0]}",
"\n └── [1]@localhost:1|[0] UNION_ALL",
"\n └── [1]@localhost:1|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├── [2]@localhost:2|[1]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n └── [2]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[1]@localhost:1|[0]}",
+ "\n ├── [2]@localhost:2|[1]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
+ "\n └── [2]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]}",
"\n └── [2]@localhost:1|[0] PROJECT",
"\n └── [2]@localhost:1|[0] TABLE SCAN (a) null",
"\n └── [1]@localhost:1|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├── [3]@localhost:2|[1]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n └── [3]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)[PARTITIONED]->{[1]@localhost:1|[0]}",
+ "\n ├── [3]@localhost:2|[1]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
+ "\n └── [3]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]}",
"\n └── [3]@localhost:1|[0] PROJECT",
"\n └── [3]@localhost:1|[0] TABLE SCAN (a) null",
"\n"
diff --git a/pinot-query-planner/src/test/resources/queries/SetOpPlans.json
b/pinot-query-planner/src/test/resources/queries/SetOpPlans.json
index 1aa229eb9af..9396598c9ad 100644
--- a/pinot-query-planner/src/test/resources/queries/SetOpPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/SetOpPlans.json
@@ -7,10 +7,10 @@
"output": [
"Execution Plan",
"\nLogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col1=[$0], col2=[$1])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col1=[$0], col2=[$1])",
"\n PinotLogicalTableScan(table=[[default, b]])",
"\n"
@@ -22,13 +22,13 @@
"output": [
"Execution Plan",
"\nLogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col1=[$0], col2=[$1])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col1=[$0], col2=[$1])",
"\n PinotLogicalTableScan(table=[[default, b]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col1=[$0], col2=[$1])",
"\n PinotLogicalTableScan(table=[[default, c]])",
"\n"
@@ -43,22 +43,22 @@
"\n PinotLogicalExchange(distribution=[hash[0, 1]])",
"\n PinotLogicalAggregate(group=[{0, 1}], aggType=[LEAF])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0, 1}], aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0, 1]])",
"\n PinotLogicalAggregate(group=[{0, 1}],
aggType=[LEAF])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0,
1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0, 1}],
aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0,
1]])",
"\n PinotLogicalAggregate(group=[{0, 1}],
aggType=[LEAF])",
"\n PinotLogicalTableScan(table=[[default,
a]])",
- "\n PinotLogicalExchange(distribution=[hash[0,
1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0, 1}],
aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0,
1]])",
"\n PinotLogicalAggregate(group=[{0, 1}],
aggType=[LEAF])",
"\n PinotLogicalTableScan(table=[[default,
b]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0, 1}], aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0, 1]])",
"\n PinotLogicalAggregate(group=[{0, 1}],
aggType=[LEAF])",
@@ -95,12 +95,12 @@
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[LEAF])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{1}],
agg#0=[$SUM0($2)], aggType=[LEAF])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{1}],
agg#0=[$SUM0($2)], aggType=[LEAF])",
@@ -117,12 +117,12 @@
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[LEAF])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[COUNT($1)],
aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{1}], agg#0=[COUNT()],
aggType=[LEAF])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[COUNT($1)],
aggType=[FINAL])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{1}], agg#0=[COUNT()],
aggType=[LEAF])",
@@ -138,12 +138,12 @@
"\nPinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[DIRECT])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[DIRECT])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n LogicalProject(col2=[$1], col3=[$2])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[DIRECT])",
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n LogicalProject(col2=[$1], col3=[$2])",
@@ -160,10 +160,10 @@
"\n PinotLogicalExchange(distribution=[hash[0]])",
"\n PinotLogicalAggregate(group=[{0}], agg#0=[$SUM0($1)],
aggType=[LEAF])",
"\n LogicalUnion(all=[true])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col2=[$1], col3=[$2])",
"\n PinotLogicalTableScan(table=[[default, a]])",
- "\n PinotLogicalExchange(distribution=[hash[0, 1]])",
+ "\n PinotLogicalExchange(distribution=[single])",
"\n LogicalProject(col2=[$1], col3=[$2])",
"\n PinotLogicalTableScan(table=[[default, b]])",
"\n"
diff --git a/pinot-query-runtime/src/test/resources/queries/QueryHints.json
b/pinot-query-runtime/src/test/resources/queries/QueryHints.json
index 913b24df0ab..05cd92da01e 100644
--- a/pinot-query-runtime/src/test/resources/queries/QueryHints.json
+++ b/pinot-query-runtime/src/test/resources/queries/QueryHints.json
@@ -167,6 +167,10 @@
"description": "INTERSECT on the partition column num with
is_colocated_by_set_op_keys hint forces a pre-partitioned (direct) exchange;
tbl1 and tbl2 are both partitioned by num so results stay correct",
"sql": "SELECT /*+ setOpOptions(is_colocated_by_set_op_keys='true') */
{tbl1}.num FROM {tbl1} INTERSECT SELECT {tbl2}.num FROM {tbl2}"
},
+ {
+ "description": "GROUP BY above a colocated INTERSECT: the hint asserts
equal rows already share a worker, so the set op's output is claimed hash
distributed on all columns and the aggregate above skips its shuffle too -- it
must still see complete groups",
+ "sql": "SELECT t.num, COUNT(*) FROM (SELECT /*+
setOpOptions(is_colocated_by_set_op_keys='true') */ {tbl1}.num AS num FROM
{tbl1} INTERSECT SELECT {tbl2}.num AS num FROM {tbl2}) AS t GROUP BY t.num"
+ },
{
"description": "EXCEPT on the partition column num with
is_colocated_by_set_op_keys hint forces a pre-partitioned (direct) exchange;
tbl1 and tbl2 are both partitioned by num so results stay correct",
"sql": "SELECT /*+ setOpOptions(is_colocated_by_set_op_keys='true') */
{tbl1}.num FROM {tbl1} EXCEPT SELECT {tbl2}.num FROM {tbl2}"
@@ -191,6 +195,26 @@
{
"description": "Chained colocated operations stay correct: a UNION ALL
of two colocated JOINs on num, set-op hint on the outer wrap and join hint per
branch (the no-shuffle plan shape is asserted in QueryCompilationTest)",
"sql": "SELECT /*+ setOpOptions(is_colocated_by_set_op_keys='true') */
* FROM (SELECT /*+ joinOptions(is_colocated_by_join_keys='true') */ {tbl1}.num
FROM {tbl1} /*+ tableOptions(partition_function='hashcode',
partition_key='num', partition_size='4') */ JOIN {tbl2} /*+
tableOptions(partition_function='hashcode', partition_key='num',
partition_size='4') */ ON {tbl1}.num = {tbl2}.num UNION ALL SELECT /*+
joinOptions(is_colocated_by_join_keys='true') */ {tbl1}.num FROM {tbl1} /*+ ta
[...]
+ },
+ {
+ "description": "UNION ALL without any hint uses local (SINGLETON)
exchanges; UNION ALL only concatenates so a direct exchange is always correct",
+ "sql": "SELECT {tbl1}.num FROM {tbl1} UNION ALL SELECT {tbl2}.num FROM
{tbl2}"
+ },
+ {
+ "description": "UNION ALL of heterogeneous non-partition columns
without any hint: the local exchange is correct under any row-to-worker
mapping",
+ "sql": "SELECT {tbl1}.name FROM {tbl1} UNION ALL SELECT {tbl2}.val
FROM {tbl2}"
+ },
+ {
+ "description": "Distinct UNION deduplicates correctly above the local
UNION ALL exchange: the union's output claims no distribution, so the dedup
aggregate shuffles for correct results",
+ "sql": "SELECT {tbl1}.name FROM {tbl1} UNION SELECT {tbl2}.val FROM
{tbl2}"
+ },
+ {
+ "description": "GROUP BY above a hint-free UNION ALL deduplicates
correctly: the union's output claims no distribution, so the aggregate shuffles
for correct results",
+ "sql": "SELECT t.name, COUNT(*) FROM (SELECT {tbl1}.name AS name FROM
{tbl1} UNION ALL SELECT {tbl2}.val AS name FROM {tbl2}) AS t GROUP BY t.name"
+ },
+ {
+ "description": "is_partitioned_by_group_by_keys above a hint-free
self-UNION ALL: both branches route identically, so the assertion holds on the
physical data and the direct (un-shuffled) aggregate sees complete groups",
+ "sql": "SELECT /*+ aggOptions(is_partitioned_by_group_by_keys='true')
*/ t.num, COUNT(*) FROM (SELECT {tbl1}.num AS num FROM {tbl1} /*+
tableOptions(partition_function='hashcode', partition_key='num',
partition_size='4') */ UNION ALL SELECT {tbl1}.num AS num FROM {tbl1} /*+
tableOptions(partition_function='hashcode', partition_key='num',
partition_size='4') */) AS t GROUP BY t.num"
}
]
},
@@ -272,6 +296,10 @@
{
"description": "Forcing is_colocated_by_set_op_keys='true' when the
inputs have mismatched partition counts (tbl1=2, tbl2=4): the planner cannot
form a direct exchange, so it still shuffles and results stay correct",
"sql": "SELECT /*+ setOpOptions(is_colocated_by_set_op_keys='true') */
{tbl1}.num FROM {tbl1} /*+ tableOptions(partition_function='hashcode',
partition_key='num', partition_size='2') */ INTERSECT SELECT {tbl2}.num FROM
{tbl2} /*+ tableOptions(partition_function='hashcode', partition_key='num',
partition_size='4') */"
+ },
+ {
+ "description": "Hint-free UNION ALL over inputs with mismatched
partition counts (tbl1=2, tbl2=4): the branches cannot share one worker
assignment, so the local exchange degrades to an arbitrary fan-out and results
stay correct",
+ "sql": "SELECT {tbl1}.num FROM {tbl1} /*+
tableOptions(partition_function='hashcode', partition_key='num',
partition_size='2') */ UNION ALL SELECT {tbl2}.num FROM {tbl2} /*+
tableOptions(partition_function='hashcode', partition_key='num',
partition_size='4') */"
}
]
},
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]