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 08c64f3d722 Fix multi-stage physical optimizer failing to query empty 
tables (#18925)
08c64f3d722 is described below

commit 08c64f3d722a5ab877098f4d09ae5ee918402d6e
Author: Yash Mayya <[email protected]>
AuthorDate: Wed Jul 8 14:11:56 2026 -0700

    Fix multi-stage physical optimizer failing to query empty tables (#18925)
---
 .../core/routing/MockRoutingManagerFactory.java    |   9 +
 .../tests/MultiStageEngineIntegrationTest.java     |  53 +++++-
 .../planner/physical/PinotDispatchPlanner.java     |  47 +++++-
 .../v2/PlanFragmentAndMailboxAssignment.java       |   9 +
 .../opt/rules/LeafStageWorkerAssignmentRule.java   |  11 +-
 .../v2/opt/rules/WorkerExchangeAssignmentRule.java |   6 +-
 .../planner/physical/v2/EmptyTablePlanTest.java    | 186 +++++++++++++++++++++
 .../rules/LeafStageWorkerAssignmentRuleTest.java   |  12 ++
 8 files changed, 328 insertions(+), 5 deletions(-)

diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/routing/MockRoutingManagerFactory.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/routing/MockRoutingManagerFactory.java
index 6a1e44f30ae..8127629ca45 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/routing/MockRoutingManagerFactory.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/routing/MockRoutingManagerFactory.java
@@ -93,6 +93,15 @@ public class MockRoutingManagerFactory {
         .add(serverInstance);
   }
 
+  /**
+   * Registers a routing entry for a table that has no routable segments (e.g. 
an empty table, or one whose segments
+   * were all pruned). This mirrors {@code BaseBrokerRoutingManager}, which 
returns a non-null {@link RoutingTable}
+   * with an empty server-to-segments map for such a table (as opposed to 
{@code null} when no routing entry exists).
+   */
+  public void registerEmptyTable(String tableNameWithType) {
+    _tableSegmentServersMap.computeIfAbsent(tableNameWithType, k -> new 
HashMap<>());
+  }
+
   public void disableTable(String tableNameWithType) {
     _disabledTables.add(tableNameWithType);
   }
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
index 479c537d25d..a1d32da14dd 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
@@ -272,6 +272,51 @@ public class MultiStageEngineIntegrationTest extends 
BaseClusterIntegrationTestS
         List.of(), "LONG");
   }
 
