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 beb891d2282 Keep partitioned workers for window and WITHIN GROUP sorts
over hash exchanges (#19954)
beb891d2282 is described below
commit beb891d2282a3ceea4fd749166c3bbce0f4339ab
Author: Yash Mayya <[email protected]>
AuthorDate: Wed Oct 21 11:48:30 2026 -0700
Keep partitioned workers for window and WITHIN GROUP sorts over hash
exchanges (#19954)
---
.../planner/physical/DispatchablePlanVisitor.java | 21 ++++-
.../apache/pinot/query/QueryCompilationTest.java | 91 +++++++++++++++++++++
.../physical/DispatchablePlanVisitorTest.java | 93 ++++++++++++++++++++++
.../resources/queries/ExplainPhysicalPlans.json | 18 +++++
4 files changed, 220 insertions(+), 3 deletions(-)
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitor.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitor.java
index 5cd307bbebb..0594a689535 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitor.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitor.java
@@ -22,6 +22,7 @@ import com.google.common.base.Preconditions;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
+import org.apache.calcite.rel.RelDistribution;
import org.apache.calcite.rel.core.JoinRelType;
import org.apache.pinot.calcite.rel.hint.PinotHintOptions;
import org.apache.pinot.common.config.provider.TableCache;
@@ -152,12 +153,26 @@ public class DispatchablePlanVisitor implements
PlanNodeVisitor<Void, Dispatchab
public Void visitSort(SortNode node, DispatchablePlanContext context) {
node.getInputs().get(0).visit(this, context);
DispatchablePlanMetadata dispatchablePlanMetadata =
getOrCreateDispatchablePlanMetadata(node, context);
- // Final sort (receives from sort exchange) needs singleton worker
- boolean isFinalSort = node.getInputs().get(0) instanceof
MailboxReceiveNode;
- dispatchablePlanMetadata.setRequireSingleton(isFinalSort);
+ dispatchablePlanMetadata.setRequireSingleton(isFinalSort(node));
return null;
}
+ /// A final sort orders (or trims with fetch/offset) every row its stage
receives, so it needs a singleton worker,
+ /// which also rotates the hotspot server across queries. A Sort without
fetch/offset over a hash-partitioned exchange
+ /// orders each partition only - it feeds a `PARTITION BY ... ORDER BY`
window or a `WITHIN GROUP` aggregate - so it
+ /// keeps the partitioned worker assignment.
+ private static boolean isFinalSort(SortNode node) {
+ if (!(node.getInputs().get(0) instanceof MailboxReceiveNode)) {
+ return false;
+ }
+ if (node.getFetch() >= 0 || node.getOffset() >= 0) {
+ return true;
+ }
+ MailboxReceiveNode receiveNode = (MailboxReceiveNode)
node.getInputs().get(0);
+ return receiveNode.getDistributionType() !=
RelDistribution.Type.HASH_DISTRIBUTED
+ || receiveNode.getKeys().isEmpty();
+ }
+
@Override
public Void visitTableScan(TableScanNode node, DispatchablePlanContext
context) {
DispatchablePlanMetadata dispatchablePlanMetadata =
getOrCreateDispatchablePlanMetadata(node, context);
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 c91508d5d41..63eec96d1dc 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
@@ -45,6 +45,7 @@ import
org.apache.pinot.query.planner.plannode.MailboxSendNode;
import org.apache.pinot.query.planner.plannode.PlanNode;
import org.apache.pinot.query.planner.plannode.ProjectNode;
import org.apache.pinot.query.planner.plannode.SetOpNode;
+import org.apache.pinot.query.planner.plannode.SortNode;
import org.apache.pinot.query.planner.plannode.WindowNode;
import org.apache.pinot.query.routing.QueryServerInstance;
import org.apache.pinot.spi.exception.QueryErrorCode;
@@ -1189,6 +1190,96 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
return null;
}
+ /// A Sort without fetch/offset over a hash-partitioned exchange orders each
partition only. It feeds a
+ /// `PARTITION BY ... ORDER BY` window or a `WITHIN GROUP` aggregate, so its
stage must keep the partitioned worker
+ /// assignment (one worker per server, times `stageParallelism`) instead of
the single worker of a final ORDER BY.
+ @Test(dataProvider = "partitionedSortQueries")
+ public void testSortOverPartitionedExchangeKeepsPartitionedWorkers(String
query, int expectedNumWorkers) {
+ Map<SortNode, DispatchablePlanFragment> sortStages =
findSortOverReceiveStages(_queryEnvironment.planQuery(query));
+ assertEquals(sortStages.size(), 1, "Expected one Sort over a mailbox
receive");
+ Map.Entry<SortNode, DispatchablePlanFragment> entry =
sortStages.entrySet().iterator().next();
+ assertTrue(isOverPartitionedReceive(entry.getKey()), "Expected the Sort to
read a hash-partitioned exchange");
+ DispatchablePlanFragment sortStage = entry.getValue();
+ assertEquals(sortStage.getServerInstanceToWorkerIdMap().size(), 2, "Sort
stage should run on every server");
+ assertEquals(sortStage.getWorkerMetadataList().size(), expectedNumWorkers);
+ }
+
+ @DataProvider(name = "partitionedSortQueries")
+ public Object[][] partitionedSortQueries() {
+ return new Object[][]{
+ {"SELECT col1, ROW_NUMBER() OVER (PARTITION BY col1 ORDER BY col3
DESC) FROM a", 2},
+ {"SELECT col1, SUM(col3) OVER (PARTITION BY col1 ORDER BY col3) FROM
a", 2},
+ {"SET stageParallelism=2; SELECT col1, ROW_NUMBER() OVER (PARTITION BY
col1 ORDER BY col3 DESC) FROM a", 4},
+ {"SELECT col1, LISTAGG(col2, ',') WITHIN GROUP (ORDER BY col3) FROM a
GROUP BY col1", 2},
+ {"SET stageParallelism=2; SELECT col1, LISTAGG(col2, ',') WITHIN GROUP
(ORDER BY col3) FROM a GROUP BY col1",
+ 4}
+ };
+ }
+
+ /// A Sort that trims (fetch/offset) or reads a keyless exchange establishes
a total order, so its stage must run on
+ /// a single worker even when `stageParallelism` asks for more.
+ @Test(dataProvider = "globalSortQueries")
+ public void testSortOverGlobalExchangeUsesSingletonWorker(String query) {
+ Map<SortNode, DispatchablePlanFragment> sortStages =
findSortOverReceiveStages(_queryEnvironment.planQuery(query));
+ assertEquals(sortStages.size(), 1, "Expected one Sort over a mailbox
receive");
+ Map.Entry<SortNode, DispatchablePlanFragment> entry =
sortStages.entrySet().iterator().next();
+ assertFalse(isOverPartitionedReceive(entry.getKey()), "Expected the Sort
to read a keyless exchange");
+ assertEquals(entry.getValue().getWorkerMetadataList().size(), 1, "Global
sort stage should use a single worker");
+ }
+
+ @DataProvider(name = "globalSortQueries")
+ public Object[][] globalSortQueries() {
+ return new Object[][]{
+ {"SET stageParallelism=2; SELECT * FROM a ORDER BY col1"},
+ {"SET stageParallelism=2; SELECT * FROM a ORDER BY col1 LIMIT 10"},
+ {"SET stageParallelism=2; SELECT col1, ROW_NUMBER() OVER (ORDER BY
col3) FROM a"},
+ {"SET stageParallelism=2; SELECT LISTAGG(col2, ',') WITHIN GROUP
(ORDER BY col3) FROM a"}
+ };
+ }
+
+ /// A partitioned window under a final ORDER BY: the window's Sort keeps the
partitioned workers, and the final Sort
+ /// still runs on a single worker.
+ @Test
+ public void testPartitionedWindowUnderFinalOrderBy() {
+ String query = "SET stageParallelism=2; SELECT col1, ROW_NUMBER() OVER
(PARTITION BY col1 ORDER BY col3) AS rn "
+ + "FROM a ORDER BY rn";
+ Map<SortNode, DispatchablePlanFragment> sortStages =
findSortOverReceiveStages(_queryEnvironment.planQuery(query));
+ assertEquals(sortStages.size(), 2, "Expected the window Sort and the final
Sort");
+ for (Map.Entry<SortNode, DispatchablePlanFragment> entry :
sortStages.entrySet()) {
+ int expectedNumWorkers = isOverPartitionedReceive(entry.getKey()) ? 4 :
1;
+ assertEquals(entry.getValue().getWorkerMetadataList().size(),
expectedNumWorkers);
+ }
+ }
+
+ /// Maps each Sort that reads straight from a mailbox to the stage that runs
it.
+ private static Map<SortNode, DispatchablePlanFragment>
findSortOverReceiveStages(
+ DispatchableSubPlan dispatchableSubPlan) {
+ Map<SortNode, DispatchablePlanFragment> sortStages = new HashMap<>();
+ for (DispatchablePlanFragment fragment :
dispatchableSubPlan.getQueryStages()) {
+ List<SortNode> sortNodes = new ArrayList<>();
+ collectSortsOverReceive(fragment.getPlanFragment().getFragmentRoot(),
sortNodes);
+ for (SortNode sortNode : sortNodes) {
+ sortStages.put(sortNode, fragment);
+ }
+ }
+ return sortStages;
+ }
+
+ private static void collectSortsOverReceive(PlanNode node, List<SortNode>
sortNodes) {
+ if (node instanceof SortNode && node.getInputs().get(0) instanceof
MailboxReceiveNode) {
+ sortNodes.add((SortNode) node);
+ }
+ for (PlanNode input : node.getInputs()) {
+ collectSortsOverReceive(input, sortNodes);
+ }
+ }
+
+ private static boolean isOverPartitionedReceive(SortNode sortNode) {
+ MailboxReceiveNode receiveNode = (MailboxReceiveNode)
sortNode.getInputs().get(0);
+ return receiveNode.getDistributionType() ==
RelDistribution.Type.HASH_DISTRIBUTED
+ && !receiveNode.getKeys().isEmpty();
+ }
+
/// Tests that FULL OUTER JOIN with only non-equi conditions uses a
singleton worker for the join stage, to ensure
/// correctness of unmatched row tracking. With multiple workers, the
broadcast right table would be on each worker
/// but each worker only sees a subset of left rows, leading to incorrect
unmatched-right-rows output.
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitorTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitorTest.java
new file mode 100644
index 00000000000..ca11e21a862
--- /dev/null
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/DispatchablePlanVisitorTest.java
@@ -0,0 +1,93 @@
+/**
+ * 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.query.planner.physical;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.calcite.rel.RelDistribution;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.calcite.runtime.PairList;
+import org.apache.pinot.calcite.rel.logical.PinotRelExchangeType;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.query.QueryEnvironment;
+import org.apache.pinot.query.context.PlannerContext;
+import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
+import org.apache.pinot.query.planner.plannode.MailboxSendNode;
+import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.query.planner.plannode.SortNode;
+import org.apache.pinot.query.planner.plannode.ValueNode;
+import org.mockito.Mockito;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+
+
+public class DispatchablePlanVisitorTest {
+ private static final int SORT_STAGE = 1;
+ private static final int SENDER_STAGE = 2;
+ private static final DataSchema DATA_SCHEMA =
+ new DataSchema(new String[]{"col"}, new
ColumnDataType[]{ColumnDataType.INT});
+ private static final List<RelFieldCollation> COLLATIONS = List.of(new
RelFieldCollation(0));
+
+ /// Only a Sort over a hash-partitioned exchange that keeps every row orders
each partition independently. Every
+ /// other Sort over a mailbox establishes or trims a total order and needs a
singleton worker.
+ @Test(dataProvider = "sortsOverReceive")
+ public void testSortOverReceiveRequiresSingleton(RelDistribution.Type
distributionType, List<Integer> keys,
+ int fetch, int offset, boolean expectedSingleton) {
+ MailboxSendNode sender = new MailboxSendNode(SENDER_STAGE, DATA_SCHEMA,
+ List.of(new ValueNode(SENDER_STAGE, DATA_SCHEMA,
PlanNode.NodeHint.EMPTY, List.of(), List.of())), SORT_STAGE,
+ PinotRelExchangeType.STREAMING, distributionType, keys, false,
COLLATIONS, false, "absHashCode");
+ MailboxReceiveNode receive = new MailboxReceiveNode(SORT_STAGE,
DATA_SCHEMA, SENDER_STAGE,
+ PinotRelExchangeType.STREAMING, distributionType, keys, COLLATIONS,
false, false, sender);
+ SortNode sort =
+ new SortNode(SORT_STAGE, DATA_SCHEMA, PlanNode.NodeHint.EMPTY,
List.of(receive), COLLATIONS, fetch, offset);
+
+ PlannerContext plannerContext = Mockito.mock(PlannerContext.class);
+ Mockito.when(plannerContext.getOptions()).thenReturn(Map.of());
+
Mockito.when(plannerContext.getEnvConfig()).thenReturn(Mockito.mock(QueryEnvironment.Config.class));
+ DispatchablePlanContext context =
+ new DispatchablePlanContext(null, 0, plannerContext, PairList.of(0,
"col"), Set.of());
+ sort.visit(new DispatchablePlanVisitor(null, null), context);
+
+
assertEquals(context.getDispatchablePlanMetadataMap().get(SORT_STAGE).isRequiresSingletonInstance(),
+ expectedSingleton);
+ }
+
+ @DataProvider(name = "sortsOverReceive")
+ public Object[][] sortsOverReceive() {
+ return new Object[][]{
+ // Window / WITHIN GROUP input: partitioned, keeps every row
+ {RelDistribution.Type.HASH_DISTRIBUTED, List.of(0), -1, -1, false},
+ // Trimming a partitioned exchange still needs every row in one place
+ {RelDistribution.Type.HASH_DISTRIBUTED, List.of(0), 10, -1, true},
+ {RelDistribution.Type.HASH_DISTRIBUTED, List.of(0), -1, 5, true},
+ {RelDistribution.Type.HASH_DISTRIBUTED, List.of(0), 10, 0, true},
+ // Final ORDER BY: keyless exchange
+ {RelDistribution.Type.HASH_DISTRIBUTED, List.of(), -1, -1, true},
+ {RelDistribution.Type.HASH_DISTRIBUTED, List.of(), 10, 0, true},
+ // Exchanges that do not partition by key
+ {RelDistribution.Type.RANDOM_DISTRIBUTED, List.of(), -1, -1, true},
+ {RelDistribution.Type.BROADCAST_DISTRIBUTED, List.of(), -1, -1, true},
+ {RelDistribution.Type.SINGLETON, List.of(), -1, -1, true}
+ };
+ }
+}
diff --git
a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
index fbb428bad31..691815bc475 100644
--- a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
@@ -736,6 +736,24 @@
"\n"
]
},
+ {
+ "description": "window function (PARTITION BY ... ORDER BY) sorts each
partition on every window-stage worker; the Sort over the hash exchange is not
a final sort, so the stage is not collapsed to a singleton",
+ "sql": "EXPLAIN IMPLEMENTATION PLAN FOR SELECT col1, ROW_NUMBER() OVER
(PARTITION BY col1 ORDER BY col3 DESC) 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] PROJECT",
+ "\n └── [1]@localhost:1|[0] WINDOW",
+ "\n └── [1]@localhost:1|[0] SORT",
+ "\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 └── [2]@localhost:1|[0] PROJECT",
+ "\n └── [2]@localhost:1|[0] TABLE SCAN
(a) null",
+ "\n"
+ ]
+ },
{
"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",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]