This is an automated email from the ASF dual-hosted git repository.
Jackie-Jiang 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 19dec509431 Reject the join distribution types that answer the join
wrong (#19862)
19dec509431 is described below
commit 19dec509431436925b33d7b981fe489c02cb7f17
Author: RAGHVENDRA KUMAR YADAV <[email protected]>
AuthorDate: Mon Oct 26 16:24:32 2026 -0700
Reject the join distribution types that answer the join wrong (#19862)
---
.../rel/rules/PinotJoinExchangeNodeInsertRule.java | 134 ++++++++++++++++++-
.../rules/PinotJoinExchangeNodeInsertRuleTest.java | 143 +++++++++++++++++++++
.../test/resources/queries/NonEquiOuterJoins.json | 58 +++++++++
.../src/test/resources/queries/QueryHints.json | 81 ++++++++++++
4 files changed, 412 insertions(+), 4 deletions(-)
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRule.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRule.java
index 86ff8603d90..c9ff53023a0 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRule.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRule.java
@@ -18,6 +18,7 @@
*/
package org.apache.pinot.calcite.rel.rules;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.util.List;
import java.util.Map;
@@ -26,12 +27,17 @@ import org.apache.calcite.plan.RelOptRule;
import org.apache.calcite.plan.RelOptRuleCall;
import org.apache.calcite.rel.RelDistributions;
import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Calc;
+import org.apache.calcite.rel.core.Filter;
import org.apache.calcite.rel.core.Join;
import org.apache.calcite.rel.core.JoinInfo;
import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.rel.core.Project;
+import org.apache.calcite.rel.core.TableScan;
import org.apache.calcite.rel.logical.LogicalAsofJoin;
import org.apache.calcite.tools.RelBuilderFactory;
import org.apache.pinot.calcite.rel.hint.PinotHintOptions;
+import org.apache.pinot.calcite.rel.hint.PinotHintStrategyTable;
import org.apache.pinot.calcite.rel.logical.PinotLogicalExchange;
@@ -88,14 +94,15 @@ public class PinotJoinExchangeNodeInsertRule extends
RelOptRule {
// Hash join
// Force pre-partitioned exchange when colocated join hint is provided
Boolean prePartitioned =
PinotHintOptions.JoinHintOptions.isColocatedByJoinKeys(join);
- // TODO: Validate if the configured distribution types are valid
if (leftDistributionType == null) {
leftDistributionType = inferLeftDistributionType(joinInfo,
join.getJoinType());
}
- newLeft = createExchangeForHashJoin(leftDistributionType,
joinInfo.leftKeys, left, prePartitioned);
if (rightDistributionType == null) {
rightDistributionType = inferRightDistributionType(joinInfo,
join.getJoinType());
}
+ validateDistributionTypes(leftDistributionType, rightDistributionType,
isReplicatedTableScan(left),
+ isReplicatedTableScan(right), joinInfo, join.getJoinType());
+ newLeft = createExchangeForHashJoin(leftDistributionType,
joinInfo.leftKeys, left, prePartitioned);
newRight = createExchangeForHashJoin(rightDistributionType,
joinInfo.rightKeys, right, prePartitioned);
}
@@ -127,11 +134,129 @@ public class PinotJoinExchangeNodeInsertRule extends
RelOptRule {
}
}
+ /// Rejects the pairs of distribution types that cannot produce the join's
rows, which are otherwise silently
+ /// answered wrong. A pair is workable only if every pair of rows that could
match meets on one worker, and only if
+ /// each row is emitted by one worker.
+ ///
+ /// Broadcasting a side gives every worker all of its rows, so it stands in
for any partitioning of the other side. A
+ /// LOCAL scan of a table hinted `is_replicated='true'` does the same, since
its leaf hands every worker the whole
+ /// table. Two such sides give every worker all the rows of both, and each
joined row is then emitted once per worker,
+ /// whether the sides are broadcast, replicated tables read locally, or one
of each. Broadcasting neither leaves both
+ /// sides partitioned, and they only line up when both are partitioned the
same way on the join keys, which also
+ /// requires the join to have keys to partition on. LOCAL on both sides is
accepted on the user's word that the two
+ /// inputs are co-partitioned, like the colocated join hint: nothing here
can check it.
+ ///
+ /// A join that decides a left row's output from the right rows it saw --
LEFT emits the left rows that matched
+ /// nothing, SEMI and ANTI emit a left row on the presence or absence of a
match, and ASOF / LEFT_ASOF pick the
+ /// closest match -- can only decide on a worker that holds every right row.
So the broadcast side must be the right
+ /// one; RIGHT is the mirror image. SEMI is the exception when the right
side is hashed on the join keys: every right
+ /// row sharing a left row's key is on the one worker that key hashes to, so
only that worker can emit the left row,
+ /// and it does exactly when a match exists. ANTI and LEFT get no such
exception, since every other worker would emit
+ /// the left row as unmatched. A FULL join emits the unmatched rows of both
sides, so with keys it cannot use a
+ /// broadcast side at all, and without keys it runs on a single worker,
where only an exchange that delivers every
+ /// row there (RANDOM or BROADCAST) is workable.
+ @VisibleForTesting
+ static void validateDistributionTypes(PinotHintOptions.DistributionType
leftDistributionType,
+ PinotHintOptions.DistributionType rightDistributionType, boolean
leftReplicated, boolean rightReplicated,
+ JoinInfo joinInfo, JoinRelType joinType) {
+ if (joinType == JoinRelType.FULL && joinInfo.leftKeys.isEmpty()) {
+ // DispatchablePlanVisitor gives a FULL join without equi keys a single
worker, so every row reaches it once.
+ Preconditions.checkArgument(isSingletonFeed(leftDistributionType) &&
isSingletonFeed(rightDistributionType),
+ "FULL join without equi keys runs on a single worker, so each side
must be distributed RANDOM or BROADCAST "
+ + "to reach it, got left: %s, right: %s", leftDistributionType,
rightDistributionType);
+ return;
+ }
+ boolean leftWhole = holdsWholeSide(leftDistributionType, leftReplicated);
+ boolean rightWhole = holdsWholeSide(rightDistributionType,
rightReplicated);
+ Preconditions.checkArgument(!(leftWhole && rightWhole),
+ "Cannot give every worker the whole of both sides of the join
(broadcast, or a LOCAL scan of a table hinted "
+ + "is_replicated='true'): every worker would emit each joined row,
repeating the join result once per "
+ + "worker. At most one side can be whole, got left: %s, right:
%s", leftDistributionType,
+ rightDistributionType);
+ if (leftWhole || rightWhole) {
+ switch (joinType) {
+ case SEMI:
+ if (rightDistributionType == PinotHintOptions.DistributionType.HASH
&& !joinInfo.leftKeys.isEmpty()) {
+ // Every right row with a given key is on one worker, so a left
row can only be emitted there.
+ break;
+ }
+ Preconditions.checkArgument(rightWhole,
+ "SEMI join requires every right row on each worker (broadcast
the right side, read a replicated right "
+ + "table locally, or hash the right side on the join keys):
a worker decides each left row from "
+ + "the right rows it holds, got left: %s, right: %s",
leftDistributionType, rightDistributionType);
+ break;
+ case LEFT:
+ case ANTI:
+ case ASOF:
+ case LEFT_ASOF:
+ case LEFT_MARK:
+ Preconditions.checkArgument(rightWhole,
+ "%s join requires every right row on each worker (broadcast the
right side, or read a replicated right "
+ + "table locally): a worker decides each left row from the
right rows it holds, got left: %s, "
+ + "right: %s", joinType, leftDistributionType,
rightDistributionType);
+ break;
+ case RIGHT:
+ Preconditions.checkArgument(leftWhole,
+ "RIGHT join requires every left row on each worker (broadcast
the left side, or read a replicated left "
+ + "table locally): a worker can only tell that a right row
matched nothing if it holds every left "
+ + "row, got left: %s, right: %s", leftDistributionType,
rightDistributionType);
+ break;
+ case FULL:
+ throw new IllegalArgumentException(
+ "FULL join cannot broadcast a side: both sides emit the rows
that matched nothing, so neither side can "
+ + "be the partitioned one. Distribute both sides by hash on
the join keys.");
+ case INNER:
+ break;
+ default:
+ // A join type not listed above may be asymmetric, so reject it
rather than accept a whole side unchecked
+ throw new IllegalArgumentException("Unsupported join type for a
broadcast or replicated side: " + joinType);
+ }
+ return;
+ }
+ Preconditions.checkArgument(!joinInfo.leftKeys.isEmpty(),
+ "Join without equi keys requires one side to be broadcast: with both
sides partitioned, a pair of rows that "
+ + "could match may land on different workers, got left: %s, right:
%s", leftDistributionType,
+ rightDistributionType);
+ Preconditions.checkArgument(
+ leftDistributionType == rightDistributionType
+ && leftDistributionType !=
PinotHintOptions.DistributionType.RANDOM,
+ "Both sides of the join must be distributed the same way on the join
keys when neither side is broadcast, "
+ + "otherwise rows sharing a key land on different workers, got
left: %s, right: %s", leftDistributionType,
+ rightDistributionType);
+ }
+
+ /// Whether every worker of the join ends up with every row of this side.
+ private static boolean holdsWholeSide(PinotHintOptions.DistributionType
distributionType, boolean replicated) {
+ return distributionType == PinotHintOptions.DistributionType.BROADCAST
+ || (replicated && distributionType ==
PinotHintOptions.DistributionType.LOCAL);
+ }
+
+ /// Whether this exchange delivers every row of its side to a single
receiving worker.
+ private static boolean isSingletonFeed(PinotHintOptions.DistributionType
distributionType) {
+ return distributionType == PinotHintOptions.DistributionType.RANDOM
+ || distributionType == PinotHintOptions.DistributionType.BROADCAST;
+ }
+
+ /// Whether `input` is a scan of a table hinted `is_replicated='true'`,
reached only through projects, filters and
+ /// calcs.
+ /// Anything else in between (an exchange, an aggregate, another join) is
not assumed to keep the whole table.
+ private static boolean isReplicatedTableScan(RelNode input) {
+ RelNode node = PinotRuleUtils.unboxRel(input);
+ // A Sort is not unwrapped: with a LIMIT or OFFSET it no longer keeps the
whole table
+ while (node instanceof Project || node instanceof Filter || node
instanceof Calc) {
+ node = PinotRuleUtils.unboxRel(node.getInput(0));
+ }
+ return node instanceof TableScan && Boolean.parseBoolean(
+ PinotHintStrategyTable.getHintOption(((TableScan) node).getHints(),
PinotHintOptions.TABLE_HINT_OPTIONS,
+ PinotHintOptions.TableHintOptions.IS_REPLICATED));
+ }
+
/// For non-equi RIGHT JOINs (no equi keys), the default
RANDOM(left)+BROADCAST(right) is incorrect because each
/// worker independently tracks matched right rows via a local BitSet.
Workers that receive only a subset of left rows
/// will incorrectly emit right rows as unmatched. Invert to
BROADCAST(left)+RANDOM(right) so each worker has the
/// full left table and can correctly determine which of its local right
rows are unmatched.
- private static PinotHintOptions.DistributionType
inferLeftDistributionType(JoinInfo joinInfo,
+ @VisibleForTesting
+ static PinotHintOptions.DistributionType inferLeftDistributionType(JoinInfo
joinInfo,
JoinRelType joinType) {
if (!joinInfo.leftKeys.isEmpty()) {
return PinotHintOptions.DistributionType.HASH;
@@ -142,7 +267,8 @@ public class PinotJoinExchangeNodeInsertRule extends
RelOptRule {
return PinotHintOptions.DistributionType.RANDOM;
}
- private static PinotHintOptions.DistributionType
inferRightDistributionType(JoinInfo joinInfo,
+ @VisibleForTesting
+ static PinotHintOptions.DistributionType inferRightDistributionType(JoinInfo
joinInfo,
JoinRelType joinType) {
if (!joinInfo.rightKeys.isEmpty()) {
return PinotHintOptions.DistributionType.HASH;
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRuleTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRuleTest.java
new file mode 100644
index 00000000000..d83b3aa6745
--- /dev/null
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotJoinExchangeNodeInsertRuleTest.java
@@ -0,0 +1,143 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.calcite.rel.rules;
+
+import org.apache.calcite.rel.core.JoinInfo;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.util.ImmutableIntList;
+import org.apache.pinot.calcite.rel.hint.PinotHintOptions.DistributionType;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static
org.apache.pinot.calcite.rel.hint.PinotHintOptions.DistributionType.BROADCAST;
+import static
org.apache.pinot.calcite.rel.hint.PinotHintOptions.DistributionType.HASH;
+import static
org.apache.pinot.calcite.rel.hint.PinotHintOptions.DistributionType.LOCAL;
+import static
org.apache.pinot.calcite.rel.hint.PinotHintOptions.DistributionType.RANDOM;
+import static org.testng.Assert.assertThrows;
+
+
+/// Covers the distribution type pairs a join can and cannot be answered with,
including join types that SQL hints do
+/// not reach today (ANTI, ASOF and LEFT_ASOF are produced or hinted outside
the path the query tests exercise).
+public class PinotJoinExchangeNodeInsertRuleTest {
+ private static final JoinInfo EQUI = JoinInfo.of(ImmutableIntList.of(0),
ImmutableIntList.of(0));
+ private static final JoinInfo NON_EQUI = JoinInfo.of(ImmutableIntList.of(),
ImmutableIntList.of());
+
+ /// {join type, join info, left, right, left replicated, right replicated}
+ @DataProvider
+ public static Object[][] accepted() {
+ return new Object[][]{
+ {JoinRelType.INNER, EQUI, HASH, HASH, false, false},
+ {JoinRelType.INNER, EQUI, LOCAL, BROADCAST, false, false},
+ {JoinRelType.LEFT, EQUI, LOCAL, BROADCAST, false, false},
+ {JoinRelType.SEMI, EQUI, LOCAL, BROADCAST, false, false},
+ {JoinRelType.ANTI, EQUI, RANDOM, BROADCAST, false, false},
+ {JoinRelType.ASOF, EQUI, HASH, BROADCAST, false, false},
+ {JoinRelType.LEFT_ASOF, EQUI, LOCAL, BROADCAST, false, false},
+ {JoinRelType.LEFT_MARK, EQUI, LOCAL, BROADCAST, false, false},
+ {JoinRelType.RIGHT, EQUI, BROADCAST, LOCAL, false, false},
+ // A SEMI join emits a left row only on the worker its key hashes to,
which holds every right row with that key.
+ {JoinRelType.SEMI, EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.SEMI, EQUI, LOCAL, HASH, true, false},
+ // A LOCAL scan of a replicated table gives every worker the whole
table, like a broadcast.
+ {JoinRelType.INNER, NON_EQUI, LOCAL, LOCAL, false, true},
+ {JoinRelType.LEFT, NON_EQUI, LOCAL, LOCAL, false, true},
+ {JoinRelType.INNER, EQUI, HASH, LOCAL, false, true},
+ {JoinRelType.RIGHT, EQUI, LOCAL, HASH, true, false},
+ // A FULL join without equi keys runs on a single worker, which RANDOM
and BROADCAST both deliver every row to.
+ {JoinRelType.FULL, NON_EQUI, RANDOM, BROADCAST, false, false},
+ {JoinRelType.FULL, NON_EQUI, RANDOM, RANDOM, false, false},
+ {JoinRelType.FULL, NON_EQUI, BROADCAST, BROADCAST, false, false},
+ };
+ }
+
+ @Test(dataProvider = "accepted")
+ public void testAccepted(JoinRelType joinType, JoinInfo joinInfo,
DistributionType left, DistributionType right,
+ boolean leftReplicated, boolean rightReplicated) {
+ PinotJoinExchangeNodeInsertRule.validateDistributionTypes(left, right,
leftReplicated, rightReplicated, joinInfo,
+ joinType);
+ }
+
+ /// {join type, join info, left, right, left replicated, right replicated}
+ @DataProvider
+ public static Object[][] rejected() {
+ return new Object[][]{
+ // Each of these decides a left row from the right rows a worker
holds, so the left cannot be the whole side.
+ {JoinRelType.LEFT, EQUI, BROADCAST, LOCAL, false, false},
+ {JoinRelType.SEMI, EQUI, BROADCAST, LOCAL, false, false},
+ {JoinRelType.SEMI, EQUI, BROADCAST, RANDOM, false, false},
+ {JoinRelType.SEMI, NON_EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.SEMI, EQUI, LOCAL, LOCAL, true, false},
+ {JoinRelType.ANTI, EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.ANTI, EQUI, LOCAL, HASH, true, false},
+ {JoinRelType.LEFT, EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.ASOF, EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.LEFT_ASOF, EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.LEFT_MARK, EQUI, BROADCAST, HASH, false, false},
+ {JoinRelType.LEFT, EQUI, LOCAL, LOCAL, true, false},
+ {JoinRelType.RIGHT, EQUI, LOCAL, BROADCAST, false, false},
+ {JoinRelType.FULL, EQUI, LOCAL, BROADCAST, false, false},
+ // Two sides that each give every worker the whole side repeat the
join result once per worker.
+ {JoinRelType.INNER, EQUI, BROADCAST, BROADCAST, false, false},
+ {JoinRelType.INNER, NON_EQUI, LOCAL, LOCAL, true, true},
+ {JoinRelType.INNER, EQUI, LOCAL, LOCAL, true, true},
+ {JoinRelType.INNER, EQUI, LOCAL, BROADCAST, true, false},
+ {JoinRelType.INNER, NON_EQUI, BROADCAST, LOCAL, false, true},
+ {JoinRelType.LEFT, EQUI, LOCAL, LOCAL, true, true},
+ {JoinRelType.SEMI, EQUI, BROADCAST, LOCAL, false, true},
+ {JoinRelType.INNER, EQUI, RANDOM, RANDOM, false, false},
+ {JoinRelType.INNER, EQUI, HASH, RANDOM, false, false},
+ {JoinRelType.INNER, NON_EQUI, LOCAL, LOCAL, false, false},
+ {JoinRelType.FULL, NON_EQUI, LOCAL, LOCAL, false, false},
+ {JoinRelType.FULL, NON_EQUI, RANDOM, LOCAL, false, false},
+ };
+ }
+
+ /// {join type, join info}
+ @DataProvider
+ public static Object[][] joinTypes() {
+ JoinRelType[] joinTypes = {
+ JoinRelType.INNER, JoinRelType.LEFT, JoinRelType.RIGHT,
JoinRelType.FULL, JoinRelType.SEMI, JoinRelType.ANTI,
+ JoinRelType.ASOF, JoinRelType.LEFT_ASOF, JoinRelType.LEFT_MARK
+ };
+ Object[][] cases = new Object[joinTypes.length * 2][];
+ for (int i = 0; i < joinTypes.length; i++) {
+ cases[2 * i] = new Object[]{joinTypes[i], EQUI};
+ cases[2 * i + 1] = new Object[]{joinTypes[i], NON_EQUI};
+ }
+ return cases;
+ }
+
+ /// The distribution types picked when the query has no hint must always
pass validation, so a change to the
+ /// inferred defaults cannot start rejecting ordinary queries.
+ @Test(dataProvider = "joinTypes")
+ public void testInferredDefaultsAccepted(JoinRelType joinType, JoinInfo
joinInfo) {
+ PinotJoinExchangeNodeInsertRule.validateDistributionTypes(
+ PinotJoinExchangeNodeInsertRule.inferLeftDistributionType(joinInfo,
joinType),
+ PinotJoinExchangeNodeInsertRule.inferRightDistributionType(joinInfo,
joinType), false, false, joinInfo,
+ joinType);
+ }
+
+ @Test(dataProvider = "rejected")
+ public void testRejected(JoinRelType joinType, JoinInfo joinInfo,
DistributionType left, DistributionType right,
+ boolean leftReplicated, boolean rightReplicated) {
+ assertThrows(IllegalArgumentException.class,
+ () -> PinotJoinExchangeNodeInsertRule.validateDistributionTypes(left,
right, leftReplicated, rightReplicated,
+ joinInfo, joinType));
+ }
+}
diff --git
a/pinot-query-runtime/src/test/resources/queries/NonEquiOuterJoins.json
b/pinot-query-runtime/src/test/resources/queries/NonEquiOuterJoins.json
index 5716b3497c8..aef2f7873c7 100644
--- a/pinot-query-runtime/src/test/resources/queries/NonEquiOuterJoins.json
+++ b/pinot-query-runtime/src/test/resources/queries/NonEquiOuterJoins.json
@@ -58,6 +58,64 @@
[12, "d", null, null],
[null, null, 3, "x"]
]
+ },
+ {
+ "description": "FULL OUTER JOIN with only non-equi condition, random
left and broadcast right: one worker receives every row",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'random',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} FULL OUTER JOIN {tbl2} ON {tbl1}.num < {tbl2}.num",
+ "outputs": [
+ [5, "a", 7, "y"],
+ [5, "a", 10, "z"],
+ [5, "a", 11, "w"],
+ [6, "b", 7, "y"],
+ [6, "b", 10, "z"],
+ [6, "b", 11, "w"],
+ [8, "c", 10, "z"],
+ [8, "c", 11, "w"],
+ [12, "d", null, null],
+ [null, null, 3, "x"]
+ ],
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "FULL OUTER JOIN with only non-equi condition, random
left and random right: one worker receives every row",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'random',
right_distribution_type = 'random') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} FULL OUTER JOIN {tbl2} ON {tbl1}.num < {tbl2}.num",
+ "outputs": [
+ [5, "a", 7, "y"],
+ [5, "a", 10, "z"],
+ [5, "a", 11, "w"],
+ [6, "b", 7, "y"],
+ [6, "b", 10, "z"],
+ [6, "b", 11, "w"],
+ [8, "c", 10, "z"],
+ [8, "c", 11, "w"],
+ [12, "d", null, null],
+ [null, null, 3, "x"]
+ ],
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "FULL OUTER JOIN with only non-equi condition,
broadcast left and broadcast right: one worker receives every row",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'broadcast',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} FULL OUTER JOIN {tbl2} ON {tbl1}.num < {tbl2}.num",
+ "outputs": [
+ [5, "a", 7, "y"],
+ [5, "a", 10, "z"],
+ [5, "a", 11, "w"],
+ [6, "b", 7, "y"],
+ [6, "b", 10, "z"],
+ [6, "b", 11, "w"],
+ [8, "c", 10, "z"],
+ [8, "c", 11, "w"],
+ [12, "d", null, null],
+ [null, null, 3, "x"]
+ ],
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "FULL OUTER JOIN with only non-equi condition cannot
take a local side on its single worker",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} FULL OUTER JOIN {tbl2} ON {tbl1}.num < {tbl2}.num",
+ "outputs": [],
+ "expectedException": ".*FULL join without equi keys runs on a single
worker.*",
+ "ignoreV2Optimizer": true
}
]
}
diff --git a/pinot-query-runtime/src/test/resources/queries/QueryHints.json
b/pinot-query-runtime/src/test/resources/queries/QueryHints.json
index 05cd92da01e..1bbf8754e02 100644
--- a/pinot-query-runtime/src/test/resources/queries/QueryHints.json
+++ b/pinot-query-runtime/src/test/resources/queries/QueryHints.json
@@ -127,6 +127,63 @@
"description": "Colocated JOIN with partition column and group by
non-partitioned column with stage parallelism",
"sql": "SET stageParallelism=2; SELECT {tbl1}.name, SUM({tbl2}.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 GROUP BY {tbl1}.name"
},
+ {
+ "description": "Both sides distributed at random drops rows that share
a key but land on different workers",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'random',
right_distribution_type = 'random') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} JOIN {tbl2} ON {tbl1}.num = {tbl2}.num",
+ "expectedException": ".*must be distributed the same way on the join
keys.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "One side hashed and the other at random drops rows
that share a key",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'hash',
right_distribution_type = 'random') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} JOIN {tbl2} ON {tbl1}.num = {tbl2}.num",
+ "expectedException": ".*must be distributed the same way on the join
keys.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "Broadcasting both sides repeats the join result once
per worker",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'broadcast',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} JOIN {tbl2} ON {tbl1}.num = {tbl2}.num",
+ "expectedException": ".*Cannot give every worker the whole of both
sides of the join.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "Join without equi keys and no broadcast side cannot
bring matching rows together",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} JOIN {tbl2} ON {tbl1}.num > {tbl2}.num",
+ "expectedException": ".*requires one side to be broadcast.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "LEFT join broadcasting the left side emits left rows
as unmatched on workers holding part of the right side",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'broadcast',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} LEFT JOIN {tbl2} ON {tbl1}.num = {tbl2}.num",
+ "expectedException": ".*LEFT join requires every right row on each
worker.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "RIGHT join broadcasting the right side emits right
rows as unmatched on workers holding part of the left side",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} RIGHT JOIN {tbl2} ON {tbl1}.num = {tbl2}.num",
+ "expectedException": ".*RIGHT join requires every left row on each
worker.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "RIGHT join without equi keys broadcasting the right
side emits right rows as unmatched",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'random',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} RIGHT JOIN {tbl2} ON {tbl1}.num > {tbl2}.num",
+ "expectedException": ".*RIGHT join requires every left row on each
worker.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "SEMI join broadcasting the left side emits a left row
once per worker holding a match",
+ "sql": "SELECT /*+ joinOptions(join_strategy = 'hash',
left_distribution_type = 'broadcast', right_distribution_type = 'local') */
{tbl1}.num, {tbl1}.name FROM {tbl1} WHERE {tbl1}.num IN (SELECT {tbl2}.num FROM
{tbl2})",
+ "expectedException": ".*SEMI join requires every right row on each
worker.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "SEMI join broadcasting the left side and hashing the
right side on the join keys: only the worker a key hashes to holds its right
rows",
+ "sql": "SELECT /*+ joinOptions(join_strategy = 'hash',
left_distribution_type = 'broadcast', right_distribution_type = 'hash') */
{tbl1}.num, {tbl1}.name FROM {tbl1} WHERE {tbl1}.num IN (SELECT {tbl2}.num FROM
{tbl2})",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "LEFT join broadcasting the right side is the workable
direction",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} LEFT JOIN {tbl2} ON {tbl1}.num = {tbl2}.num"
+ },
{
"description": "Broadcast JOIN",
"sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} JOIN {tbl2} ON {tbl1}.num = {tbl2}.num"
@@ -721,6 +778,30 @@
"description": "Local replicated JOIN with partition hint and
parallelism",
"sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} /*+ tableOptions(partition_function='hashcode',
partition_key='num', partition_size='4', partition_parallelism='2') */ JOIN
{tbl2} /*+ tableOptions(is_replicated='true') */ ON {tbl1}.num = {tbl2}.num"
},
+ {
+ "description": "Local JOIN without equi keys against a replicated table:
every worker holds the whole right table",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} JOIN {tbl2} /*+ tableOptions(is_replicated='true') */ ON
{tbl1}.num > {tbl2}.num"
+ },
+ {
+ "description": "Local LEFT JOIN without equi keys against a replicated
table",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} LEFT JOIN {tbl2} /*+ tableOptions(is_replicated='true')
*/ ON {tbl1}.num > {tbl2}.num"
+ },
+ {
+ "description": "Hash on the left and local on a replicated right table",
+ "sql": "SELECT /*+ joinOptions(right_distribution_type = 'local') */
{tbl1}.num, {tbl1}.name, {tbl2}.num, {tbl2}.val FROM {tbl1} JOIN {tbl2} /*+
tableOptions(is_replicated='true') */ ON {tbl1}.num = {tbl2}.num"
+ },
+ {
+ "description": "Local JOIN between two replicated tables repeats the
join result once per worker",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'local') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} /*+ tableOptions(is_replicated='true') */ JOIN {tbl2}
/*+ tableOptions(is_replicated='true') */ ON {tbl1}.num > {tbl2}.num",
+ "expectedException": ".*Cannot give every worker the whole of both sides
of the join.*",
+ "ignoreV2Optimizer": true
+ },
+ {
+ "description": "Local scan of a replicated left table and a broadcast
right side repeats the join result once per worker",
+ "sql": "SELECT /*+ joinOptions(left_distribution_type = 'local',
right_distribution_type = 'broadcast') */ {tbl1}.num, {tbl1}.name, {tbl2}.num,
{tbl2}.val FROM {tbl1} /*+ tableOptions(is_replicated='true') */ JOIN {tbl2} ON
{tbl1}.num = {tbl2}.num",
+ "expectedException": ".*Cannot give every worker the whole of both sides
of the join.*",
+ "ignoreV2Optimizer": true
+ },
{
"description": "Window PARTITION BY the table's partition column with
is_partitioned_by_window_keys hint forces a pre-partitioned (direct) exchange;
tbl1 is partitioned by num so results stay correct",
"sql": "SELECT /*+ windowOptions(is_partitioned_by_window_keys='true')
*/ {tbl1}.num, SUM({tbl1}.num) OVER (PARTITION BY {tbl1}.num) FROM {tbl1}"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]