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 849c6206237 Enable broker segment pruning by default for the MSE
logical planner (and fix routing-filter construction bugs it surfaces) (#18941)
849c6206237 is described below
commit 849c6206237fd7ce7151229db87638bac9ff54c0
Author: Yash Mayya <[email protected]>
AuthorDate: Thu Jul 9 11:58:49 2026 -0700
Enable broker segment pruning by default for the MSE logical planner (and
fix routing-filter construction bugs it surfaces) (#18941)
---
...PartitionLLCRealtimeClusterIntegrationTest.java | 12 +-
.../BaseLogicalTableIntegrationTest.java | 6 +-
.../planner/logical/LeafStageToPinotQuery.java | 69 ++++-
.../query/routing/PlanNodeRoutingQueryBuilder.java | 27 +-
.../apache/pinot/query/routing/WorkerManager.java | 54 +++-
.../planner/logical/LeafStageToPinotQueryTest.java | 143 +++++++++++
.../routing/PlanNodeRoutingQueryBuilderTest.java | 60 +++++
.../pinot/query/routing/WorkerManagerTest.java | 286 ++++++++++++++++++++-
.../resources/queries/ExplainPhysicalPlans.json | 33 +--
.../apache/pinot/spi/utils/CommonConstants.java | 10 +-
10 files changed, 639 insertions(+), 61 deletions(-)
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentPartitionLLCRealtimeClusterIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentPartitionLLCRealtimeClusterIntegrationTest.java
index 75e1ad0e166..6846d06918d 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentPartitionLLCRealtimeClusterIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/SegmentPartitionLLCRealtimeClusterIntegrationTest.java
@@ -215,20 +215,22 @@ public class
SegmentPartitionLLCRealtimeClusterIntegrationTest extends BaseClust
// 'CA' hashes to partition 0, so only that partition's segments should
be routed when broker pruning is enabled.
String filteredQuery = "SELECT COUNT(*) FROM mytable WHERE DestState =
'CA'";
- // Baseline: the multi-stage engine without broker pruning scans all
segments across both partitions.
- JsonNode unpruned = postQuery(filteredQuery);
+ // Baseline: with broker pruning explicitly disabled, the query scans
all segments across both partitions.
+ JsonNode unpruned = postQuery("SET useBrokerPruning=false; " +
filteredQuery);
assertTrue(unpruned.get("exceptions").isEmpty(), "Unexpected exceptions
without broker pruning");
int unprunedSegmentsQueried =
unpruned.get(MetadataKey.NUM_SEGMENTS_QUERIED.getName()).asInt();
- // With broker pruning enabled, the non-matching partition's segments
are pruned at the broker before dispatch.
- JsonNode pruned = postQuery("SET useBrokerPruning=true; " +
filteredQuery);
+ // Broker pruning is on by default: without any SET, the non-matching
partition's segments are pruned at the
+ // broker before dispatch.
+ JsonNode pruned = postQuery(filteredQuery);
assertTrue(pruned.get("exceptions").isEmpty(), "Unexpected exceptions
with broker pruning");
// Broker pruning is a routing optimization only; the result must be
identical to the unpruned run.
assertEquals(pruned.get("resultTable").get("rows").get(0).get(0).asLong(),
unpruned.get("resultTable").get("rows").get(0).get(0).asLong(),
"Broker pruning changed the query result");
- // Broker pruning actually engaged: the non-matching partition's
segments were dropped, so fewer were queried.
+ // Broker pruning actually engaged by default: the non-matching
partition's segments were dropped, so fewer
+ // were queried.
assertTrue(pruned.get("numSegmentsPrunedByBroker").asInt() > 0,
"Expected the broker to prune the non-matching partition's
segments");
assertTrue(pruned.get(MetadataKey.NUM_SEGMENTS_QUERIED.getName()).asInt() <
unprunedSegmentsQueried,
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
index db52283c71d..c9c61871d14 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/logicaltable/BaseLogicalTableIntegrationTest.java
@@ -470,10 +470,12 @@ public abstract class BaseLogicalTableIntegrationTest
extends BaseClusterIntegra
String logicalTableName = getLogicalTableName();
String filteredQuery = "SELECT COUNT(*) FROM " + logicalTableName + "
WHERE DaysSinceEpoch > 16312";
- JsonNode withoutPruning = postQuery(filteredQuery);
+ // Broker pruning is on by default on the MSE logical planner path;
disable it explicitly for the baseline so the
+ // two runs exercise different routing paths.
+ JsonNode withoutPruning = postQuery("SET useBrokerPruning=false; " +
filteredQuery);
assertTrue(withoutPruning.get("exceptions").isEmpty(), "Unexpected
exceptions without broker pruning");
- JsonNode withPruning = postQuery("SET useBrokerPruning=true; " +
filteredQuery);
+ JsonNode withPruning = postQuery(filteredQuery);
assertTrue(withPruning.get("exceptions").isEmpty(), "Unexpected exceptions
with broker pruning");
// Broker pruning is a routing optimization only; the result must be
identical to the unpruned run.
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
index 99d2235f99a..f6777b9dcc3 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
@@ -22,10 +22,12 @@ import com.google.common.base.Preconditions;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
+import javax.annotation.Nullable;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.Filter;
import org.apache.calcite.rel.core.Project;
import org.apache.calcite.rel.core.TableScan;
+import org.apache.commons.lang3.EnumUtils;
import org.apache.pinot.common.request.DataSource;
import org.apache.pinot.common.request.Expression;
import org.apache.pinot.common.request.ExpressionType;
@@ -38,6 +40,11 @@ import org.apache.pinot.sql.FilterKind;
/**
* Utility to convert a leaf stage to a {@link PinotQuery}.
+ *
+ * <p>{@link #createPinotQueryForRouting} folds a Calcite {@link RelNode} leaf
stage into a routing query for the
+ * physical optimizer; {@code PlanNodeRoutingQueryBuilder} is the {@code
PlanNode} (logical-planner) counterpart that
+ * must be kept in sync (same leaf-boundary stop and filter-combining
behavior). The shared filter-normalization
+ * helpers ({@link #ensureFilterIsFunctionExpression} and {@link
#addFilterExpression}) live here and are used by both.
*/
public class LeafStageToPinotQuery {
private LeafStageToPinotQuery() {
@@ -59,13 +66,20 @@ public class LeafStageToPinotQuery {
"Could not find table scan");
TableScan tableScan = (TableScan) bottomToTopNodes.get(0);
PinotQuery pinotQuery = initializePinotQueryForTableScan(tableName,
tableScan);
- for (RelNode parentNode : bottomToTopNodes) {
+ for (int i = 1; i < bottomToTopNodes.size(); i++) {
+ RelNode parentNode = bottomToTopNodes.get(i);
if (parentNode instanceof Filter) {
if (!skipFilter) {
handleFilter((Filter) parentNode, pinotQuery);
}
} else if (parentNode instanceof Project) {
handleProject((Project) parentNode, pinotQuery);
+ } else {
+ // Leaf boundary: the first node that is neither Filter nor Project
(e.g. an un-split DIRECT aggregate)
+ // changes the row space -- InputRefs in nodes above it index that
node's output, not the scan/project
+ // columns -- so folding anything above it (e.g. a HAVING filter)
would mis-resolve columns and corrupt the
+ // routing filter. Everything below this boundary is a genuine
row-level condition; ignore everything above.
+ break;
}
}
return pinotQuery;
@@ -103,8 +117,31 @@ public class LeafStageToPinotQuery {
RexExpression rexExpression =
RexExpressionUtils.fromRexNode(filter.getCondition());
Expression filterExpression =
CalciteRexExpressionParser.toExpression(rexExpression,
pinotQuery.getSelectList());
-
pinotQuery.setFilterExpression(ensureFilterIsFunctionExpression(filterExpression));
+ addFilterExpression(pinotQuery,
ensureFilterIsFunctionExpression(filterExpression));
+ }
+ }
+
+ /**
+ * Adds the given filter to the query, AND-ing it with any previously set
filter (rather than overwriting it, which
+ * would silently discard a genuine row-level condition when a leaf stage
contains multiple filter nodes).
+ * <p>
+ * Note: this mutates {@code pinotQuery} (and, when combining, reuses the
existing filter expression in-place). It
+ * assumes the query is freshly constructed for routing and not shared
across concurrent callers.
+ */
+ public static void addFilterExpression(PinotQuery pinotQuery, @Nullable
Expression filterExpression) {
+ if (filterExpression == null) {
+ return;
}
+ Expression existingFilter = pinotQuery.getFilterExpression();
+ if (existingFilter == null) {
+ pinotQuery.setFilterExpression(filterExpression);
+ return;
+ }
+ Function andFunction = new Function(FilterKind.AND.name());
+ andFunction.setOperands(new ArrayList<>(List.of(existingFilter,
filterExpression)));
+ Expression combined = new Expression(ExpressionType.FUNCTION);
+ combined.setFunctionCall(andFunction);
+ pinotQuery.setFilterExpression(combined);
}
/**
@@ -117,10 +154,18 @@ public class LeafStageToPinotQuery {
* always-false predicate EQUALS(0, 1), and drops LITERAL true expressions
(null = no filter).
* For AND/OR/NOT nodes, operands are recursively fixed.
* <p>
+ * Boolean scalar functions used directly as predicates (e.g. {@code WHERE
contains(col, 'foo')}) are wrapped as
+ * {@code EQUALS(fn(...), true)}, mirroring the single-stage engine's {@code
PredicateComparisonRewriter}: segment
+ * pruners resolve filter operators via {@code FilterKind.valueOf} and would
otherwise throw on them.
+ * <p>
* Note: This method mutates the input expression's operand lists in-place
for AND/OR/NOT nodes.
* It assumes the expression tree is freshly constructed and not shared
across concurrent callers.
+ *
+ * @return the pruner-safe filter expression, or {@code null} when the input
carries no filter constraint (e.g.
+ * LITERAL true, or an AND/OR/NOT that reduces away entirely).
*/
- public static Expression ensureFilterIsFunctionExpression(Expression
expression) {
+ @Nullable
+ public static Expression ensureFilterIsFunctionExpression(@Nullable
Expression expression) {
if (expression == null) {
return null;
}
@@ -157,17 +202,16 @@ public class LeafStageToPinotQuery {
return null; // NOT(constant) — drop entire filter
}
operands.set(0, fixed);
+ } else if (!EnumUtils.isValidEnum(FilterKind.class, operator)) {
+ // Boolean scalar function used directly as a predicate (e.g.
contains(col, 'foo')).
+ return wrapAsEqualsTrue(expression);
}
return expression;
}
if (expression.getIdentifier() != null) {
// Bare boolean column reference (e.g., "is_active" after REINTERPRET
stripped).
// Wrap as EQUALS(col, true) so pruners see a standard predicate.
- Function equalsFunction = new Function(FilterKind.EQUALS.name());
- equalsFunction.setOperands(new ArrayList<>(List.of(expression,
RequestUtils.getLiteralExpression(true))));
- Expression wrapped = new Expression(ExpressionType.FUNCTION);
- wrapped.setFunctionCall(equalsFunction);
- return wrapped;
+ return wrapAsEqualsTrue(expression);
}
// LITERAL expression (constant-folded predicate, e.g., TRUE/FALSE).
// Treat LITERAL false as an always-false predicate that pruners can
process so they
@@ -185,4 +229,13 @@ public class LeafStageToPinotQuery {
// LITERAL true or non-boolean literals have no filter constraint so we
skip pruning
return null;
}
+
+ /** Wraps a boolean-valued expression as the standard predicate {@code
EQUALS(expression, true)}. */
+ private static Expression wrapAsEqualsTrue(Expression expression) {
+ Function equalsFunction = new Function(FilterKind.EQUALS.name());
+ equalsFunction.setOperands(new ArrayList<>(List.of(expression,
RequestUtils.getLiteralExpression(true))));
+ Expression wrapped = new Expression(ExpressionType.FUNCTION);
+ wrapped.setFunctionCall(equalsFunction);
+ return wrapped;
+ }
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
index 3171ec0bef9..aecd0590c2d 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilder.java
@@ -38,15 +38,24 @@ import
org.apache.pinot.query.planner.plannode.TableScanNode;
* Converts an MSE leaf stage {@link PlanNode} tree to a {@link PinotQuery}
for routing purposes. Used by the broker
* pruning path in {@link WorkerManager} to build a filter-bearing routing
query that enables segment pruning at the
* broker.
+ *
+ * <p>This is the {@link PlanNode} (logical-planner) counterpart of {@link
LeafStageToPinotQuery}, which performs the
+ * same leaf-stage → routing-query fold over Calcite {@link
org.apache.calcite.rel.RelNode}s for the physical
+ * optimizer. The two traversals are intentionally kept as separate builders
because they walk different node
+ * hierarchies; the shared filter-normalization logic ({@code
ensureFilterIsFunctionExpression} /
+ * {@code addFilterExpression}) lives in {@link LeafStageToPinotQuery}. Keep
the leaf-boundary and filter-combining
+ * behavior of the two in sync.
*/
public class PlanNodeRoutingQueryBuilder {
private PlanNodeRoutingQueryBuilder() {
}
/**
- * Converts a PlanNode leaf stage root to a {@link PinotQuery} for routing
purposes. Only handles Project, Filter
- * and TableScan nodes — other node types in the chain are silently skipped.
Callers should expect this method
- * to throw on malformed trees (e.g., missing TableScanNode, multi-input
nodes) and handle failures gracefully.
+ * Converts a PlanNode leaf stage root to a {@link PinotQuery} for routing
purposes. Folds Filter and Project nodes
+ * bottom-up starting from the TableScan, stopping at the first node of any
other type (a leaf boundary such as an
+ * un-split aggregate): nodes above the boundary operate on a different row
space and must not contribute to the
+ * routing filter. Callers should expect this method to throw on malformed
trees (e.g., missing TableScanNode,
+ * multi-input nodes) and handle failures gracefully.
*
* @param tableName the table name (with or without type suffix). Passed
explicitly because PlanNode trees
* don't carry the resolved table name.
@@ -61,13 +70,20 @@ public class PlanNodeRoutingQueryBuilder {
"Could not find table scan");
TableScanNode tableScan = (TableScanNode) bottomToTopNodes.get(0);
PinotQuery pinotQuery = initializePinotQueryForTableScan(tableName,
tableScan);
- for (PlanNode parentNode : bottomToTopNodes) {
+ for (int i = 1; i < bottomToTopNodes.size(); i++) {
+ PlanNode parentNode = bottomToTopNodes.get(i);
if (parentNode instanceof FilterNode) {
if (!skipFilter) {
handleFilter((FilterNode) parentNode, pinotQuery);
}
} else if (parentNode instanceof ProjectNode) {
handleProject((ProjectNode) parentNode, pinotQuery);
+ } else {
+ // Leaf boundary: the first node that is neither Filter nor Project
(e.g. an un-split DIRECT aggregate)
+ // changes the row space -- InputRefs in nodes above it index that
node's output, not the scan/project
+ // columns -- so folding anything above it (e.g. a HAVING filter)
would mis-resolve columns and corrupt the
+ // routing filter. Everything below this boundary is a genuine
row-level condition; ignore everything above.
+ break;
}
}
return pinotQuery;
@@ -100,6 +116,7 @@ public class PlanNodeRoutingQueryBuilder {
private static void handleFilter(FilterNode filter, PinotQuery pinotQuery) {
Expression filterExpression =
CalciteRexExpressionParser.toExpression(filter.getCondition(),
pinotQuery.getSelectList());
-
pinotQuery.setFilterExpression(LeafStageToPinotQuery.ensureFilterIsFunctionExpression(filterExpression));
+ LeafStageToPinotQuery.addFilterExpression(pinotQuery,
+
LeafStageToPinotQuery.ensureFilterIsFunctionExpression(filterExpression));
}
}
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 a0edc857847..3f84a2c4881 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
@@ -538,9 +538,19 @@ public class WorkerManager {
PinotQuery routingPinotQuery =
extractRoutingQuery(fragment.getFragmentRoot(), tableName, context);
// When broker pruning is enabled, routingPinotQuery carries the leaf
stage filter so that segment pruners can
// eliminate segments. When disabled (null), fall back to an unfiltered
SELECT * routing request.
- Map<String, RoutingTable> routingTableMap = routingPinotQuery != null
- ? getRoutingTable(routingPinotQuery, context.getRequestId())
- : getRoutingTable(tableName, context.getRequestId(),
context.getPlannerContext().getOptions());
+ Map<String, RoutingTable> routingTableMap = null;
+ if (routingPinotQuery != null) {
+ try {
+ routingTableMap = getRoutingTable(routingPinotQuery,
context.getRequestId());
+ } catch (RuntimeException e) {
+ // Pruning is best-effort: never fail a query that would otherwise
route successfully unpruned.
+ LOGGER.warn("Broker pruning skipped for table {} due to routing
failure", tableName, e);
+ routingPinotQuery = null;
+ }
+ }
+ if (routingTableMap == null) {
+ routingTableMap = getRoutingTable(tableName, context.getRequestId(),
context.getPlannerContext().getOptions());
+ }
Preconditions.checkState(!routingTableMap.isEmpty(), "Unable to find
routing entries for table: %s", tableName);
// acquire time boundary info if it is a hybrid table.
@@ -807,18 +817,40 @@ public class WorkerManager {
: logicalTableRouteInfo.getRealtimeTableName());
PinotQuery routingPinotQuery =
extractRoutingQuery(fragment.getFragmentRoot(), rawTableName, context);
+ boolean routed = false;
+ if (routingPinotQuery != null) {
+ try {
+ calculateLogicalTableRoutes(tableRouteProvider, logicalTableRouteInfo,
routingPinotQuery, queryOptions,
+ context);
+
context.addNumSegmentsPrunedByBroker(logicalTableRouteInfo.getNumPrunedSegmentsTotal());
+ routed = true;
+ } catch (RuntimeException e) {
+ // Pruning is best-effort: never fail a query that would otherwise
route successfully unpruned. Re-running
+ // unfiltered below is safe because calculateRoutes assigns (rather
than accumulates) the per-table routing
+ // state, fully overwriting anything the failed attempt wrote.
+ LOGGER.warn("Broker pruning skipped for logical table {} due to
routing failure", rawTableName, e);
+ }
+ }
+ if (!routed) {
+ calculateLogicalTableRoutes(tableRouteProvider, logicalTableRouteInfo,
null, queryOptions, context);
+ }
+
+ assignTableSegmentsToWorkers(logicalTableRouteInfo, metadata);
+ }
+
+ /**
+ * Builds the per-table-type routing {@link BrokerRequest}s for a logical
table (filter-bearing when
+ * {@code routingPinotQuery} is non-null, bare {@code SELECT *} otherwise)
and calculates the routes.
+ */
+ private void calculateLogicalTableRoutes(LogicalTableRouteProvider
tableRouteProvider,
+ LogicalTableRouteInfo logicalTableRouteInfo, @Nullable PinotQuery
routingPinotQuery,
+ Map<String, String> queryOptions, DispatchablePlanContext context) {
BrokerRequest offlineBrokerRequest = logicalTableRouteInfo.hasOffline() ?
buildLogicalTableRoutingBrokerRequest(
logicalTableRouteInfo.getOfflineTableName(), routingPinotQuery,
queryOptions) : null;
BrokerRequest realtimeBrokerRequest = logicalTableRouteInfo.hasRealtime()
? buildLogicalTableRoutingBrokerRequest(
logicalTableRouteInfo.getRealtimeTableName(), routingPinotQuery,
queryOptions) : null;
-
tableRouteProvider.calculateRoutes(logicalTableRouteInfo, _routingManager,
offlineBrokerRequest,
realtimeBrokerRequest, context.getRequestId());
- if (routingPinotQuery != null) {
-
context.addNumSegmentsPrunedByBroker(logicalTableRouteInfo.getNumPrunedSegmentsTotal());
- }
-
- assignTableSegmentsToWorkers(logicalTableRouteInfo, metadata);
}
/**
@@ -1008,8 +1040,8 @@ public class WorkerManager {
routingTableMap = getRoutingTable(routingPinotQuery, requestId);
} catch (RuntimeException e) {
// Pruning is best-effort: never fail a query that would otherwise route
successfully unpruned.
- LOGGER.warn("Broker pruning skipped for partitioned table {} due to
routing failure: {}",
- routingPinotQuery.getDataSource().getTableName(), e.getMessage());
+ LOGGER.warn("Broker pruning skipped for partitioned table {} due to
routing failure",
+ routingPinotQuery.getDataSource().getTableName(), e);
return null;
}
if (routingTableMap.isEmpty()) {
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
index b80ca4b4d48..76ac02898af 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
@@ -18,22 +18,82 @@
*/
package org.apache.pinot.query.planner.logical;
+import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
+import org.apache.calcite.plan.RelOptCluster;
+import org.apache.calcite.plan.RelOptTable;
+import org.apache.calcite.plan.volcano.VolcanoPlanner;
+import org.apache.calcite.rel.logical.LogicalAggregate;
+import org.apache.calcite.rel.logical.LogicalFilter;
+import org.apache.calcite.rel.logical.LogicalTableScan;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.util.ImmutableBitSet;
import org.apache.pinot.common.request.Expression;
import org.apache.pinot.common.request.ExpressionType;
import org.apache.pinot.common.request.Function;
+import org.apache.pinot.common.request.PinotQuery;
import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.query.type.TypeFactory;
+import org.mockito.Mockito;
+import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
public class LeafStageToPinotQueryTest {
+ // --- RelNode leaf-boundary handling (createPinotQueryForRouting, the
physical-optimizer path) ---
+
+ @Test
+ public void testCreatePinotQueryForRoutingStopsAtLeafBoundary() {
+ // Build a RelNode leaf tree: TableScan(col1 INT, col2 INT) -> Filter(col1
= 5) -> Aggregate -> Filter($1 > 10).
+ // The Aggregate is a leaf boundary: InputRefs above it index the
aggregate output, not the scan columns, so its
+ // HAVING filter must NOT be folded into the routing query. The routing
filter must be exactly the WHERE (col1 = 5).
+ // Removing the leaf-boundary break in createPinotQueryForRouting makes
this fail (HAVING folds in against the wrong
+ // row space). This is the RelNode-path mirror of
PlanNodeRoutingQueryBuilderTest#...StopsAtLeafBoundary.
+ TypeFactory typeFactory = new TypeFactory();
+ RexBuilder rexBuilder = new RexBuilder(typeFactory);
+ RelOptCluster cluster = RelOptCluster.create(new VolcanoPlanner(),
rexBuilder);
+ RelDataType intType = typeFactory.createSqlType(SqlTypeName.INTEGER);
+ RelDataType rowType = typeFactory.builder().add("col1",
intType).add("col2", intType).build();
+
+ RelOptTable table = Mockito.mock(RelOptTable.class);
+ Mockito.when(table.getRowType()).thenReturn(rowType);
+ Mockito.when(table.getRelOptSchema()).thenReturn(null);
+ LogicalTableScan tableScan = LogicalTableScan.create(cluster, table,
List.of());
+
+ RexNode whereCondition = rexBuilder.makeCall(SqlStdOperatorTable.EQUALS,
+ rexBuilder.makeInputRef(intType, 0),
rexBuilder.makeExactLiteral(BigDecimal.valueOf(5), intType));
+ LogicalFilter whereFilter = LogicalFilter.create(tableScan,
whereCondition);
+
+ LogicalAggregate aggregate =
+ LogicalAggregate.create(whereFilter, List.of(), ImmutableBitSet.of(0),
null, List.of());
+
+ // HAVING references the aggregate's output (index 0 here = the group
key). Its InputRef space is the aggregate
+ // output, so folding it into the routing query would mis-resolve against
the scan columns.
+ RexNode havingCondition =
rexBuilder.makeCall(SqlStdOperatorTable.GREATER_THAN,
+
rexBuilder.makeInputRef(aggregate.getRowType().getFieldList().get(0).getType(),
0),
+ rexBuilder.makeExactLiteral(BigDecimal.valueOf(10), intType));
+ LogicalFilter havingFilter = LogicalFilter.create(aggregate,
havingCondition);
+
+ PinotQuery pinotQuery =
LeafStageToPinotQuery.createPinotQueryForRouting("testTable", havingFilter,
false);
+
+ Expression filter = pinotQuery.getFilterExpression();
+ assertNotNull(filter);
+ assertEquals(filter.getFunctionCall().getOperator(), "EQUALS");
+
assertEquals(filter.getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col1");
+ }
+
// --- Basic expression type handling ---
@Test
@@ -94,6 +154,89 @@ public class LeafStageToPinotQueryTest {
assertEquals(result.getFunctionCall().getOperator(), "EQUALS");
}
+ @DataProvider(name = "validFilterKindOperators")
+ public static Object[][] validFilterKindOperators() {
+ // Function-style predicates whose operator IS a FilterKind must NOT be
wrapped as EQUALS(fn, true) -- only
+ // non-FilterKind scalar functions (e.g. contains) are wrapped. This
guards the !isValidEnum boundary.
+ return new Object[][]{
+ {"IN"}, {"NOT_IN"}, {"RANGE"}, {"LIKE"}, {"REGEXP_LIKE"},
{"TEXT_MATCH"}, {"JSON_MATCH"},
+ {"IS_NULL"}, {"IS_NOT_NULL"}, {"NOT_EQUALS"}, {"BETWEEN"}
+ };
+ }
+
+ @Test(dataProvider = "validFilterKindOperators")
+ public void testValidFilterKindFunctionPassedThroughUnchanged(String
operator) {
+ Function function = new Function(operator);
+ function.setOperands(new
ArrayList<>(List.of(RequestUtils.getIdentifierExpression("col"))));
+ Expression funcExpr = new Expression(ExpressionType.FUNCTION);
+ funcExpr.setFunctionCall(function);
+
+ Expression result =
LeafStageToPinotQuery.ensureFilterIsFunctionExpression(funcExpr);
+
+ assertSame(result, funcExpr, operator + " (a valid FilterKind) must not be
wrapped");
+ assertEquals(result.getFunctionCall().getOperator(), operator);
+ }
+
+ @Test
+ public void testBooleanScalarFunctionWrappedAsEqualsTrue() {
+ // A boolean scalar function used directly as a predicate (WHERE
contains(col, 'foo')) is not a FilterKind;
+ // segment pruners resolve operators via FilterKind.valueOf and would
throw on it. It must be wrapped as
+ // EQUALS(contains(col, 'foo'), true), mirroring the single-stage engine's
PredicateComparisonRewriter.
+ Expression containsExpr = makeCompound("contains",
+ RequestUtils.getIdentifierExpression("col"),
RequestUtils.getLiteralExpression("foo"));
+
+ Expression result =
LeafStageToPinotQuery.ensureFilterIsFunctionExpression(containsExpr);
+
+ assertNotNull(result);
+ assertEquals(result.getFunctionCall().getOperator(), "EQUALS");
+ List<Expression> operands = result.getFunctionCall().getOperands();
+ assertEquals(operands.size(), 2);
+ assertEquals(operands.get(0).getFunctionCall().getOperator(), "contains");
+ assertTrue(operands.get(1).getLiteral().getBoolValue());
+ }
+
+ @Test
+ public void testAndWithBooleanScalarFunctionWrapsIt() {
+ assertCompoundWithBooleanScalarFunctionWraps("AND");
+ }
+
+ @Test
+ public void testOrWithBooleanScalarFunctionWrapsIt() {
+ assertCompoundWithBooleanScalarFunctionWraps("OR");
+ }
+
+ private void assertCompoundWithBooleanScalarFunctionWraps(String op) {
+ // OP(contains(col, 'foo'), EQUALS(col2, 'val')) → OP(EQUALS(contains(col,
'foo'), true), EQUALS(col2, 'val'))
+ Expression containsExpr = makeCompound("contains",
+ RequestUtils.getIdentifierExpression("col"),
RequestUtils.getLiteralExpression("foo"));
+ Expression compoundExpr = makeCompound(op, containsExpr,
makeEquals("col2", "val"));
+
+ Expression result =
LeafStageToPinotQuery.ensureFilterIsFunctionExpression(compoundExpr);
+
+ assertNotNull(result);
+ assertEquals(result.getFunctionCall().getOperator(), op);
+ List<Expression> operands = result.getFunctionCall().getOperands();
+ assertEquals(operands.size(), 2);
+ assertEquals(operands.get(0).getFunctionCall().getOperator(), "EQUALS");
+
assertEquals(operands.get(0).getFunctionCall().getOperands().get(0).getFunctionCall().getOperator(),
"contains");
+ assertEquals(operands.get(1).getFunctionCall().getOperator(), "EQUALS");
+ }
+
+ @Test
+ public void testNotWithBooleanScalarFunctionWrapsOperand() {
+ Expression containsExpr = makeCompound("contains",
+ RequestUtils.getIdentifierExpression("col"),
RequestUtils.getLiteralExpression("foo"));
+ Expression notExpr = makeCompound("NOT", containsExpr);
+
+ Expression result =
LeafStageToPinotQuery.ensureFilterIsFunctionExpression(notExpr);
+
+ assertNotNull(result);
+ assertEquals(result.getFunctionCall().getOperator(), "NOT");
+ Expression operand = result.getFunctionCall().getOperands().get(0);
+ assertEquals(operand.getFunctionCall().getOperator(), "EQUALS");
+
assertEquals(operand.getFunctionCall().getOperands().get(0).getFunctionCall().getOperator(),
"contains");
+ }
+
// --- AND/OR handling (shared logic, tested symmetrically) ---
@Test
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
index a1532eff487..125249b64ea 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/routing/PlanNodeRoutingQueryBuilderTest.java
@@ -19,9 +19,11 @@
package org.apache.pinot.query.routing;
import java.util.List;
+import org.apache.pinot.common.request.Expression;
import org.apache.pinot.common.request.PinotQuery;
import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.AggregateNode;
import org.apache.pinot.query.planner.plannode.FilterNode;
import org.apache.pinot.query.planner.plannode.PlanNode;
import org.apache.pinot.query.planner.plannode.ProjectNode;
@@ -58,4 +60,62 @@ public class PlanNodeRoutingQueryBuilderTest {
assertEquals(pinotQuery.getFilterExpression().getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col1");
}
+
+ @Test
+ public void testCreatePinotQueryForRoutingCombinesStackedLeafFilters() {
+ // Two filter nodes below the leaf boundary (both operate on the scan row
stream) must be AND-combined into the
+ // routing filter, not overwritten -- otherwise a genuine row-level
condition is silently dropped and pruning
+ // becomes incorrect. Reverting the builder to overwrite-the-filter would
make this fail.
+ TableScanNode tableScanNode =
+ new TableScanNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
"testTable", List.of("col1", "col2"));
+ FilterNode lowerFilter = new FilterNode(1, TEST_SCHEMA,
PlanNode.NodeHint.EMPTY, List.of(tableScanNode),
+ new RexExpression.FunctionCall(DataSchema.ColumnDataType.BOOLEAN,
"EQUALS",
+ List.of(new RexExpression.InputRef(0),
+ new RexExpression.Literal(DataSchema.ColumnDataType.STRING,
"foo"))));
+ FilterNode upperFilter = new FilterNode(1, TEST_SCHEMA,
PlanNode.NodeHint.EMPTY, List.of(lowerFilter),
+ new RexExpression.FunctionCall(DataSchema.ColumnDataType.BOOLEAN,
"EQUALS",
+ List.of(new RexExpression.InputRef(1),
+ new RexExpression.Literal(DataSchema.ColumnDataType.STRING,
"bar"))));
+
+ PinotQuery pinotQuery =
PlanNodeRoutingQueryBuilder.createPinotQueryForRouting("testTable",
upperFilter, false);
+
+ Expression filter = pinotQuery.getFilterExpression();
+ assertNotNull(filter);
+ assertEquals(filter.getFunctionCall().getOperator(), "AND");
+ List<Expression> operands = filter.getFunctionCall().getOperands();
+ assertEquals(operands.size(), 2);
+ // Combined as AND(lower, upper): EQUALS(col1,'foo') AND
EQUALS(col2,'bar').
+
assertEquals(operands.get(0).getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col1");
+
assertEquals(operands.get(1).getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col2");
+ }
+
+ @Test
+ public void testCreatePinotQueryForRoutingStopsAtLeafBoundary() {
+ // A node that is neither Filter nor Project (here an AggregateNode) is a
leaf boundary: anything above it operates
+ // on a different (post-aggregate) row space, so its filters/projects must
NOT be folded into the routing query.
+ // The routing query should carry only the WHERE filter below the
aggregate.
+ TableScanNode tableScanNode =
+ new TableScanNode(1, TEST_SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
"testTable", List.of("col1", "col2"));
+ FilterNode whereFilter = new FilterNode(1, TEST_SCHEMA,
PlanNode.NodeHint.EMPTY, List.of(tableScanNode),
+ new RexExpression.FunctionCall(DataSchema.ColumnDataType.BOOLEAN,
"EQUALS",
+ List.of(new RexExpression.InputRef(0),
+ new RexExpression.Literal(DataSchema.ColumnDataType.STRING,
"foo"))));
+ DataSchema aggSchema = new DataSchema(new String[]{"col1", "EXPR$1"},
+ new DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.STRING,
DataSchema.ColumnDataType.LONG});
+ AggregateNode aggregateNode = new AggregateNode(1, aggSchema,
PlanNode.NodeHint.EMPTY, List.of(whereFilter),
+ List.of(new RexExpression.FunctionCall(DataSchema.ColumnDataType.LONG,
"COUNT", List.of())),
+ List.of(), List.of(0), AggregateNode.AggType.DIRECT, false, List.of(),
-1);
+ // A HAVING filter above the aggregate; its InputRef(1) indexes the
aggregate output, not the scan columns.
+ FilterNode havingFilter = new FilterNode(1, aggSchema,
PlanNode.NodeHint.EMPTY, List.of(aggregateNode),
+ new RexExpression.FunctionCall(DataSchema.ColumnDataType.BOOLEAN,
"GREATER_THAN",
+ List.of(new RexExpression.InputRef(1),
+ new RexExpression.Literal(DataSchema.ColumnDataType.LONG,
10))));
+
+ PinotQuery pinotQuery =
PlanNodeRoutingQueryBuilder.createPinotQueryForRouting("testTable",
havingFilter, false);
+
+ Expression filter = pinotQuery.getFilterExpression();
+ assertNotNull(filter);
+ assertEquals(filter.getFunctionCall().getOperator(), "EQUALS");
+
assertEquals(filter.getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col1");
+ }
}
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 0c5ac12cfeb..4d99eeb537e 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
@@ -31,6 +31,7 @@ import org.apache.helix.zookeeper.datamodel.ZNRecord;
import org.apache.pinot.common.config.provider.TableCache;
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.RoutingManager;
import org.apache.pinot.core.routing.RoutingTable;
@@ -46,6 +47,7 @@ import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.sql.FilterKind;
import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
import org.testng.annotations.Test;
@@ -257,7 +259,7 @@ public class WorkerManagerTest {
}
@Test
- public void testBrokerPruningDefaultsToUnfilteredRoutingOnThisPath() {
+ public void testBrokerPruningOnByDefaultAndExplicitlyDisabledOnThisPath() {
Schema schema = getSchemaBuilder("testTable").build();
ServerInstance server = getServerInstance("localhost", 1);
Map<String, ServerInstance> serverInstanceMap =
Map.of(server.getInstanceId(), server);
@@ -280,19 +282,255 @@ public class WorkerManagerTest {
QueryEnvironment queryEnvironment = new
QueryEnvironment(CommonConstants.DEFAULT_DATABASE, tableCache,
workerManager);
- // Without SET useBrokerPruning=true, this path should fall back to
unfiltered SELECT * routing.
+ // Broker pruning is on by default: without any SET, the routing query
should carry the leaf filter.
try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
"SELECT col2 FROM testTable WHERE col1 = 'foo'")) {
DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
assertNotNull(dispatchableSubPlan);
}
+ BrokerRequest brokerRequest =
routingManager.getCapturedRoutingRequest("testTable_OFFLINE");
+ assertNotNull(brokerRequest);
+ assertNotNull(brokerRequest.getPinotQuery().getFilterExpression());
+
+ // Explicitly disabling falls back to unfiltered SELECT * routing (segment
lookup only).
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=false; SELECT col2 FROM testTable WHERE col1 =
'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertNotNull(dispatchableSubPlan);
+ }
+ brokerRequest =
routingManager.getCapturedRoutingRequest("testTable_OFFLINE");
+ assertNotNull(brokerRequest);
+ assertNull(brokerRequest.getPinotQuery().getFilterExpression());
+ }
+
+ @Test
+ public void testBrokerPruningWrapsBooleanScalarFunctionPredicate() {
+ // Regression: a boolean scalar function used directly as a predicate
(WHERE contains(...)) is not a FilterKind,
+ // and segment pruners resolve filter operators via FilterKind.valueOf.
The routing query must carry it wrapped
+ // as EQUALS(contains(...), true); before this was handled, such queries
failed to plan with
+ // "No enum constant FilterKind.contains" when broker pruning was enabled.
+ Schema schema = getSchemaBuilder("testTable").build();
+ ServerInstance server = getServerInstance("localhost", 1);
+ Map<String, ServerInstance> serverInstanceMap =
Map.of(server.getInstanceId(), server);
+ RoutingTable routingTable = new RoutingTable(Map.of(server, new
SegmentsToQuery(List.of("segment1"), List.of())),
+ List.of(), 0);
+ CapturingRoutingManager routingManager = new
CapturingRoutingManager(serverInstanceMap,
+ Map.of("testTable_OFFLINE", routingTable));
+
+ QueryEnvironment queryEnvironment = newQueryEnvironment(schema,
routingManager);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SELECT col2 FROM testTable WHERE contains(col1, 'foo')")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertNotNull(dispatchableSubPlan);
+ }
+
+ BrokerRequest brokerRequest =
routingManager.getCapturedRoutingRequest("testTable_OFFLINE");
+ assertNotNull(brokerRequest);
+ Expression filterExpression =
brokerRequest.getPinotQuery().getFilterExpression();
+ assertNotNull(filterExpression);
+ assertEquals(filterExpression.getFunctionCall().getOperator(), "EQUALS");
+ Expression wrappedFunction =
filterExpression.getFunctionCall().getOperands().get(0);
+ assertNotNull(wrappedFunction.getFunctionCall());
+ assertEquals(wrappedFunction.getFunctionCall().getOperator(), "contains");
+ }
+
+ @Test
+ public void testBrokerPruningIgnoresFilterAboveLeafAggregate() {
+ // is_partitioned_by_group_by_keys produces a DIRECT (un-split) aggregate
with no exchange under it, so the
+ // HAVING filter lands in the SAME leaf fragment, above the aggregate. Its
InputRefs index the aggregate's
+ // OUTPUT row space ([col1, SUM(col3)]), not the scan columns: folding it
into the routing query would
+ // mis-resolve the refs against scan columns AND overwrite the genuine
WHERE filter, causing incorrect
+ // pruning. The routing query must carry exactly the WHERE filter and
nothing above the aggregate boundary.
+ Schema schema = getSchemaBuilder("testTable").build();
+ ServerInstance server = getServerInstance("localhost", 1);
+ Map<String, ServerInstance> serverInstanceMap =
Map.of(server.getInstanceId(), server);
+ RoutingTable routingTable = new RoutingTable(Map.of(server, new
SegmentsToQuery(List.of("segment1"), List.of())),
+ List.of(), 0);
+ CapturingRoutingManager routingManager = new
CapturingRoutingManager(serverInstanceMap,
+ Map.of("testTable_OFFLINE", routingTable));
+
+ QueryEnvironment queryEnvironment = newQueryEnvironment(schema,
routingManager);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SELECT /*+ aggOptions(is_partitioned_by_group_by_keys='true') */
col1, SUM(col3) FROM testTable "
+ + "WHERE col2 = 'x' GROUP BY col1 HAVING SUM(col3) > 10")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertNotNull(dispatchableSubPlan);
+ }
+
+ BrokerRequest brokerRequest =
routingManager.getCapturedRoutingRequest("testTable_OFFLINE");
+ assertNotNull(brokerRequest);
+ Expression filterExpression =
brokerRequest.getPinotQuery().getFilterExpression();
+ assertNotNull(filterExpression);
+ assertEquals(filterExpression.getFunctionCall().getOperator(), "EQUALS");
+
assertEquals(filterExpression.getFunctionCall().getOperands().get(0).getIdentifier().getName(),
"col2");
+ }
+
+ @Test
+ public void testBrokerPruningPhysicalOptimizerRoutingFilterExcludesHaving() {
+ // The physical optimizer (usePhysicalOptimizer=true, where broker pruning
is already on by default) builds its
+ // routing query via the shared LeafStageToPinotQuery. This exercises that
path end-to-end: the captured routing
+ // filter must carry only the WHERE predicate and must NOT contain the
HAVING predicate. (On the v2 path the
+ // aggregate is split across an exchange, so HAVING stays out of the leaf;
this guards that the shared builder
+ // produces a correct WHERE-only routing filter on the v2 path -- the
un-split-aggregate boundary case that the
+ // leaf-boundary break specifically protects is covered on the logical
path by
+ // testBrokerPruningIgnoresFilterAboveLeafAggregate and
PlanNodeRoutingQueryBuilderTest.)
+ Schema schema = getSchemaBuilder("testTable").build();
+ ServerInstance server = getServerInstance("localhost", 1);
+ Map<String, ServerInstance> serverInstanceMap =
Map.of(server.getInstanceId(), server);
+ RoutingTable routingTable = new RoutingTable(Map.of(server, new
SegmentsToQuery(List.of("segment1"), List.of())),
+ List.of(), 0);
+ CapturingRoutingManager routingManager = new
CapturingRoutingManager(serverInstanceMap,
+ Map.of("testTable_OFFLINE", routingTable));
+
+ QueryEnvironment queryEnvironment = newQueryEnvironment(schema,
routingManager);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET usePhysicalOptimizer=true; SELECT col1, SUM(col3) FROM testTable "
+ + "WHERE col2 = 'x' GROUP BY col1 HAVING SUM(col3) > 10")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertNotNull(dispatchableSubPlan);
+ }
+
+ BrokerRequest brokerRequest =
routingManager.getCapturedRoutingRequest("testTable_OFFLINE");
+ assertNotNull(brokerRequest, "Physical optimizer should route through the
capturing routing manager");
+ Expression filterExpression =
brokerRequest.getPinotQuery().getFilterExpression();
+ assertNotNull(filterExpression);
+ // The routing filter is the WHERE predicate only; the HAVING
(GREATER_THAN on SUM) must not appear.
+ assertFalse(containsOperatorOnColumn(filterExpression, "GREATER_THAN"),
+ "HAVING predicate leaked into the physical-optimizer routing filter: "
+ filterExpression);
+ assertTrue(containsIdentifier(filterExpression, "col2"),
+ "WHERE predicate missing from the physical-optimizer routing filter: "
+ filterExpression);
+ }
+
+ private static boolean containsOperatorOnColumn(Expression expression,
String operator) {
+ if (expression == null || expression.getFunctionCall() == null) {
+ return false;
+ }
+ Function function = expression.getFunctionCall();
+ if (function.getOperator().equals(operator)) {
+ return true;
+ }
+ for (Expression operand : function.getOperands()) {
+ if (containsOperatorOnColumn(operand, operator)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ private static boolean containsIdentifier(Expression expression, String
identifier) {
+ if (expression == null) {
+ return false;
+ }
+ if (expression.getIdentifier() != null) {
+ return expression.getIdentifier().getName().equals(identifier);
+ }
+ if (expression.getFunctionCall() != null) {
+ for (Expression operand : expression.getFunctionCall().getOperands()) {
+ if (containsIdentifier(operand, identifier)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ @Test
+ public void testBrokerPruningAllPrunedLeafPlansAcrossExchangeShapes() {
+ // When the filter prunes every segment, the leaf gets zero workers.
Planning (including mailbox assignment,
+ // which runs before the all-leaves-empty short-circuit rewrite) must
still succeed for every exchange shape a
+ // leaf can feed: plain select, global sort/limit (singleton receiver),
aggregations, empty OVER() windows and
+ // set-ops. A planning exception here is a regression: the same query
planned fine with pruning off.
+ List<String> queries = List.of(
+ "SELECT col2 FROM testTable WHERE col1 = 'foo'",
+ "SELECT col2 FROM testTable WHERE col1 = 'foo' ORDER BY col2 LIMIT 5",
+ "SELECT COUNT(*) FROM testTable WHERE col1 = 'foo'",
+ "SELECT col1, COUNT(*) FROM testTable WHERE col1 = 'foo' GROUP BY col1
ORDER BY COUNT(*) LIMIT 3",
+ "SELECT SUM(col3) OVER () FROM testTable WHERE col1 = 'foo'",
+ "SELECT col2 FROM testTable WHERE col1 = 'foo' UNION ALL SELECT col2
FROM testTable",
+ "SELECT DISTINCT col2 FROM testTable WHERE col1 = 'foo' LIMIT 4",
+ // Dynamic-broadcast semi-join: the build side (subquery) is a
separate prunable leaf feeding a
+ // PIPELINE_BREAKER exchange into the main-scan leaf.
+ "SELECT /*+ joinOptions(join_strategy='dynamic_broadcast') */ col2
FROM testTable "
+ + "WHERE col2 IN (SELECT col2 FROM testTable WHERE col1 = 'foo')");
+ for (String query : queries) {
+ Schema schema = getSchemaBuilder("testTable").build();
+ ServerInstance server = getServerInstance("localhost", 1);
+ Map<String, ServerInstance> serverInstanceMap =
Map.of(server.getInstanceId(), server);
+ RoutingTable routingTable = new RoutingTable(Map.of(server, new
SegmentsToQuery(List.of("segment1"),
+ List.of())), List.of(), 0);
+ CapturingRoutingManager routingManager = new
CapturingRoutingManager(serverInstanceMap,
+ Map.of("testTable_OFFLINE", routingTable));
+ routingManager.setEmptyOnFilteredRouting(true);
+
+ QueryEnvironment queryEnvironment = newQueryEnvironment(schema,
routingManager);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(query)) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertNotNull(dispatchableSubPlan, "Planning failed for all-pruned
query: " + query);
+ } catch (RuntimeException e) {
+ throw new AssertionError("All-pruned leaf broke planning for query: "
+ query + " -- " + e, e);
+ }
+ }
+ }
+
+ @Test
+ public void testBrokerPruningFallsBackToUnfilteredRoutingOnRoutingFailure() {
+ // Pruning is best-effort: if routing the filtered query throws (e.g. a
segment pruner failing on an exotic
+ // filter shape), the query must still plan via the unfiltered SELECT *
fallback rather than fail.
+ Schema schema = getSchemaBuilder("testTable").build();
+ ServerInstance server = getServerInstance("localhost", 1);
+ Map<String, ServerInstance> serverInstanceMap =
Map.of(server.getInstanceId(), server);
+ RoutingTable routingTable = new RoutingTable(Map.of(server, new
SegmentsToQuery(List.of("segment1"), List.of())),
+ List.of(), 0);
+ CapturingRoutingManager routingManager = new
CapturingRoutingManager(serverInstanceMap,
+ Map.of("testTable_OFFLINE", routingTable), true);
+
+ QueryEnvironment queryEnvironment = newQueryEnvironment(schema,
routingManager);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SELECT col2 FROM testTable WHERE col1 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertNotNull(dispatchableSubPlan);
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
+ }
+
+ // The captured request is the fallback: unfiltered SELECT * (the filtered
attempt threw and was not recorded).
BrokerRequest brokerRequest =
routingManager.getCapturedRoutingRequest("testTable_OFFLINE");
assertNotNull(brokerRequest);
- // No filter should be present — the routing query is a plain SELECT *
used for segment lookup only.
assertNull(brokerRequest.getPinotQuery().getFilterExpression());
}
+ /**
+ * Mimics how segment pruners consume a routing filter: operators are
resolved via {@code FilterKind.valueOf}
+ * (which throws on non-FilterKind operators, e.g. bare boolean scalar
functions like {@code contains}) and
+ * AND/OR/NOT operands are walked recursively. Keeps the mock routing
managers honest: a routing query that would
+ * crash the real segment pruners also fails the unit tests.
+ */
+ private static void validatePrunableFilter(@Nullable Expression expression) {
+ if (expression == null || expression.getFunctionCall() == null) {
+ return;
+ }
+ Function function = expression.getFunctionCall();
+ FilterKind filterKind = FilterKind.valueOf(function.getOperator());
+ if (filterKind == FilterKind.AND || filterKind == FilterKind.OR ||
filterKind == FilterKind.NOT) {
+ for (Expression operand : function.getOperands()) {
+ validatePrunableFilter(operand);
+ }
+ }
+ }
+
+ /** Builds a QueryEnvironment over a single offline table "testTable" backed
by the given routing manager. */
+ private static QueryEnvironment newQueryEnvironment(Schema schema,
RoutingManager routingManager) {
+ Map<String, String> tableNameMap = new HashMap<>();
+ tableNameMap.put("testTable_OFFLINE", "testTable_OFFLINE");
+ tableNameMap.put("testTable", "testTable");
+ TableCache tableCache = mock(TableCache.class);
+ when(tableCache.getTableNameMap()).thenReturn(tableNameMap);
+ when(tableCache.getActualTableName(anyString())).thenAnswer(inv ->
tableNameMap.get(inv.getArgument(0)));
+ when(tableCache.getSchema(anyString())).thenReturn(schema);
+
when(tableCache.getTableConfig("testTable_OFFLINE")).thenReturn(mock(TableConfig.class));
+ WorkerManager workerManager = new WorkerManager("Broker_localhost",
"localhost", 3, routingManager);
+ return new QueryEnvironment(CommonConstants.DEFAULT_DATABASE, tableCache,
workerManager);
+ }
+
@Test
public void testBrokerPruningPreservesQueryOptionsOnRoutingRequest() {
Schema schema = getSchemaBuilder("testTable").build();
@@ -398,8 +636,8 @@ public class WorkerManagerTest {
}
@Test
- public void testBrokerPruningPartitionedLeafDisabledKeepsAllPartitions() {
- // Without SET useBrokerPruning=true the logical planner default (false)
applies, so no pruning occurs.
+ public void testBrokerPruningPartitionedLeafOnByDefault() {
+ // Broker pruning is on by default: without any SET, the partitioned leaf
prunes non-matching partitions.
QueryEnvironment queryEnvironment =
newPartitionedQueryEnvironment(new int[]{0, 1, 2, 3}, 4,
List.of("seg2"), 3);
try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
@@ -407,6 +645,23 @@ public class WorkerManagerTest {
+ "/*+ tableOptions(partition_function='hashcode',
partition_key='col1', partition_size='4') */ "
+ "WHERE col1 = 'foo'")) {
DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
+ assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 3);
+ DispatchablePlanFragment leaf = leafFragment(dispatchableSubPlan);
+ assertNotNull(leaf);
+ assertEquals(leaf.getWorkerIdToSegmentsMap().size(), 1);
+ }
+ }
+
+ @Test
+ public void testBrokerPruningPartitionedLeafDisabledKeepsAllPartitions() {
+ // Explicitly disabling broker pruning keeps all partitions assigned.
+ QueryEnvironment queryEnvironment =
+ newPartitionedQueryEnvironment(new int[]{0, 1, 2, 3}, 4,
List.of("seg2"), 3);
+ try (QueryEnvironment.CompiledQuery compiledQuery =
queryEnvironment.compile(
+ "SET useBrokerPruning=false; SELECT col2 FROM testTable "
+ + "/*+ tableOptions(partition_function='hashcode',
partition_key='col1', partition_size='4') */ "
+ + "WHERE col1 = 'foo'")) {
+ DispatchableSubPlan dispatchableSubPlan =
compiledQuery.planQuery(0).getQueryPlan();
assertEquals(dispatchableSubPlan.getNumSegmentsPrunedByBroker(), 0);
DispatchablePlanFragment leaf = leafFragment(dispatchableSubPlan);
assertNotNull(leaf);
@@ -1009,12 +1264,25 @@ public class WorkerManagerTest {
private static class CapturingRoutingManager implements RoutingManager {
private final Map<String, ServerInstance> _serverInstanceMap;
private final Map<String, RoutingTable> _routingTableByName;
+ private final boolean _throwOnFilteredRouting;
+ private boolean _emptyOnFilteredRouting;
private final Map<String, BrokerRequest> _capturedRoutingRequests = new
LinkedHashMap<>();
CapturingRoutingManager(Map<String, ServerInstance> serverInstanceMap,
Map<String, RoutingTable> routingTableByName) {
+ this(serverInstanceMap, routingTableByName, false);
+ }
+
+ CapturingRoutingManager(Map<String, ServerInstance> serverInstanceMap,
+ Map<String, RoutingTable> routingTableByName, boolean
throwOnFilteredRouting) {
_serverInstanceMap = serverInstanceMap;
_routingTableByName = routingTableByName;
+ _throwOnFilteredRouting = throwOnFilteredRouting;
+ }
+
+ /** When set, filter-bearing routing requests return an all-pruned (empty)
routing table. */
+ void setEmptyOnFilteredRouting(boolean emptyOnFilteredRouting) {
+ _emptyOnFilteredRouting = emptyOnFilteredRouting;
}
@Nullable
@@ -1030,8 +1298,15 @@ public class WorkerManagerTest {
@Nullable
@Override
public RoutingTable getRoutingTable(BrokerRequest brokerRequest, long
requestId) {
+ if (_throwOnFilteredRouting &&
brokerRequest.getPinotQuery().getFilterExpression() != null) {
+ throw new RuntimeException("Simulated routing failure for filtered
request");
+ }
+
validatePrunableFilter(brokerRequest.getPinotQuery().getFilterExpression());
String tableNameWithType = brokerRequest.getQuerySource().getTableName();
_capturedRoutingRequests.put(tableNameWithType, brokerRequest);
+ if (_emptyOnFilteredRouting &&
brokerRequest.getPinotQuery().getFilterExpression() != null) {
+ return new RoutingTable(Map.of(), List.of(), 1);
+ }
return _routingTableByName.get(tableNameWithType);
}
@@ -1124,6 +1399,7 @@ public class WorkerManagerTest {
if (_throwOnRouting) {
throw new RuntimeException("Simulated routing failure");
}
+
validatePrunableFilter(brokerRequest.getPinotQuery().getFilterExpression());
return
_routingTableByTable.get(brokerRequest.getQuerySource().getTableName());
}
diff --git
a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
index 55535941b97..641603e9468 100644
--- a/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/ExplainPhysicalPlans.json
@@ -70,13 +70,10 @@
"\n │ └── [2]@localhost:1|[1] PROJECT",
"\n │ └── [2]@localhost:1|[1] TABLE SCAN (a)
null",
"\n └── [1]@localhost:1|[0]
MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├── [3]@localhost:2|[2]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n ├── [3]@localhost:2|[3]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n ├── [3]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]} (Subtree
Omitted)",
- "\n └── [3]@localhost:1|[1]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]}",
- "\n └── [3]@localhost:1|[1] PROJECT",
- "\n └── [3]@localhost:1|[1] FILTER",
- "\n └── [3]@localhost:1|[1] TABLE SCAN
(b) null",
+ "\n └── [3]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[1]@localhost:1|[0],[1]@localhost:2|[1]}",
+ "\n └── [3]@localhost:1|[0] PROJECT",
+ "\n └── [3]@localhost:1|[0] FILTER",
+ "\n └── [3]@localhost:1|[0] TABLE SCAN
(b) null",
"\n"
]
},
@@ -184,13 +181,10 @@
"\n │ └── [2]@localhost:1|[1] FILTER",
"\n │ └── [2]@localhost:1|[1] TABLE
SCAN (a) null",
"\n └── [2]@localhost:1|[1]
MAIL_RECEIVE(BROADCAST_DISTRIBUTED)",
- "\n ├── [3]@localhost:2|[2]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]} (Subtree Omitted)",
- "\n ├── [3]@localhost:2|[3]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]} (Subtree Omitted)",
- "\n ├── [3]@localhost:1|[0]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]} (Subtree Omitted)",
- "\n └── [3]@localhost:1|[1]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]}",
- "\n └── [3]@localhost:1|[1] PROJECT",
- "\n └── [3]@localhost:1|[1]
FILTER",
- "\n └── [3]@localhost:1|[1]
TABLE SCAN (b) null",
+ "\n └── [3]@localhost:1|[0]
MAIL_SEND(BROADCAST_DISTRIBUTED)->{[2]@localhost:1|[0, 1],[2]@localhost:2|[2,
3]}",
+ "\n └── [3]@localhost:1|[0] PROJECT",
+ "\n └── [3]@localhost:1|[0]
FILTER",
+ "\n └── [3]@localhost:1|[0]
TABLE SCAN (b) null",
"\n"
]
},
@@ -402,13 +396,10 @@
"\n └── [3]@localhost:1|[0]
FILTER",
"\n └── [3]@localhost:1|[0]
AGGREGATE_FINAL",
"\n └──
[3]@localhost:1|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)",
- "\n ├──
[4]@localhost:2|[2]
MAIL_SEND(HASH_DISTRIBUTED)->{[3]@localhost:1|[0],[3]@localhost:2|[1]} (Subtree
Omitted)",
- "\n ├──
[4]@localhost:2|[3]
MAIL_SEND(HASH_DISTRIBUTED)->{[3]@localhost:1|[0],[3]@localhost:2|[1]} (Subtree
Omitted)",
- "\n ├──
[4]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[3]@localhost:1|[0],[3]@localhost:2|[1]} (Subtree
Omitted)",
- "\n └──
[4]@localhost:1|[1]
MAIL_SEND(HASH_DISTRIBUTED)->{[3]@localhost:1|[0],[3]@localhost:2|[1]}",
- "\n └──
[4]@localhost:1|[1] AGGREGATE_LEAF",
- "\n └──
[4]@localhost:1|[1] FILTER",
- "\n └──
[4]@localhost:1|[1] TABLE SCAN (b) null",
+ "\n └──
[4]@localhost:1|[0]
MAIL_SEND(HASH_DISTRIBUTED)->{[3]@localhost:1|[0],[3]@localhost:2|[1]}",
+ "\n └──
[4]@localhost:1|[0] AGGREGATE_LEAF",
+ "\n └──
[4]@localhost:1|[0] FILTER",
+ "\n └──
[4]@localhost:1|[0] TABLE SCAN (b) null",
"\n"
]
},
diff --git
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index ee25435988a..f617e91b74b 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -640,11 +640,13 @@ public class CommonConstants {
/**
* Whether to use broker pruning by default on the logical planner
(non-physical-optimizer) path.
* This value can always be overridden by {@link
Request.QueryOptionKey#USE_BROKER_PRUNING} query option.
- * Separated from {@link #CONFIG_OF_USE_BROKER_PRUNING} so the two paths
can be rolled out independently.
+ * Separated from {@link #CONFIG_OF_USE_BROKER_PRUNING} so the two paths
can be rolled out independently; both
+ * default to enabled now that all logical-planner leaf paths
(non-partitioned, partitioned, logical tables)
+ * support broker pruning. Actual pruning still requires segment pruners
to be configured on the table.
*/
public static final String CONFIG_OF_LOGICAL_PLANNER_USE_BROKER_PRUNING =
"pinot.broker.multistage.logical.planner.use.broker.pruning";
- public static final boolean DEFAULT_LOGICAL_PLANNER_USE_BROKER_PRUNING =
false;
+ public static final boolean DEFAULT_LOGICAL_PLANNER_USE_BROKER_PRUNING =
true;
/**
* Default server stage limit for lite mode queries.
@@ -1000,8 +1002,8 @@ public class CommonConstants {
// Not user-settable; used by servers to detect silent truncation at
execution time.
public static final String LITE_MODE_IMPLICIT_LEAF_STAGE_LIMIT =
"liteModeImplicitLeafStageLimit";
// Used by the MSE engine to enable broker-side segment pruning during
routing. The physical optimizer
- // path defaults to DEFAULT_USE_BROKER_PRUNING (true); the logical
planner path defaults to
- // DEFAULT_LOGICAL_PLANNER_USE_BROKER_PRUNING (false). Both can be
overridden per-query.
+ // path defaults to DEFAULT_USE_BROKER_PRUNING and the logical planner
path defaults to
+ // DEFAULT_LOGICAL_PLANNER_USE_BROKER_PRUNING (both true). Both can be
overridden per-query.
public static final String USE_BROKER_PRUNING = "useBrokerPruning";
// When lite mode is enabled, if this flag is set, we will run all the
non-leaf stage operators within the
// broker itself. That way, the MSE queries will model the scatter
gather pattern used by the V1 Engine.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]