+  @Test
+  public void testAllLeafStagesEmptyBrokerResponsesWithPhysicalOptimizer()
+      throws Exception {
+    // Same short-circuit behavior must hold under the multi-stage physical 
optimizer, which previously failed with
+    // "No routing entry for offline or realtime type" when a leaf stage had 
no routable segments.
+    String table = "mytable";
+    String prefix = "SET useBrokerPruning = 'true'; SET usePhysicalOptimizer = 
'true'; ";
+    assertAllLeafStagesEmptyRows(prefix, "SELECT AirlineID, Carrier FROM " + 
table + " WHERE DaysSinceEpoch < 0",
+        List.of(), "LONG", "STRING");
+    assertAllLeafStagesEmptyRows(prefix, "SELECT COUNT(*) FROM " + table + " 
WHERE DaysSinceEpoch < 0",
+        List.of(List.<Object>of(0)), "LONG");
+    assertAllLeafStagesEmptyRows(prefix, "SELECT SUM(ActualElapsedTime) FROM " 
+ table + " WHERE DaysSinceEpoch < 0",
+        List.of(Arrays.asList((Object) null)), "LONG");
+    assertAllLeafStagesEmptyRows(prefix, "SELECT COUNT(*) + 1 FROM " + table + 
" WHERE DaysSinceEpoch < 0",
+        List.of(List.<Object>of(1)), "LONG");
+    assertAllLeafStagesEmptyRows(prefix,
+        "SELECT AirlineID, COUNT(*) FROM " + table + " WHERE DaysSinceEpoch < 
0 GROUP BY AirlineID",
+        List.of(), "LONG", "LONG");
+  }
+
+  @Test
+  public void testPartiallyEmptyWithPhysicalOptimizerFailsFast()
+      throws Exception {
+    // Combining an empty/fully-pruned table with a non-empty table is not yet 
supported by the physical optimizer.
+    // It must fail fast with a clear, actionable error rather than silently 
drop rows.
+    String table = "mytable";
+    String expectedError = "combine an empty or fully-pruned table with a 
non-empty table";
+    // The empty branch (all segments pruned) contributes zero rows, so the 
union counts only the non-empty branch.
+    String unionQuery = "SELECT COUNT(*) FROM (SELECT AirlineID FROM " + table 
+ " WHERE DaysSinceEpoch < 0 "
+        + "UNION ALL SELECT AirlineID FROM " + table + ") u";
+
+    JsonNode overUnion = postQuery("SET useBrokerPruning = 'true'; SET 
usePhysicalOptimizer = 'true'; " + unionQuery);
+    assertFalse(overUnion.get("exceptions").isEmpty(), "Expected a 
partially-empty error: " + overUnion);
+    assertTrue(overUnion.get("exceptions").toString().contains(expectedError),
+        "Expected a clear partially-empty error, got: " + 
overUnion.get("exceptions"));
+
+    // The suggested fallback (legacy engine) must answer the same query 
correctly: the empty branch adds nothing, so
+    // the union count equals the total row count of the non-empty table.
+    long total = postQuery("SELECT COUNT(*) FROM " + 
table).get("resultTable").get("rows").get(0).get(0).asLong();
+    assertTrue(total > 0, "Sanity: mytable should be non-empty");
+    JsonNode fallback = postQuery("SET useBrokerPruning = 'true'; SET 
usePhysicalOptimizer = 'false'; " + unionQuery);
+    assertTrue(fallback.get("exceptions").isEmpty(), "Fallback should not 
error: " + fallback);
+    
assertEquals(fallback.get("resultTable").get("rows").get(0).get(0).asLong(), 
total);
+  }
+
   @Test
   public void testReplicatedLeavesThatCanProduceRowsDoNotShortCircuit()
       throws Exception {
@@ -296,7 +341,13 @@ public class MultiStageEngineIntegrationTest extends 
BaseClusterIntegrationTestS
 
   private JsonNode assertAllLeafStagesEmptyRows(String query, 
List<List<Object>> expectedRows, String... expectedTypes)
       throws Exception {
-    JsonNode response = postQuery("SET useBrokerPruning = 'true'; " + query);
+    return assertAllLeafStagesEmptyRows("SET useBrokerPruning = 'true'; ", 
query, expectedRows, expectedTypes);
+  }
+
+  private JsonNode assertAllLeafStagesEmptyRows(String setPrefix, String 
query, List<List<Object>> expectedRows,
+      String... expectedTypes)
+      throws Exception {
+    JsonNode response = postQuery(setPrefix + query);
     assertTrue(response.get("exceptions").isEmpty(), "Unexpected exceptions 
for query: " + query);
     assertEquals(response.get("numServersQueried").asInt(), 0, "Query should 
not dispatch to servers: " + query);
     assertEquals(response.get("numServersResponded").asInt(), 0, "Query should 
not dispatch to servers: " + query);
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/PinotDispatchPlanner.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/PinotDispatchPlanner.java
index 5e21eea6988..b32c4164693 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/PinotDispatchPlanner.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/PinotDispatchPlanner.java
@@ -109,10 +109,53 @@ public class PinotDispatchPlanner {
     for (var entry : result._planFragmentMap.entrySet()) {
       context.getDispatchablePlanStageRootMap().put(entry.getKey(), 
entry.getValue().getFragmentRoot());
     }
+    trackEmptyLeafStages(context);
     runValidations(rootFragment, context);
     return finalizeDispatchableSubPlan(rootFragment, context);
   }
 
+  /**
+   * Records empty leaf stages so that {@link 
DispatchablePlanContext#isAllNonReplicatedLeafStagesEmpty()} works for the
+   * physical optimizer path, mirroring what {@code WorkerManager} does for 
the legacy path. A leaf stage is a fragment
+   * that scans one or more tables; it is empty when it was assigned zero 
workers (no routable segments, e.g. an empty
+   * table or all segments pruned by the broker). When every leaf stage is 
empty, this drives the empty-leaf
+   * short-circuit in {@link #finalizeDispatchableSubPlan}.
+   * <p>
+   * When only <i>some</i> leaf stages are empty (an empty or fully-pruned 
table combined with a non-empty table in the
+   * same query), the physical optimizer cannot yet produce a correct plan: an 
empty leaf is assigned zero workers, and
+   * a downstream join/set-op/aggregate derives its worker set from its 
inputs, so the empty branch can zero out a whole
+   * stage and silently drop rows. Rather than return wrong results, fail fast 
with an actionable error suggesting the
+   * legacy (non-physical) engine, which handles this case.
+   * <p>
+   * Unlike the legacy path (which excludes broadcast-replicated dim leaves 
from this tracking via
+   * {@code WorkerManager}'s early return), every table-scanning fragment is 
counted here. The physical optimizer does
+   * not honor the {@code IS_REPLICATED} broadcast hint (a dim table is routed 
like any other table), and it never
+   * populates {@code replicatedSegments}, so {@link 
#hasNonEmptyReplicatedLeaf} cannot rescue an incorrectly
+   * short-circuited replicated join. Counting every leaf keeps the fail-fast 
conservative: a query mixing an empty
+   * table with a non-empty (dim or fact) table is rejected rather than 
risking a wrong result.
+   */
+  private static void trackEmptyLeafStages(DispatchablePlanContext context) {
+    int leafStages = 0;
+    int emptyLeafStages = 0;
+    for (DispatchablePlanMetadata metadata : 
context.getDispatchablePlanMetadataMap().values()) {
+      if (metadata.getScannedTables().isEmpty()) {
+        // Not a leaf stage (no table scan).
+        continue;
+      }
+      leafStages++;
+      context.recordLeafStageAssigned();
+      if (metadata.getWorkerIdToServerInstanceMap().isEmpty()) {
+        emptyLeafStages++;
+        context.recordLeafStageEmpty();
+      }
+    }
+    if (emptyLeafStages > 0 && emptyLeafStages < leafStages) {
+      throw new UnsupportedOperationException("The multi-stage physical 
optimizer does not yet support queries that "
+          + "combine an empty or fully-pruned table with a non-empty table 
(e.g. a join or union where one side has "
+          + "no routable segments). Retry with the query option 
'usePhysicalOptimizer=false'.");
+    }
+  }
+
   /**
    * Run validations on the plan. Since there is only one validator right now, 
don't try to over-engineer it.
    */
@@ -128,8 +171,8 @@ public class PinotDispatchPlanner {
 
   private static DispatchableSubPlan finalizeDispatchableSubPlan(PlanFragment 
subPlanRoot,
       DispatchablePlanContext dispatchablePlanContext) {
-    // TODO: Physical Optimizer path does not track empty leaf stages. To 
support short-circuit,
-    //  check if all leaf TableScanMetadata have empty workerIdToSegmentsMap.
+    // Empty leaf stages are tracked for both engines: the legacy path in 
WorkerManager, and the physical optimizer
+    // path in #trackEmptyLeafStages.
     boolean allLeafStagesEmpty = 
dispatchablePlanContext.isAllNonReplicatedLeafStagesEmpty();
     if (allLeafStagesEmpty && 
hasNonEmptyReplicatedLeaf(dispatchablePlanContext.getDispatchablePlanMetadataMap()))
 {
       allLeafStagesEmpty = false;
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PlanFragmentAndMailboxAssignment.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PlanFragmentAndMailboxAssignment.java
index 29ef661150e..8e622b36171 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PlanFragmentAndMailboxAssignment.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PlanFragmentAndMailboxAssignment.java
@@ -274,6 +274,15 @@ public class PlanFragmentAndMailboxAssignment {
   private void computeMailboxInfos(int senderStageId, int receiverStageId,
       Map<Integer, QueryServerInstance> senderWorkers, Map<Integer, 
QueryServerInstance> receiverWorkers,
       ExchangeStrategy exchangeDesc, Context context) {
+    if (senderWorkers.isEmpty() && receiverWorkers.isEmpty()) {
+      // Both stages have no workers, which happens for an all-empty subtree: 
a leaf stage with no routable segments
+      // (e.g. an empty table or all segments pruned by the broker) whose 
emptiness propagates up. There is no data to
+      // route and no receiver to inform, and these stages are discarded by 
the empty-leaf short-circuit in
+      // PinotDispatchPlanner#finalizeDispatchableSubPlan. Skipping avoids the 
single-instance-receiver check below for
+      // a degenerate SINGLETON gather. When only the sender is empty (e.g. 
the empty side of a union or join), the
+      // exchange-specific logic below still wires the receiver with an empty 
sender list so it knows to expect no rows.
+      return;
+    }
     DispatchablePlanMetadata senderMetadata = 
context._fragmentMetadataMap.get(senderStageId);
     DispatchablePlanMetadata receiverMetadata = 
context._fragmentMetadataMap.get(receiverStageId);
     Map<Integer, Map<Integer, MailboxInfos>> senderMailboxMap = 
senderMetadata.getWorkerIdToMailboxesMap();
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRule.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRule.java
index d0ae144ae5e..c087d251980 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRule.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRule.java
@@ -218,8 +218,17 @@ public class LeafStageWorkerAssignmentRule extends 
PRelOptRule {
       InstanceIdToSegments instanceIdToSegments, Map<String, 
TablePartitionInfo> tpiMap,
       boolean inferInvalidPartitionSegment, boolean 
inferRealtimeSegmentPartition) {
     Set<String> tableTypes = instanceIdToSegments.getActiveTableTypes();
+    if (tableTypes.isEmpty()) {
+      // The table has a routing entry but no routable segments for either 
table type (e.g. an empty table, or all
+      // segments were pruned by the broker). Assign zero workers instead of 
throwing, mirroring the legacy
+      // WorkerManager. The resulting empty leaf stage is detected and 
short-circuited to an empty result on the
+      // broker (see PinotDispatchPlanner#finalizeDispatchableSubPlan).
+      List<String> noWorkers = List.of();
+      PinotDataDistribution emptyDistribution = new 
PinotDataDistribution(RelDistribution.Type.RANDOM_DISTRIBUTED,
+          noWorkers, noWorkers.hashCode(), null, null);
+      return new TableScanWorkerAssignmentResult(emptyDistribution, new 
HashMap<>());
+    }
     Set<String> partitionedTableTypes = 
tableTypes.stream().filter(tpiMap::containsKey).collect(Collectors.toSet());
-    Preconditions.checkState(!tableTypes.isEmpty(), "No routing entry for 
offline or realtime type");
     if (tableTypes.equals(partitionedTableTypes)) {
       // TODO(mse-physical): Support auto-partitioning inference for Hybrid 
tables.
       if (partitionedTableTypes.size() == 1) {
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/WorkerExchangeAssignmentRule.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/WorkerExchangeAssignmentRule.java
index 11fdc0ce94b..f03e15caf25 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/WorkerExchangeAssignmentRule.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/opt/rules/WorkerExchangeAssignmentRule.java
@@ -322,7 +322,11 @@ public class WorkerExchangeAssignmentRule implements 
PRelNodeTransformer {
           _physicalPlannerContext.getDefaultHashFunction());
     }
     if (distributionConstraint.getType() == RelDistribution.Type.SINGLETON) {
-      List<String> newWorkers = 
currentNodeDistribution.getWorkers().subList(0, 1);
+      // Pick a single worker to gather the data on. When the input has no 
workers (e.g. an empty leaf stage whose
+      // table has no routable segments), keep the singleton empty; the empty 
leaf is short-circuited to an empty
+      // result on the broker later (see 
PinotDispatchPlanner#finalizeDispatchableSubPlan).
+      List<String> currentWorkers = currentNodeDistribution.getWorkers();
+      List<String> newWorkers = currentWorkers.isEmpty() ? currentWorkers : 
currentWorkers.subList(0, 1);
       PinotDataDistribution pinotDataDistribution = new 
PinotDataDistribution(RelDistribution.Type.SINGLETON,
           newWorkers, newWorkers.hashCode(), null, null);
       return new PhysicalExchange(nodeId(), currentNode, 
pinotDataDistribution, List.of(),
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/EmptyTablePlanTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/EmptyTablePlanTest.java
new file mode 100644
index 00000000000..fabcb5fedf3
--- /dev/null
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/EmptyTablePlanTest.java
@@ -0,0 +1,186 @@
+/**
+ * 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.v2;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.pinot.common.config.provider.TableCache;
+import org.apache.pinot.core.routing.MockRoutingManagerFactory;
+import org.apache.pinot.core.routing.RoutingManager;
+import org.apache.pinot.query.QueryEnvironment;
+import org.apache.pinot.query.QueryEnvironmentTestBase;
+import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
+import org.apache.pinot.query.planner.physical.DispatchableSubPlan;
+import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.query.planner.plannode.TableScanNode;
+import org.apache.pinot.query.routing.WorkerManager;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.*;
+
+
+/**
+ * Verifies that querying an empty table (a table that exists and has a 
routing entry, but no routable segments) under
+ * the multi-stage physical optimizer does not fail. Such a query should be 
short-circuited to an empty result on the
+ * broker, mirroring the single-stage engine and the legacy multi-stage path 
(see PinotDispatchPlanner and #18538).
+ */
+public class EmptyTablePlanTest {
+  private static final String EMPTY_TABLE = "emptytable_OFFLINE";
+  private static final String EMPTY_TABLE_2 = "emptytable2_OFFLINE";
+
+  private QueryEnvironment _queryEnvironment;
+
+  @BeforeClass
+  public void setUp() {
+    MockRoutingManagerFactory factory = new MockRoutingManagerFactory(1, 2);
+    for (Map.Entry<String, Schema> entry : 
QueryEnvironmentTestBase.TABLE_SCHEMAS.entrySet()) {
+      factory.registerTable(entry.getValue(), entry.getKey());
+    }
+    for (Map.Entry<String, List<String>> entry : 
QueryEnvironmentTestBase.SERVER1_SEGMENTS.entrySet()) {
+      for (String segment : entry.getValue()) {
+        factory.registerSegment(1, entry.getKey(), segment);
+      }
+    }
+    for (Map.Entry<String, List<String>> entry : 
QueryEnvironmentTestBase.SERVER2_SEGMENTS.entrySet()) {
+      for (String segment : entry.getValue()) {
+        factory.registerSegment(2, entry.getKey(), segment);
+      }
+    }
+    // Register offline tables that have a routing entry but no routable 
segments (empty tables).
+    
factory.registerTable(QueryEnvironmentTestBase.TABLE_SCHEMAS.get("c_OFFLINE"), 
EMPTY_TABLE);
+    factory.registerEmptyTable(EMPTY_TABLE);
+    
factory.registerTable(QueryEnvironmentTestBase.TABLE_SCHEMAS.get("c_OFFLINE"), 
EMPTY_TABLE_2);
+    factory.registerEmptyTable(EMPTY_TABLE_2);
+
+    RoutingManager routingManager = factory.buildRoutingManager(null);
+    TableCache tableCache = factory.buildTableCache();
+    WorkerManager workerManager = new WorkerManager("Broker_localhost", 
"localhost", 3, routingManager);
+
+    _queryEnvironment = new QueryEnvironment(
+        QueryEnvironment.configBuilder()
+            .requestId(-1L)
+            .database(CommonConstants.DEFAULT_DATABASE)
+            .tableCache(tableCache)
+            .workerManager(workerManager)
+            .defaultUsePhysicalOptimizer(true)
+            .defaultInferPartitionHint(true)
+            .build());
+  }
+
+  @Test
+  public void testSelectStarFromEmptyTable() {
+    DispatchableSubPlan plan = _queryEnvironment.planQuery("SELECT * FROM 
emptytable");
+    assertNotNull(plan);
+    assertTrue(plan.isAllLeafStagesEmpty(), "All leaf stages should be 
detected as empty");
+    assertShortCircuited(plan);
+  }
+
+  @Test
+  public void testCountStarFromEmptyTable() {
+    DispatchableSubPlan plan = _queryEnvironment.planQuery("SELECT COUNT(*) 
FROM emptytable");
+    assertNotNull(plan);
+    assertTrue(plan.isAllLeafStagesEmpty(), "All leaf stages should be 
detected as empty");
+    assertShortCircuited(plan);
+  }
+
+  @Test
+  public void testFilterFromEmptyTable() {
+    DispatchableSubPlan plan = _queryEnvironment.planQuery("SELECT col1, col3 
FROM emptytable WHERE col3 > 5");
+    assertNotNull(plan);
+    assertTrue(plan.isAllLeafStagesEmpty(), "All leaf stages should be 
detected as empty");
+    assertShortCircuited(plan);
+  }
+
+  @Test
+  public void testGroupByFromEmptyTable() {
+    DispatchableSubPlan plan =
+        _queryEnvironment.planQuery("SELECT col1, SUM(col3) FROM emptytable 
GROUP BY col1");
+    assertNotNull(plan);
+    assertTrue(plan.isAllLeafStagesEmpty(), "All leaf stages should be 
detected as empty");
+    assertShortCircuited(plan);
+  }
+
+  @Test
+  public void testNonExistentTableStillErrors() {
+    // A table that does not exist must still fail (caught during validation / 
table-cache lookup), not be treated as
+    // an empty table and short-circuited to an empty result.
+    RuntimeException e = expectThrows(RuntimeException.class,
+        () -> _queryEnvironment.planQuery("SELECT * FROM 
this_table_does_not_exist"));
+    assertTrue(e.getMessage().toLowerCase().contains("not found")
+            || e.getMessage().toLowerCase().contains("object 
'this_table_does_not_exist'")
+            || 
e.getMessage().toLowerCase().contains("this_table_does_not_exist"),
+        "Expected a table-not-found error, got: " + e.getMessage());
+  }
+
+  @Test
+  public void testUnionOfTwoEmptyTablesShortCircuits() {
+    // Multiple leaf stages, all empty: still short-circuited (exercises the 
all-empty boundary with >1 leaf).
+    DispatchableSubPlan plan = _queryEnvironment.planQuery(
+        "SELECT col1, col2 FROM emptytable UNION ALL SELECT col1, col2 FROM 
emptytable2");
+    assertNotNull(plan);
+    assertTrue(plan.isAllLeafStagesEmpty(), "All leaf stages should be 
detected as empty");
+    assertShortCircuited(plan);
+  }
+
+  @Test
+  public void testUnionWithNonEmptyTableFailsFast() {
+    // Combining an empty table with a non-empty table is not yet supported by 
the physical optimizer. It must fail
+    // fast with a clear error rather than silently drop rows (see 
PinotDispatchPlanner#trackEmptyLeafStages).
+    assertPartiallyEmptyError("SELECT col1, col2 FROM emptytable UNION ALL 
SELECT col1, col2 FROM c");
+  }
+
+  @Test
+  public void testJoinWithNonEmptyTableFailsFast() {
+    assertPartiallyEmptyError("SELECT t.col1 FROM emptytable t JOIN c ON 
t.col1 = c.col1");
+  }
+
+  private void assertPartiallyEmptyError(String query) {
+    RuntimeException e = expectThrows(RuntimeException.class, () -> 
_queryEnvironment.planQuery(query));
+    assertTrue(e.getMessage().contains("combine an empty or fully-pruned table 
with a non-empty table"),
+        "Expected a clear partially-empty error, got: " + e.getMessage());
+  }
+
+  /**
+   * When all leaf stages are empty, the plan is rewritten so that only the 
broker reduce stage (stage 0) remains,
+   * and its plan tree no longer contains any table scan (the leaves are 
inlined to empty literals).
+   */
+  private void assertShortCircuited(DispatchableSubPlan plan) {
+    assertEquals(plan.getQueryStageMap().keySet(), Set.of(0),
+        "Only the reduce stage should remain after short-circuiting empty 
leaves");
+    DispatchablePlanFragment reduceStage = plan.getQueryStageMap().get(0);
+    
assertFalse(containsTableScan(reduceStage.getPlanFragment().getFragmentRoot()),
+        "Reduce stage should not contain a TableScanNode after inlining empty 
leaves");
+  }
+
+  private static boolean containsTableScan(PlanNode node) {
+    if (node instanceof TableScanNode) {
+      return true;
+    }
+    for (PlanNode input : node.getInputs()) {
+      if (containsTableScan(input)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRuleTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRuleTest.java
index 870399cfc6b..14b4d9b5992 100644
--- 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRuleTest.java
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/physical/v2/opt/rules/LeafStageWorkerAssignmentRuleTest.java
@@ -115,6 +115,18 @@ public class LeafStageWorkerAssignmentRuleTest {
     validateTableScanAssignment(result, 
HYBRID_INSTANCE_ID_TO_SEGMENTS._realtimeTableSegmentsMap, "REALTIME");
   }
 
+  @Test
+  public void testAssignTableScanWithEmptyTable() {
+    // An empty table (or one whose segments were all pruned) has a routing 
entry but no routable segments, so
+    // neither the offline nor the realtime segments map is populated. This 
should yield a zero-worker assignment
+    // (which is later short-circuited to an empty result on the broker), NOT 
throw. See LeafStageWorkerAssignmentRule.
+    InstanceIdToSegments emptyInstanceIdToSegments = new 
InstanceIdToSegments();
+    TableScanWorkerAssignmentResult result = 
LeafStageWorkerAssignmentRule.assignTableScan(TABLE_NAME, FIELDS_IN_SCAN,
+        emptyInstanceIdToSegments, Map.of(), false, false);
+    assertTrue(result._pinotDataDistribution.getWorkers().isEmpty(), "Empty 
table should assign zero workers");
+    assertTrue(result._workerIdToSegmentsMap.isEmpty(), "Empty table should 
have no worker-to-segments entries");
+  }
+
   @Test
   public void testAssignTableScanPartitionedOfflineTable() {
     TableScanWorkerAssignmentResult result = 
LeafStageWorkerAssignmentRule.assignTableScan(TABLE_NAME, FIELDS_IN_SCAN,


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

Reply via email to