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 51496aebf94 Fix planning of lookup joins over an intermediate stage 
(#19874)
51496aebf94 is described below

commit 51496aebf94451e09cf58cc4e4b46863ccc12585
Author: Yash Mayya <[email protected]>
AuthorDate: Thu Oct 15 14:55:12 2026 -0700

    Fix planning of lookup joins over an intermediate stage (#19874)
---
 .../apache/pinot/query/routing/WorkerManager.java  |  89 ++++++++++++----
 .../pinot/query/routing/WorkerManagerTest.java     | 114 +++++++++++++++++++++
 .../src/test/resources/queries/LookupJoin.json     |  20 ++++
 3 files changed, 204 insertions(+), 19 deletions(-)

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 9743ec88f67..3539df3720c 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
@@ -423,30 +423,15 @@ public class WorkerManager {
       assignWorkersToNonRootFragment(child, context, leafOnly, 
visitedFragmentIds);
     }
     Map<Integer, DispatchablePlanMetadata> metadataMap = 
context.getDispatchablePlanMetadataMap();
-    DispatchablePlanMetadata metadata = 
metadataMap.get(fragment.getFragmentId());
-    boolean isLeaf = isLeafPlan(metadata);
-    if (leafOnly != isLeaf) {
+    if (leafOnly != isAssignedInLeafPass(fragment, metadataMap)) {
       return;
     }
-    if (isLeaf) {
+    if (isLeafPlan(metadataMap.get(fragment.getFragmentId()))) {
       // TODO: Revisit this logic and see if we can generalize this
       // For LOOKUP join, join is leaf stage because there is no exchange 
added to the right side of the join. When we
       // find a single local exchange child in the leaf stage, assign workers 
based on the local exchange child.
       if (isLookupJoin(children)) {
-        DispatchablePlanMetadata childMetadata = 
metadataMap.get(children.get(0).getFragmentId());
-        Map<Integer, QueryServerInstance> workerIdToServerInstanceMap = 
assignWorkersForLocalExchange(childMetadata);
-        metadata.setWorkerIdToServerInstanceMap(workerIdToServerInstanceMap);
-        metadata.setPartitionFunction(childMetadata.getPartitionFunction());
-        // The worker map comes from the child, so the worker ids stand for 
the same partition classes as the child's.
-        metadata.setPartitionClassIds(childMetadata.getPartitionClassIds());
-        // Fake a segments map so that the worker can be correctly identified 
as leaf stage
-        Map<String, List<String>> segmentsMap = 
Map.of(TableType.OFFLINE.name(), List.of());
-        Map<Integer, Map<String, List<String>>> workerIdToSegmentsMap =
-            
Maps.newHashMapWithExpectedSize(workerIdToServerInstanceMap.size());
-        for (Integer workerId : workerIdToServerInstanceMap.keySet()) {
-          workerIdToSegmentsMap.put(workerId, segmentsMap);
-        }
-        metadata.setWorkerIdToSegmentsMap(workerIdToSegmentsMap);
+        assignWorkersToLookupJoinFragment(fragment, context, leafOnly);
       } else {
         assignWorkersToLeafFragment(fragment, context);
       }
@@ -455,6 +440,69 @@ public class WorkerManager {
     }
   }
 
+  /// Returns whether the leaf pass assigns the workers of the given fragment. 
A lookup join fragment scans the
+  /// dimension table, so it is a leaf fragment. But it takes its workers from 
its input, so when the input is assigned
+  /// in the intermediate pass (e.g. an aggregation below the join), the 
lookup join fragment is assigned in that pass
+  /// too.
+  private static boolean isAssignedInLeafPass(PlanFragment fragment,
+      Map<Integer, DispatchablePlanMetadata> metadataMap) {
+    if (!isLeafPlan(metadataMap.get(fragment.getFragmentId()))) {
+      return false;
+    }
+    List<PlanFragment> children = fragment.getChildren();
+    return !isLookupJoin(children) || isAssignedInLeafPass(children.get(0), 
metadataMap);
+  }
+
+  /// Assigns the workers of a lookup join fragment from the workers of its 
input, and fakes an empty segments map so
+  /// that the workers are identified as leaf stage workers. The join probes 
the dimension table in memory, so it must
+  /// run on servers, which hold the dimension table. A leaf input runs on 
servers, so the join takes the same workers.
+  /// An intermediate input can run on instances that cannot scan segments (see
+  /// [#getCandidateServersForReplicatedLeaf]), so then the instances come 
from that hook.
+  ///
+  /// @param isInputLeaf whether the input of the join is assigned in the leaf 
pass
+  private void assignWorkersToLookupJoinFragment(PlanFragment fragment, 
DispatchablePlanContext context,
+      boolean isInputLeaf) {
+    Map<Integer, DispatchablePlanMetadata> metadataMap = 
context.getDispatchablePlanMetadataMap();
+    DispatchablePlanMetadata metadata = 
metadataMap.get(fragment.getFragmentId());
+    DispatchablePlanMetadata childMetadata = 
metadataMap.get(fragment.getChildren().get(0).getFragmentId());
+    Map<Integer, QueryServerInstance> workerIdToServerInstanceMap = 
assignWorkersForLocalExchange(childMetadata);
+    boolean inheritPartitioning = true;
+    if (!isInputLeaf) {
+      int numInputWorkers = workerIdToServerInstanceMap.size();
+      List<QueryServerInstance> inputWorkers = new 
ArrayList<>(numInputWorkers);
+      for (int workerId = 0; workerId < numInputWorkers; workerId++) {
+        inputWorkers.add(workerIdToServerInstanceMap.get(workerId));
+      }
+      List<QueryServerInstance> workers = 
getCandidateServersForReplicatedLeaf(context, inputWorkers);
+      if (!workers.equals(inputWorkers)) {
+        // The workers differ from the input's, so the exchange redistributes 
the rows and the worker ids no longer
+        // stand for the input's partition classes.
+        workerIdToServerInstanceMap = 
Maps.newHashMapWithExpectedSize(workers.size());
+        for (int workerId = 0; workerId < workers.size(); workerId++) {
+          workerIdToServerInstanceMap.put(workerId, workers.get(workerId));
+        }
+        inheritPartitioning = false;
+      }
+    }
+    metadata.setWorkerIdToServerInstanceMap(workerIdToServerInstanceMap);
+    if (inheritPartitioning) {
+      metadata.setPartitionFunction(childMetadata.getPartitionFunction());
+      // The worker map comes from the child, so the worker ids stand for the 
same partition classes as the child's.
+      metadata.setPartitionClassIds(childMetadata.getPartitionClassIds());
+    } else {
+      metadata.setPartitionFunction(DEFAULT_SHUFFLE_PARTITION_FUNCTION);
+    }
+    // Fake a segments map so that the worker can be correctly identified as 
leaf stage. The OFFLINE key with no
+    // segments is only a placeholder: the join reads the dimension table from 
memory, not from segments.
+    Map<String, List<String>> segmentsMap = Map.of(TableType.OFFLINE.name(), 
List.of());
+    Map<Integer, Map<String, List<String>>> workerIdToSegmentsMap =
+        Maps.newHashMapWithExpectedSize(workerIdToServerInstanceMap.size());
+    for (Integer workerId : workerIdToServerInstanceMap.keySet()) {
+      workerIdToSegmentsMap.put(workerId, segmentsMap);
+    }
+    metadata.setWorkerIdToSegmentsMap(workerIdToSegmentsMap);
+  }
+
   static boolean isLookupJoin(List<PlanFragment> children) {
     if (children.size() != 1) {
       return false;
@@ -807,7 +855,10 @@ public class WorkerManager {
   }
 
   /// Returns the instances to assign to replicated leaf stage children when 
there is no local exchange peer. By
-  /// default, uses the same candidates as the intermediate stage.
+  /// default, uses the same candidates as the intermediate stage. Also 
returns the instances to assign to a lookup join
+  /// stage over an intermediate input, which are by default the workers of 
that input. Return that list unchanged, in
+  /// the same order, to keep the exchange local and the input's partitioning. 
Any other list makes the exchange
+  /// redistribute the rows.
   ///
   /// Subclasses can override to use different instances for replicated leaf 
stages (e.g., when intermediate stages
   /// run on non-server instances that cannot scan segments).
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
index eafaff9cca7..dfc526e6bfd 100644
--- 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/WorkerManagerTest.java
@@ -33,6 +33,7 @@ import org.apache.pinot.common.request.BrokerRequest;
 import org.apache.pinot.common.request.Expression;
 import org.apache.pinot.common.request.Function;
 import org.apache.pinot.common.request.PinotQuery;
+import org.apache.pinot.core.routing.MockRoutingManagerFactory;
 import org.apache.pinot.core.routing.RoutingManager;
 import org.apache.pinot.core.routing.RoutingTable;
 import org.apache.pinot.core.routing.SegmentsToQuery;
@@ -41,6 +42,7 @@ import 
org.apache.pinot.core.routing.TablePartitionReplicatedServersInfo;
 import org.apache.pinot.core.routing.timeboundary.TimeBoundaryInfo;
 import org.apache.pinot.core.transport.ServerInstance;
 import org.apache.pinot.query.QueryEnvironment;
+import org.apache.pinot.query.planner.physical.DispatchablePlanContext;
 import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
 import org.apache.pinot.query.planner.physical.DispatchableSubPlan;
 import org.apache.pinot.spi.config.table.TableConfig;
@@ -2298,6 +2300,118 @@ public class WorkerManagerTest {
     }
   }
 
+  private static final String LOOKUP_JOINS_OVER_AGGREGATE_QUERY =
+      "SELECT /*+ joinOptions(join_strategy='lookup') */ t.col1, t.cnt, 
dim1.col2, dim2.col2 "
+          + "FROM (SELECT col1, COUNT(*) AS cnt FROM fact GROUP BY col1) t "
+          + "JOIN dim1 ON t.col1 = dim1.col1 JOIN dim2 ON t.col1 = dim2.col1";
+
+  /// A lookup join over an intermediate input (the aggregation of a subquery) 
is assigned after its input. By default
+  /// it takes the workers of its input, like a lookup join over a leaf input. 
The second lookup join takes the workers
+  /// of the first one, so it must be assigned after the first one too.
+  @Test
+  public void testLookupJoinOverIntermediateInputTakesInputWorkers() {
+    DispatchableSubPlan plan = 
newLookupJoinQueryEnvironment(null).planQuery(LOOKUP_JOINS_OVER_AGGREGATE_QUERY);
+    DispatchablePlanFragment dim1Join = lookupJoinFragment(plan, "dim1");
+    DispatchablePlanFragment dim2Join = lookupJoinFragment(plan, "dim2");
+    Map<Integer, String> aggregateWorkers = 
workerIdToServer(inputFragment(plan, dim1Join));
+    assertEquals(aggregateWorkers.size(), 2);
+    assertEquals(workerIdToServer(dim1Join), aggregateWorkers);
+    assertEquals(workerIdToServer(dim2Join), aggregateWorkers);
+  }
+
+  /// When the intermediate stages run on an instance that cannot scan 
segments, like a broker, a lookup join over an
+  /// intermediate input runs on the servers from 
[WorkerManager#getCandidateServersForReplicatedLeaf]. Only servers
+  /// hold the dimension tables.
+  @Test
+  public void testLookupJoinOverIntermediateInputUsesReplicatedLeafServers() {
+    QueryServerInstance broker = new QueryServerInstance("Broker_localhost_9", 
"localhost", 9, 9);
+    DispatchableSubPlan plan = 
newLookupJoinQueryEnvironment(broker).planQuery(LOOKUP_JOINS_OVER_AGGREGATE_QUERY);
+    DispatchablePlanFragment dim1Join = lookupJoinFragment(plan, "dim1");
+    DispatchablePlanFragment dim2Join = lookupJoinFragment(plan, "dim2");
+    assertEquals(workerIdToServer(inputFragment(plan, dim1Join)), Map.of(0, 
broker.getInstanceId()));
+    Set<String> factServers = new 
HashSet<>(workerIdToServer(leafFragmentForTable(plan, "fact")).values());
+    assertEquals(factServers.size(), 2);
+    assertEquals(new HashSet<>(workerIdToServer(dim1Join).values()), 
factServers);
+    assertEquals(workerIdToServer(dim2Join), workerIdToServer(dim1Join));
+  }
+
+  /// A lookup join over a leaf input takes the workers of the leaf, also when 
the intermediate stages run on an
+  /// instance that cannot scan segments.
+  @Test
+  public void testLookupJoinOverLeafInputTakesLeafWorkers() {
+    QueryServerInstance broker = new QueryServerInstance("Broker_localhost_9", 
"localhost", 9, 9);
+    DispatchableSubPlan plan = newLookupJoinQueryEnvironment(broker).planQuery(
+        "SELECT /*+ joinOptions(join_strategy='lookup') */ fact.col1, 
dim1.col2 "
+            + "FROM fact JOIN dim1 ON fact.col1 = dim1.col1");
+    DispatchablePlanFragment dim1Join = lookupJoinFragment(plan, "dim1");
+    DispatchablePlanFragment factLeaf = inputFragment(plan, dim1Join);
+    assertEquals(factLeaf, leafFragmentForTable(plan, "fact"));
+    assertEquals(workerIdToServer(dim1Join), workerIdToServer(factLeaf));
+  }
+
+  /// Builds a QueryEnvironment over the offline table "fact", with one 
segment on each of 2 servers, and the offline
+  /// tables "dim1" and "dim2", with one segment on both servers like 
dimension tables.
+  ///
+  /// @param intermediateStageWorker when set, every intermediate stage runs 
on this instance, see
+  ///        [NonServerIntermediateStageWorkerManager]
+  private static QueryEnvironment newLookupJoinQueryEnvironment(@Nullable 
QueryServerInstance intermediateStageWorker) {
+    MockRoutingManagerFactory factory = new MockRoutingManagerFactory(1, 2);
+    factory.registerTable(getSchemaBuilder("fact").build(), "fact_OFFLINE");
+    factory.registerSegment(1, "fact_OFFLINE", "fact_1");
+    factory.registerSegment(2, "fact_OFFLINE", "fact_2");
+    for (String dimTableName : List.of("dim1", "dim2")) {
+      String dimTableNameWithType = dimTableName + "_OFFLINE";
+      factory.registerTable(getSchemaBuilder(dimTableName).build(), 
dimTableNameWithType);
+      factory.registerSegment(1, dimTableNameWithType, dimTableName + "_0");
+      factory.registerSegment(2, dimTableNameWithType, dimTableName + "_0");
+    }
+    RoutingManager routingManager = factory.buildRoutingManager(null);
+    WorkerManager workerManager = intermediateStageWorker == null
+        ? new WorkerManager("Broker_localhost", "localhost", 3, routingManager)
+        : new NonServerIntermediateStageWorkerManager(routingManager, 
intermediateStageWorker);
+    return new QueryEnvironment(CommonConstants.DEFAULT_DATABASE, 
factory.buildTableCache(), workerManager);
+  }
+
+  /// Runs every intermediate stage on one instance that cannot scan segments, 
like a broker. So it runs the replicated
+  /// leaf stages on the servers that host the tables.
+  private static class NonServerIntermediateStageWorkerManager extends 
WorkerManager {
+    private final QueryServerInstance _intermediateStageWorker;
+
+    NonServerIntermediateStageWorkerManager(RoutingManager routingManager,
+        QueryServerInstance intermediateStageWorker) {
+      super("Broker_localhost", "localhost", 3, routingManager);
+      _intermediateStageWorker = intermediateStageWorker;
+    }
+
+    @Override
+    protected List<QueryServerInstance> 
getCandidateServers(DispatchablePlanContext context) {
+      // Mutable, because the caller sorts it
+      return new ArrayList<>(List.of(_intermediateStageWorker));
+    }
+
+    @Override
+    protected List<QueryServerInstance> 
getCandidateServersForReplicatedLeaf(DispatchablePlanContext context,
+        List<QueryServerInstance> intermediateStageWorkers) {
+      return getCandidateServersPerTables(context);
+    }
+  }
+
+  /// Returns the lookup join fragment that probes the given dimension table. 
It scans the dimension table, has the
+  /// input of the join as its only child, and every worker is a leaf stage 
worker.
+  private static DispatchablePlanFragment 
lookupJoinFragment(DispatchableSubPlan dispatchableSubPlan,
+      String dimTableName) {
+    DispatchablePlanFragment fragment = 
leafFragmentForTable(dispatchableSubPlan, dimTableName);
+    assertEquals(fragment.getPlanFragment().getChildren().size(), 1, "Not a 
lookup join fragment: " + dimTableName);
+    assertEquals(fragment.getWorkerIdToSegmentsMap().keySet(), 
workerIdToServer(fragment).keySet());
+    return fragment;
+  }
+
+  /// Returns the fragment that feeds the given fragment, which has a single 
child.
+  private static DispatchablePlanFragment inputFragment(DispatchableSubPlan 
dispatchableSubPlan,
+      DispatchablePlanFragment fragment) {
+    return 
dispatchableSubPlan.getQueryStageMap().get(fragment.getPlanFragment().getChildren().get(0).getFragmentId());
+  }
+
   /// Returns the only fragment below the reduce stage with neither segments 
nor children, i.e. the join stage.
   private static DispatchablePlanFragment joinFragment(DispatchableSubPlan 
dispatchableSubPlan) {
     for (Map.Entry<Integer, DispatchablePlanFragment> entry : 
dispatchableSubPlan.getQueryStageMap().entrySet()) {
diff --git a/pinot-query-runtime/src/test/resources/queries/LookupJoin.json 
b/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
index 93b0468d7d2..d5ed8dcabd5 100644
--- a/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
+++ b/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
@@ -73,6 +73,26 @@
           ["gamma", 1]
         ],
         "ignoreLiteMode": true
+      },
+      {
+        "description": "Lookup join over an aggregation - the input of the 
join is an intermediate stage, so the join stage is assigned after it",
+        "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */ t.dim_key, 
t.cnt, {dim_tbl}.name FROM (SELECT dim_key, COUNT(*) AS cnt FROM {fact_tbl} 
GROUP BY dim_key) t JOIN {dim_tbl} ON t.dim_key = {dim_tbl}.id",
+        "outputs": [
+          [1, 2, "alpha"],
+          [2, 1, "beta"],
+          [3, 1, "gamma"]
+        ],
+        "ignoreLiteMode": true
+      },
+      {
+        "description": "Two lookup joins over an aggregation - the upper join 
takes the workers of the lower one, which is assigned after the aggregation",
+        "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */ t.dim_key, 
t.cnt, d1.name, d2.name FROM (SELECT dim_key, COUNT(*) AS cnt FROM {fact_tbl} 
GROUP BY dim_key) t JOIN {dim_tbl} d1 ON t.dim_key = d1.id JOIN {dim_tbl} d2 ON 
t.dim_key = d2.id",
+        "outputs": [
+          [1, 2, "alpha", "alpha"],
+          [2, 1, "beta", "beta"],
+          [3, 1, "gamma", "gamma"]
+        ],
+        "ignoreLiteMode": true
       }
     ]
   },


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

Reply via email to