This is an automated email from the ASF dual-hosted git repository.
gortiz 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 25e378b5b07 Prune unused passthrough columns from UNNEST output
(opt-in) (#18782)
25e378b5b07 is described below
commit 25e378b5b07198fa1ebf2d0671572c3d6bfd24c2
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Thu Jun 25 17:17:21 2026 +0200
Prune unused passthrough columns from UNNEST output (opt-in) (#18782)
---
.../MultiStageBrokerRequestHandler.java | 4 +
pinot-common/src/main/proto/plan.proto | 5 +
.../tests/custom/UnnestIntegrationTest.java | 38 ++++
.../org/apache/pinot/query/QueryEnvironment.java | 31 +++-
.../planner/logical/PinotLogicalQueryPlanner.java | 6 +-
.../planner/logical/RelToPlanNodeConverter.java | 195 +++++++++++++++++++++
.../pinot/query/planner/plannode/UnnestNode.java | 44 ++++-
.../query/planner/serde/PlanNodeDeserializer.java | 10 +-
.../query/planner/serde/PlanNodeSerializer.java | 4 +-
.../query/planner/plannode/UnnestNodeTest.java | 32 ++++
.../query/planner/serde/PlanNodeSerDeTest.java | 40 +++++
.../pinot/query/queries/UnnestSqlPlannerTest.java | 149 ++++++++++++++++
.../query/runtime/operator/UnnestOperator.java | 28 ++-
.../query/runtime/operator/UnnestOperatorTest.java | 113 ++++++++++++
.../apache/pinot/spi/utils/CommonConstants.java | 15 ++
15 files changed, 702 insertions(+), 12 deletions(-)
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
index 725c0925bf7..d9d60bbaad4 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
@@ -558,6 +558,9 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
int sortExchangeCopyThreshold = _config.getProperty(
CommonConstants.Broker.CONFIG_OF_SORT_EXCHANGE_COPY_THRESHOLD,
CommonConstants.Broker.DEFAULT_SORT_EXCHANGE_COPY_THRESHOLD);
+ boolean defaultUnnestColumnPruning = _config.getProperty(
+ CommonConstants.Broker.CONFIG_OF_UNNEST_COLUMN_PRUNING,
+ CommonConstants.Broker.DEFAULT_UNNEST_COLUMN_PRUNING);
WorkerManager workerManager =
QueryOptionsUtils.isMultiClusterRoutingEnabled(queryOptions, false)
? _multiClusterWorkerManager : _workerManager;
return QueryEnvironment.configBuilder()
@@ -569,6 +572,7 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
.isNullHandlingEnabled(QueryOptionsUtils.isNullHandlingEnabled(queryOptions))
.defaultInferPartitionHint(inferPartitionHint)
.defaultUseSpools(defaultUseSpool)
+ .defaultUnnestColumnPruning(defaultUnnestColumnPruning)
.defaultUseLeafServerForIntermediateStage(defaultUseLeafServerForIntermediateStage)
.defaultEnableGroupTrim(defaultEnableGroupTrim)
.defaultEnableDynamicFilteringSemiJoin(defaultEnableDynamicFilteringSemiJoin)
diff --git a/pinot-common/src/main/proto/plan.proto
b/pinot-common/src/main/proto/plan.proto
index 352cd92f4c1..8c2917d0968 100644
--- a/pinot-common/src/main/proto/plan.proto
+++ b/pinot-common/src/main/proto/plan.proto
@@ -236,6 +236,11 @@ message UnnestNode {
repeated int32 elementIndexes = 3;
// Optional absolute output index of the ordinality column in the stage
schema.
optional int32 ordinalityIndex = 4;
+ // When prunedPassthrough is true, the input-row column indexes copied into
the output, in output order.
+ // When absent/empty (old brokers), the operator copies the whole input row
(legacy behavior).
+ repeated int32 passthroughInputIndexes = 5;
+ // Whether the output schema was pruned to only the input columns referenced
downstream. Absent => false => legacy.
+ bool prunedPassthrough = 6;
}
enum WindowFrameType {
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UnnestIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UnnestIntegrationTest.java
index 5e824af94f6..c93522a16e3 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UnnestIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/UnnestIntegrationTest.java
@@ -26,6 +26,7 @@ import org.apache.avro.generic.GenericData;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.Schema;
+import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
@@ -303,6 +304,43 @@ public class UnnestIntegrationTest extends
CustomDataQueryClusterIntegrationTest
assertEquals(sum, 6 * getCountStarResult());
}
+ @DataProvider(name = "columnPruningQueries")
+ public Object[][] columnPruningQueries() {
+ String table = getTableName();
+ return new Object[][]{
+ // single array, source array dropped
+ {String.format("SELECT intCol, u.elem FROM %s CROSS JOIN
UNNEST(stringArrayCol) AS u(elem) "
+ + "ORDER BY intCol, u.elem", table)},
+ // select only the unnested element (zero passthrough)
+ {String.format("SELECT u.elem FROM %s CROSS JOIN
UNNEST(stringArrayCol) AS u(elem) ORDER BY u.elem", table)},
+ // WITH ORDINALITY (ordinality index recomputed against pruned output)
+ {String.format("SELECT intCol, u.elem, u.idx FROM %s CROSS JOIN
UNNEST(stringArrayCol) WITH ORDINALITY "
+ + "AS u(elem, idx) ORDER BY intCol, u.idx", table)},
+ // multiple arrays, both source arrays dropped
+ {String.format("SELECT intCol, u.longValue, u.stringValue FROM %s "
+ + "CROSS JOIN UNNEST(longArrayCol, stringArrayCol) AS u(longValue,
stringValue) "
+ + "ORDER BY intCol, u.longValue", table)},
+ // source array also selected (must be retained)
+ {String.format("SELECT intCol, stringArrayCol, u.elem FROM %s CROSS
JOIN UNNEST(stringArrayCol) AS u(elem) "
+ + "ORDER BY intCol, u.elem", table)}
+ };
+ }
+
+ @Test(dataProvider = "columnPruningQueries")
+ public void testCrossJoinUnnestColumnPruningMatchesDefault(String select)
+ throws Exception {
+ // Pruning input/passthrough columns from the UNNEST output must not
change results. Verified end-to-end on the
+ // multi-stage engine (the only engine that supports UNNEST CROSS JOIN),
with the flag toggled per query.
+ setUseMultiStageQueryEngine(true);
+ JsonNode defaultRows = postQuery(select).get("resultTable").get("rows");
+ JsonNode prunedRows = postQuery("SET unnestColumnPruning=true; " +
select).get("resultTable").get("rows");
+
+ assertNotNull(defaultRows);
+ assertNotNull(prunedRows);
+ assertEquals(prunedRows.toString(), defaultRows.toString(),
+ "Column pruning must not change UNNEST results for: " + select);
+ }
+
@Override
public String getTableName() {
return DEFAULT_TABLE_NAME;
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
index 2fb657de1fd..5801a0b6e5e 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
@@ -536,7 +536,7 @@ public class QueryEnvironment {
return pinotDispatchPlanner.createDispatchableSubPlanV2(plan.getLeft(),
plan.getRight());
}
SubPlan plan = PinotLogicalQueryPlanner.makePlan(relRoot, tracker,
useSpools(plannerContext.getOptions()),
- _envConfig.defaultHashFunction());
+ _envConfig.defaultHashFunction(),
pruneUnnestColumns(plannerContext.getOptions()));
PinotDispatchPlanner pinotDispatchPlanner =
new PinotDispatchPlanner(plannerContext,
_envConfig.getWorkerManager(), _envConfig.getRequestId(),
_envConfig.getTableCache());
@@ -719,6 +719,23 @@ public class QueryEnvironment {
return Boolean.parseBoolean(optionValue);
}
+ /**
+ * Whether to prune unused input (passthrough) columns from the UNNEST
output. Defaults to the broker config
+ * {@link Config#defaultUnnestColumnPruning()} (itself {@code false} by
default) because a broker emitting the
+ * smaller schema cannot be honored by an un-upgraded server; enable only
once all servers support it. Can be
+ * overridden per query via {@link
CommonConstants.Broker.Request.QueryOptionKey#UNNEST_COLUMN_PRUNING}.
+ * <p>
+ * NOTE: This is a no-op under {@code usePhysicalOptimizer} (the v2 path
does not go through
+ * {@link org.apache.pinot.query.planner.logical.RelToPlanNodeConverter}).
+ */
+ public boolean pruneUnnestColumns(Map<String, String> options) {
+ String optionValue =
options.get(CommonConstants.Broker.Request.QueryOptionKey.UNNEST_COLUMN_PRUNING);
+ if (optionValue == null) {
+ return _envConfig.defaultUnnestColumnPruning();
+ }
+ return Boolean.parseBoolean(optionValue);
+ }
+
@Value.Immutable
public interface Config {
@@ -780,6 +797,18 @@ public class QueryEnvironment {
return CommonConstants.Broker.DEFAULT_OF_SPOOLS;
}
+ /**
+ * Whether to prune unused input (passthrough) columns from the UNNEST
output by default.
+ *
+ * This is treated as the default value for the broker and it is expected
to be obtained from a Pinot configuration.
+ * This default value can be always overridden at query level by the query
option
+ * {@link
CommonConstants.Broker.Request.QueryOptionKey#UNNEST_COLUMN_PRUNING}.
+ */
+ @Value.Default
+ default boolean defaultUnnestColumnPruning() {
+ return CommonConstants.Broker.DEFAULT_UNNEST_COLUMN_PRUNING;
+ }
+
/// Whether to only use servers for leaf stages as the workers for the
intermediate stages.
/// This is useful to control the fanout of the query and reduce data
shuffling.
@Value.Default
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PinotLogicalQueryPlanner.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PinotLogicalQueryPlanner.java
index e1c38e462ec..eceb18102d9 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PinotLogicalQueryPlanner.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PinotLogicalQueryPlanner.java
@@ -46,6 +46,7 @@ import org.apache.pinot.query.planner.plannode.ExchangeNode;
import org.apache.pinot.query.planner.plannode.MailboxReceiveNode;
import org.apache.pinot.query.planner.plannode.MailboxSendNode;
import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.spi.utils.CommonConstants;
/**
@@ -60,8 +61,9 @@ public class PinotLogicalQueryPlanner {
*/
public static SubPlan makePlan(RelRoot relRoot,
@Nullable TransformationTracker.Builder<PlanNode, RelNode> tracker,
boolean useSpools,
- String hashFunction) {
- PlanNode rootNode = new RelToPlanNodeConverter(tracker,
hashFunction).toPlanNode(relRoot.rel);
+ String hashFunction, boolean pruneUnnestColumns) {
+ PlanNode rootNode = new RelToPlanNodeConverter(tracker, hashFunction,
+ !CommonConstants.Helix.DEFAULT_ENABLE_CASE_INSENSITIVE,
pruneUnnestColumns).toPlanNode(relRoot.rel);
PlanFragment rootFragment = planNodeToPlanFragment(rootNode, tracker,
useSpools, hashFunction);
return new SubPlan(rootFragment,
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
index c347e18a79d..66271d5c006 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
@@ -21,6 +21,7 @@ package org.apache.pinot.query.planner.logical;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Set;
import javax.annotation.Nullable;
@@ -111,6 +112,8 @@ public final class RelToPlanNodeConverter {
private final TransformationTracker.Builder<PlanNode, RelNode> _tracker;
private final String _hashFunction;
private final boolean _caseSensitive;
+ // When true, UNNEST output is pruned to drop input (passthrough) columns
not referenced downstream. Default off.
+ private final boolean _pruneUnnestColumns;
public RelToPlanNodeConverter(@Nullable
TransformationTracker.Builder<PlanNode, RelNode> tracker,
String hashFunction) {
@@ -119,9 +122,15 @@ public final class RelToPlanNodeConverter {
public RelToPlanNodeConverter(@Nullable
TransformationTracker.Builder<PlanNode, RelNode> tracker,
String hashFunction, boolean caseSensitive) {
+ this(tracker, hashFunction, caseSensitive, false);
+ }
+
+ public RelToPlanNodeConverter(@Nullable
TransformationTracker.Builder<PlanNode, RelNode> tracker,
+ String hashFunction, boolean caseSensitive, boolean pruneUnnestColumns) {
_tracker = tracker;
_hashFunction = hashFunction;
_caseSensitive = caseSensitive;
+ _pruneUnnestColumns = pruneUnnestColumns;
}
/**
@@ -227,6 +236,9 @@ public final class RelToPlanNodeConverter {
convertInputs(node.getInputs()), arrayExprs, tableFunctionContext);
}
+ // NOTE: Besides the main dispatch, this is also invoked from
tryPruneUnnestPassthrough. Its result is always used
+ // (either directly or rewritten into a pruned node), so it must remain free
of non-idempotent side effects beyond
+ // converting/tracking its own subtree.
private BasePlanNode convertLogicalCorrelate(LogicalCorrelate node) {
// Pattern: Correlate(left, Uncollect(Project(correlatedFields...)))
RelNode right = node.getRight();
@@ -709,10 +721,193 @@ public final class RelToPlanNodeConverter {
}
private ProjectNode convertLogicalProject(LogicalProject node) {
+ if (_pruneUnnestColumns) {
+ ProjectNode pruned = tryPruneUnnestPassthrough(node);
+ if (pruned != null) {
+ return pruned;
+ }
+ }
return new ProjectNode(DEFAULT_STAGE_ID, toDataSchema(node.getRowType()),
NodeHint.fromRelHints(node.getHints()),
convertInputs(node.getInputs()),
RexExpressionUtils.fromRexNodes(node.getProjects()));
}
+ /**
+ * When a Project sits directly above a CROSS JOIN UNNEST (a {@link
LogicalCorrelate} over {@link Uncollect}), prunes
+ * the input (passthrough) columns that the Project does not reference -
notably the unnested source array - from the
+ * {@link UnnestNode} output, so the operator does not copy them into every
exploded row. The Project's own
+ * {@code InputRef}s are remapped from the full correlate-output index space
to the pruned space.
+ * <p>
+ * Returns {@code null} (caller falls back to the default conversion,
preserving all passthrough columns) when the
+ * pattern is not recognized, the layout is not the standard one, or pruning
would be a no-op.
+ */
+ @Nullable
+ private ProjectNode tryPruneUnnestPassthrough(LogicalProject node) {
+ List<RelNode> inputs = node.getInputs();
+ if (inputs.size() != 1 || !(inputs.get(0) instanceof LogicalCorrelate)) {
+ return null;
+ }
+ LogicalCorrelate correlate = (LogicalCorrelate) inputs.get(0);
+ RelNode right = correlate.getRight();
+ // Only handle the UNNEST pattern, and only when no correlate-filter wraps
the Uncollect. With a wrapping filter,
+ // convertLogicalCorrelate emits a FilterNode, so the Project no longer
sits directly on the UnnestNode.
+ if (findUncollect(right) == null || findCorrelateFilter(right) != null) {
+ // Cheap structural check failed before any conversion: let the caller
fall back to the default conversion.
+ return null;
+ }
+ // Convert the correlate exactly once. From here we always return a
ProjectNode wrapping this converted input, so
+ // the caller never re-runs the (side-effecting, tracker-registering)
conversion of the left subtree.
+ BasePlanNode converted = convertLogicalCorrelate(correlate);
+ if (converted instanceof UnnestNode) {
+ ProjectNode pruned = buildPrunedProject(node, (UnnestNode) converted,
correlate);
+ if (pruned != null) {
+ return pruned;
+ }
+ }
+ // Not prunable (unexpected layout, no-op, or non-UnnestNode): wrap the
already-converted input in a Project.
+ if (_tracker != null) {
+ _tracker.trackCreation(correlate, converted);
+ }
+ return new ProjectNode(DEFAULT_STAGE_ID, toDataSchema(node.getRowType()),
NodeHint.fromRelHints(node.getHints()),
+ new ArrayList<>(List.of(converted)),
RexExpressionUtils.fromRexNodes(node.getProjects()));
+ }
+
+ /**
+ * Builds the pruned Project-over-UnnestNode for the standard CROSS JOIN
UNNEST layout, or returns {@code null} when
+ * the layout is non-standard or pruning would be a no-op (all passthrough
columns are referenced downstream). On
+ * success it registers the new UnnestNode with the tracker against {@code
correlate}.
+ */
+ @Nullable
+ private ProjectNode buildPrunedProject(LogicalProject node, UnnestNode full,
RelNode correlate) {
+ if (full.isPrunedPassthrough()) {
+ return null;
+ }
+ DataSchema fullSchema = full.getDataSchema();
+ int outSize = fullSchema.size();
+ int numArrays = full.getArrayExprs().size();
+ boolean withOrdinality = full.isWithOrdinality();
+ int leftCount = outSize - numArrays - (withOrdinality ? 1 : 0);
+ if (leftCount <= 0) {
+ // No passthrough columns to prune.
+ return null;
+ }
+ // Only handle the clean layout produced by the standard path: passthrough
columns occupy [0, leftCount) and the
+ // element (then ordinality) columns occupy the trailing region. Fall back
otherwise.
+ List<Integer> elementIndexes = full.getElementIndexes();
+ if (elementIndexes.size() != numArrays) {
+ return null;
+ }
+ for (int k = 0; k < numArrays; k++) {
+ if (elementIndexes.get(k) != leftCount + k) {
+ return null;
+ }
+ }
+ if (withOrdinality && full.getOrdinalityIndex() != leftCount + numArrays) {
+ return null;
+ }
+ // Collect referenced passthrough (left) columns from the project
expressions.
+ List<RexExpression> projects =
RexExpressionUtils.fromRexNodes(node.getProjects());
+ boolean[] referenced = new boolean[leftCount];
+ for (RexExpression project : projects) {
+ collectReferencedColumns(project, leftCount, referenced);
+ }
+ List<Integer> retained = new ArrayList<>();
+ for (int i = 0; i < leftCount; i++) {
+ if (referenced[i]) {
+ retained.add(i);
+ }
+ }
+ if (retained.size() == leftCount) {
+ // All passthrough columns are used downstream; pruning would be a no-op.
+ return null;
+ }
+ int numRetained = retained.size();
+ // Build the old (full correlate output) -> new (pruned output) index map.
+ int[] oldToNew = new int[outSize];
+ Arrays.fill(oldToNew, -1);
+ for (int i = 0; i < numRetained; i++) {
+ oldToNew[retained.get(i)] = i;
+ }
+ for (int k = 0; k < numArrays; k++) {
+ oldToNew[leftCount + k] = numRetained + k;
+ }
+ if (withOrdinality) {
+ oldToNew[leftCount + numArrays] = numRetained + numArrays;
+ }
+ // Build the pruned output schema: retained passthrough columns, then
element columns, then ordinality.
+ int newSize = numRetained + numArrays + (withOrdinality ? 1 : 0);
+ String[] columnNames = new String[newSize];
+ ColumnDataType[] columnTypes = new ColumnDataType[newSize];
+ for (int i = 0; i < numRetained; i++) {
+ int oldIdx = retained.get(i);
+ columnNames[i] = fullSchema.getColumnName(oldIdx);
+ columnTypes[i] = fullSchema.getColumnDataType(oldIdx);
+ }
+ for (int k = 0; k < numArrays; k++) {
+ columnNames[numRetained + k] = fullSchema.getColumnName(leftCount + k);
+ columnTypes[numRetained + k] = fullSchema.getColumnDataType(leftCount +
k);
+ }
+ if (withOrdinality) {
+ columnNames[numRetained + numArrays] =
fullSchema.getColumnName(leftCount + numArrays);
+ columnTypes[numRetained + numArrays] =
fullSchema.getColumnDataType(leftCount + numArrays);
+ }
+ DataSchema prunedSchema = new DataSchema(columnNames, columnTypes);
+ // Recompute element/ordinality indexes against the pruned (smaller)
output.
+ List<Integer> newElementIndexes = new ArrayList<>(numArrays);
+ for (int k = 0; k < numArrays; k++) {
+ newElementIndexes.add(numRetained + k);
+ }
+ int newOrdinalityIndex = withOrdinality ? numRetained + numArrays :
UnnestNode.UNSPECIFIED_INDEX;
+ UnnestNode.TableFunctionContext context = new
UnnestNode.TableFunctionContext(withOrdinality, newElementIndexes,
+ newOrdinalityIndex, retained, true);
+ UnnestNode prunedUnnest = new UnnestNode(DEFAULT_STAGE_ID, prunedSchema,
full.getNodeHint(), full.getInputs(),
+ full.getArrayExprs(), context);
+ if (_tracker != null) {
+ _tracker.trackCreation(correlate, prunedUnnest);
+ }
+ // Remap the project's input refs from the full correlate-output space to
the pruned space.
+ List<RexExpression> remappedProjects = new ArrayList<>(projects.size());
+ for (RexExpression project : projects) {
+ remappedProjects.add(remapInputRefs(project, oldToNew));
+ }
+ return new ProjectNode(DEFAULT_STAGE_ID, toDataSchema(node.getRowType()),
NodeHint.fromRelHints(node.getHints()),
+ new ArrayList<>(List.of(prunedUnnest)), remappedProjects);
+ }
+
+ private static void collectReferencedColumns(RexExpression expr, int
leftCount, boolean[] referenced) {
+ if (expr instanceof RexExpression.InputRef) {
+ int idx = ((RexExpression.InputRef) expr).getIndex();
+ if (idx >= 0 && idx < leftCount) {
+ referenced[idx] = true;
+ }
+ } else if (expr instanceof RexExpression.FunctionCall) {
+ for (RexExpression op : ((RexExpression.FunctionCall)
expr).getFunctionOperands()) {
+ collectReferencedColumns(op, leftCount, referenced);
+ }
+ }
+ }
+
+ private static RexExpression remapInputRefs(RexExpression expr, int[]
oldToNew) {
+ if (expr instanceof RexExpression.InputRef) {
+ int idx = ((RexExpression.InputRef) expr).getIndex();
+ int mapped = (idx >= 0 && idx < oldToNew.length) ? oldToNew[idx] : -1;
+ // mapped is always >= 0 here: passthrough columns referenced by the
project were retained, and element/
+ // ordinality columns are always mapped. Guard defensively to avoid
silently corrupting indexes.
+ Preconditions.checkState(mapped >= 0, "Unexpected unmapped InputRef
index %s while pruning UNNEST passthrough",
+ idx);
+ return new RexExpression.InputRef(mapped);
+ } else if (expr instanceof RexExpression.FunctionCall) {
+ RexExpression.FunctionCall fc = (RexExpression.FunctionCall) expr;
+ List<RexExpression> ops = fc.getFunctionOperands();
+ List<RexExpression> rewritten = new ArrayList<>(ops.size());
+ for (RexExpression op : ops) {
+ rewritten.add(remapInputRefs(op, oldToNew));
+ }
+ return new RexExpression.FunctionCall(fc.getDataType(),
fc.getFunctionName(), rewritten);
+ } else {
+ return expr;
+ }
+ }
+
private FilterNode convertLogicalFilter(LogicalFilter node) {
return new FilterNode(DEFAULT_STAGE_ID, toDataSchema(node.getRowType()),
NodeHint.fromRelHints(node.getHints()),
convertInputs(node.getInputs()),
RexExpressionUtils.fromRexNode(node.getCondition()));
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/UnnestNode.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/UnnestNode.java
index 1135de4312c..786659a208f 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/UnnestNode.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/UnnestNode.java
@@ -106,6 +106,22 @@ public class UnnestNode extends BasePlanNode {
return _tableFunctionContext.getOrdinalityIndex();
}
+ /**
+ * Input-row column indexes copied into the output, in output order. Only
meaningful when
+ * {@link #isPrunedPassthrough()} is {@code true}; otherwise empty and the
operator copies the whole input row.
+ */
+ public List<Integer> getPassthroughInputIndexes() {
+ return _tableFunctionContext.getPassthroughInputIndexes();
+ }
+
+ /**
+ * Whether the output schema has been pruned to only the input columns
referenced downstream (plus the element and
+ * ordinality columns). When {@code false}, the operator preserves legacy
behavior of copying the whole input row.
+ */
+ public boolean isPrunedPassthrough() {
+ return _tableFunctionContext.isPrunedPassthrough();
+ }
+
@Override
public String explain() {
return "UNNEST";
@@ -157,11 +173,21 @@ public class UnnestNode extends BasePlanNode {
private final boolean _withOrdinality;
private final List<Integer> _elementIndexes;
private final int _ordinalityIndex;
+ private final List<Integer> _passthroughInputIndexes;
+ private final boolean _prunedPassthrough;
public TableFunctionContext(boolean withOrdinality, List<Integer>
elementIndexes, int ordinalityIndex) {
+ // Legacy/default: no passthrough pruning. The operator copies the whole
input row into the output.
+ this(withOrdinality, elementIndexes, ordinalityIndex, List.of(), false);
+ }
+
+ public TableFunctionContext(boolean withOrdinality, List<Integer>
elementIndexes, int ordinalityIndex,
+ List<Integer> passthroughInputIndexes, boolean prunedPassthrough) {
_withOrdinality = withOrdinality;
_elementIndexes = List.copyOf(elementIndexes);
_ordinalityIndex = ordinalityIndex;
+ _passthroughInputIndexes = List.copyOf(passthroughInputIndexes);
+ _prunedPassthrough = prunedPassthrough;
}
public boolean isWithOrdinality() {
@@ -176,8 +202,17 @@ public class UnnestNode extends BasePlanNode {
return _ordinalityIndex;
}
+ public List<Integer> getPassthroughInputIndexes() {
+ return _passthroughInputIndexes;
+ }
+
+ public boolean isPrunedPassthrough() {
+ return _prunedPassthrough;
+ }
+
public TableFunctionContext copy() {
- return new TableFunctionContext(_withOrdinality, _elementIndexes,
_ordinalityIndex);
+ return new TableFunctionContext(_withOrdinality, _elementIndexes,
_ordinalityIndex, _passthroughInputIndexes,
+ _prunedPassthrough);
}
@Override
@@ -190,12 +225,15 @@ public class UnnestNode extends BasePlanNode {
}
TableFunctionContext that = (TableFunctionContext) o;
return _withOrdinality == that._withOrdinality && _ordinalityIndex ==
that._ordinalityIndex
- && Objects.equals(_elementIndexes, that._elementIndexes);
+ && _prunedPassthrough == that._prunedPassthrough
+ && Objects.equals(_elementIndexes, that._elementIndexes)
+ && Objects.equals(_passthroughInputIndexes,
that._passthroughInputIndexes);
}
@Override
public int hashCode() {
- return Objects.hash(_withOrdinality, _elementIndexes, _ordinalityIndex);
+ return Objects.hash(_withOrdinality, _elementIndexes, _ordinalityIndex,
_passthroughInputIndexes,
+ _prunedPassthrough);
}
}
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
index 7f2144124d3..c91e46d8f5f 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
@@ -240,8 +240,16 @@ public class PlanNodeDeserializer {
int ordIdx = protoUnnestNode.hasOrdinalityIndex() ?
protoUnnestNode.getOrdinalityIndex()
: UnnestNode.UNSPECIFIED_INDEX;
+ // Passthrough pruning metadata. Absent on plans produced by older
brokers, in which case prunedPassthrough is
+ // false and the operator copies the whole input row (legacy behavior).
+ List<Integer> passthroughInputIndexes = new ArrayList<>();
+ for (int idx : protoUnnestNode.getPassthroughInputIndexesList()) {
+ passthroughInputIndexes.add(idx);
+ }
+
UnnestNode.TableFunctionContext context =
- new
UnnestNode.TableFunctionContext(protoUnnestNode.getWithOrdinality(),
elementIndexes, ordIdx);
+ new
UnnestNode.TableFunctionContext(protoUnnestNode.getWithOrdinality(),
elementIndexes, ordIdx,
+ passthroughInputIndexes, protoUnnestNode.getPrunedPassthrough());
return new UnnestNode(protoNode.getStageId(),
extractDataSchema(protoNode), extractNodeHint(protoNode),
extractInputs(protoNode), arrayExprs, context);
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
index 99665d5c29b..f2283ed09f5 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
@@ -287,7 +287,9 @@ public class PlanNodeSerializer {
.addAllArrayExprs(convertExpressions(node.getArrayExprs()))
.setWithOrdinality(context.isWithOrdinality())
.addAllElementIndexes(context.getElementIndexes())
- .setOrdinalityIndex(context.getOrdinalityIndex());
+ .setOrdinalityIndex(context.getOrdinalityIndex())
+ .addAllPassthroughInputIndexes(context.getPassthroughInputIndexes())
+ .setPrunedPassthrough(context.isPrunedPassthrough());
builder.setUnnestNode(unnestNodeBuilder.build());
return null;
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/plannode/UnnestNodeTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/plannode/UnnestNodeTest.java
index 0396d2dd284..6045cd8aad0 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/plannode/UnnestNodeTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/plannode/UnnestNodeTest.java
@@ -185,4 +185,36 @@ public class UnnestNodeTest {
Assert.assertEquals(node1.hashCode(), node2.hashCode());
Assert.assertNotEquals(node1, node3);
}
+
+ @Test
+ public void testDefaultPassthroughIsNotPruned() {
+ DataSchema dataSchema = new DataSchema(new String[]{"id", "elem"},
+ new DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.INT,
DataSchema.ColumnDataType.STRING});
+ UnnestNode node = new UnnestNode(0, dataSchema, PlanNode.NodeHint.EMPTY,
+ new ArrayList<>(), List.of(new RexExpression.InputRef(1)),
List.of("elem"), false, null);
+
+ // Legacy/default construction: no pruning, empty passthrough map.
+ Assert.assertFalse(node.isPrunedPassthrough());
+ Assert.assertTrue(node.getPassthroughInputIndexes().isEmpty());
+ }
+
+ @Test
+ public void testPrunedPassthroughMetadata() {
+ DataSchema dataSchema = new DataSchema(new String[]{"id", "elem"},
+ new DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.INT,
DataSchema.ColumnDataType.STRING});
+ UnnestNode.TableFunctionContext context =
+ new UnnestNode.TableFunctionContext(false, List.of(1),
UnnestNode.UNSPECIFIED_INDEX, List.of(0), true);
+ UnnestNode node = new UnnestNode(0, dataSchema, PlanNode.NodeHint.EMPTY,
+ new ArrayList<>(), List.of(new RexExpression.InputRef(1)), context);
+
+ Assert.assertTrue(node.isPrunedPassthrough());
+ Assert.assertEquals(node.getPassthroughInputIndexes(), List.of(0));
+
+ // A pruned node must not equal an otherwise-identical non-pruned node.
+ UnnestNode legacy = new UnnestNode(0, dataSchema, PlanNode.NodeHint.EMPTY,
+ new ArrayList<>(), List.of(new RexExpression.InputRef(1)),
+ new UnnestNode.TableFunctionContext(false, List.of(1),
UnnestNode.UNSPECIFIED_INDEX));
+ Assert.assertNotEquals(node, legacy);
+ Assert.assertNotEquals(node.hashCode(), legacy.hashCode());
+ }
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/serde/PlanNodeSerDeTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/serde/PlanNodeSerDeTest.java
index 8fc02e78122..98745869d70 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/serde/PlanNodeSerDeTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/serde/PlanNodeSerDeTest.java
@@ -18,10 +18,16 @@
*/
package org.apache.pinot.query.planner.serde;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
import org.apache.pinot.query.QueryEnvironmentTestBase;
+import org.apache.pinot.query.planner.logical.RexExpression;
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.UnnestNode;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
@@ -38,4 +44,38 @@ public class PlanNodeSerDeTest extends
QueryEnvironmentTestBase {
assertEquals(stagePlan, deserializedStagePlan);
}
}
+
+ @Test
+ public void testPrunedUnnestNodeSerDe() {
+ // Round-trips the passthrough-pruning wire fields
(passthroughInputIndexes, prunedPassthrough). A non-sequential
+ // index list plus WITH ORDINALITY exercise the proto repeated/bool fields
and ordering.
+ DataSchema dataSchema = new DataSchema(new String[]{"col0", "col2",
"elem", "ord"},
+ new ColumnDataType[]{ColumnDataType.INT, ColumnDataType.STRING,
ColumnDataType.INT, ColumnDataType.INT});
+ UnnestNode.TableFunctionContext context =
+ new UnnestNode.TableFunctionContext(true, List.of(2), 3, List.of(0,
2), true);
+ UnnestNode node = new UnnestNode(1, dataSchema, PlanNode.NodeHint.EMPTY,
new ArrayList<>(),
+ List.of(new RexExpression.InputRef(1)), context);
+
+ PlanNode deserialized =
PlanNodeDeserializer.process(PlanNodeSerializer.process(node));
+ assertEquals(deserialized, node);
+ UnnestNode deserializedUnnest = (UnnestNode) deserialized;
+ assertEquals(deserializedUnnest.getPassthroughInputIndexes(), List.of(0,
2));
+ assertEquals(deserializedUnnest.isPrunedPassthrough(), true);
+ assertEquals(deserializedUnnest.getOrdinalityIndex(), 3);
+ }
+
+ @Test
+ public void testLegacyUnnestNodeSerDe() {
+ // A non-pruned UnnestNode must round-trip with prunedPassthrough=false
and an empty passthrough map (the wire
+ // default an old broker produces).
+ DataSchema dataSchema = new DataSchema(new String[]{"id", "arr", "elem"},
+ new ColumnDataType[]{ColumnDataType.INT, ColumnDataType.INT_ARRAY,
ColumnDataType.INT});
+ UnnestNode node = new UnnestNode(1, dataSchema, PlanNode.NodeHint.EMPTY,
new ArrayList<>(),
+ new RexExpression.InputRef(1), "elem", false, null);
+
+ UnnestNode deserialized = (UnnestNode)
PlanNodeDeserializer.process(PlanNodeSerializer.process(node));
+ assertEquals(deserialized, node);
+ assertEquals(deserialized.isPrunedPassthrough(), false);
+ assertEquals(deserialized.getPassthroughInputIndexes(), List.of());
+ }
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/queries/UnnestSqlPlannerTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/queries/UnnestSqlPlannerTest.java
index 5a4d1d3d1df..58888ff4635 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/queries/UnnestSqlPlannerTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/queries/UnnestSqlPlannerTest.java
@@ -21,6 +21,10 @@ package org.apache.pinot.query.queries;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+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.logical.RexExpression;
import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
@@ -29,6 +33,8 @@ 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.UnnestNode;
+import org.apache.pinot.query.routing.WorkerManager;
+import org.apache.pinot.spi.utils.CommonConstants;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -85,6 +91,149 @@ public class UnnestSqlPlannerTest extends
QueryEnvironmentTestBase {
assertOrdinality(unnestNode, withOrdinality);
}
+ @Test
+ public void testUnnestColumnPruningDropsSourceArray() {
+ // With pruning enabled, the unnested source array (mcol1) must not be
carried in the UnnestNode output.
+ String sql = "SET unnestColumnPruning=true; "
+ + "SELECT e.col1, u.s FROM e CROSS JOIN UNNEST(e.mcol1) AS u(s)";
+ DispatchableSubPlan subPlan = _queryEnvironment.planQuery(sql);
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ UnnestNode unnestNode = unnestNodes.get(0);
+
+ Assert.assertTrue(unnestNode.isPrunedPassthrough(), "UnnestNode should be
pruned");
+ List<String> columns =
Arrays.asList(unnestNode.getDataSchema().getColumnNames());
+ Assert.assertFalse(columns.contains("mcol1"),
+ "Source array column should be pruned from UnnestNode output, found: "
+ columns);
+ Assert.assertTrue(columns.contains("col1"), "Referenced passthrough column
col1 should be retained: " + columns);
+ Assert.assertEquals(unnestNode.getDataSchema().size(), 2, "Expected [col1,
element] only: " + columns);
+ // col1 is the only retained passthrough column (input index 0); the
element lands right after it.
+ Assert.assertEquals(unnestNode.getPassthroughInputIndexes(), List.of(0));
+ Assert.assertEquals(unnestNode.getElementIndexes(), List.of(1));
+ }
+
+ @Test
+ public void testUnnestColumnPruningDisabledByDefault() {
+ // Without the flag, behavior is unchanged: the source array is still part
of the UnnestNode output.
+ String sql = "SELECT e.col1, u.s FROM e CROSS JOIN UNNEST(e.mcol1) AS
u(s)";
+ DispatchableSubPlan subPlan = _queryEnvironment.planQuery(sql);
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ UnnestNode unnestNode = unnestNodes.get(0);
+
+ Assert.assertFalse(unnestNode.isPrunedPassthrough(), "UnnestNode should
not be pruned by default");
+ List<String> columns =
Arrays.asList(unnestNode.getDataSchema().getColumnNames());
+ Assert.assertTrue(columns.contains("mcol1"),
+ "Without pruning, the source array column should remain in the
UnnestNode output: " + columns);
+ }
+
+ @Test
+ public void testUnnestColumnPruningRetainsSelectedSourceArray() {
+ // When the user also selects the source array, it must be retained even
with pruning enabled.
+ String sql = "SET unnestColumnPruning=true; "
+ + "SELECT e.col1, e.mcol1, u.s FROM e CROSS JOIN UNNEST(e.mcol1) AS
u(s)";
+ DispatchableSubPlan subPlan = _queryEnvironment.planQuery(sql);
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ UnnestNode unnestNode = unnestNodes.get(0);
+
+ List<String> columns =
Arrays.asList(unnestNode.getDataSchema().getColumnNames());
+ Assert.assertTrue(columns.contains("mcol1"),
+ "Explicitly selected source array must be retained: " + columns);
+ Assert.assertTrue(columns.contains("col1"), columns.toString());
+ }
+
+ @Test
+ public void testUnnestColumnPruningWithOrdinality() {
+ // The ordinality index must be recomputed against the pruned (smaller)
output.
+ String sql = "SET unnestColumnPruning=true; "
+ + "SELECT e.col1, u.s, u.ord FROM e CROSS JOIN UNNEST(e.mcol1) WITH
ORDINALITY AS u(s, ord)";
+ DispatchableSubPlan subPlan = _queryEnvironment.planQuery(sql);
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ UnnestNode unnestNode = unnestNodes.get(0);
+
+ Assert.assertTrue(unnestNode.isWithOrdinality());
+ Assert.assertTrue(unnestNode.isPrunedPassthrough());
+ List<String> columns =
Arrays.asList(unnestNode.getDataSchema().getColumnNames());
+ Assert.assertFalse(columns.contains("mcol1"), "Source array should be
pruned: " + columns);
+ // Retained passthrough = [col1] (1), element at index 1, ordinality at
index 2.
+ Assert.assertEquals(unnestNode.getPassthroughInputIndexes(), List.of(0));
+ Assert.assertEquals(unnestNode.getElementIndexes(), List.of(1));
+ Assert.assertEquals(unnestNode.getOrdinalityIndex(), 2);
+ Assert.assertEquals(unnestNode.getDataSchema().size(), 3);
+ }
+
+ @Test
+ public void testUnnestColumnPruningMultipleArrays() {
+ // Both source arrays must be dropped; element indexes recompute
contiguously after the retained passthrough.
+ String sql = "SET unnestColumnPruning=true; "
+ + "SELECT e.col1, u.longVal, u.stringVal FROM e CROSS JOIN
UNNEST(e.mcol2, e.mcol1) AS u(longVal, stringVal)";
+ DispatchableSubPlan subPlan = _queryEnvironment.planQuery(sql);
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ UnnestNode unnestNode = unnestNodes.get(0);
+
+ Assert.assertTrue(unnestNode.isPrunedPassthrough());
+ List<String> columns =
Arrays.asList(unnestNode.getDataSchema().getColumnNames());
+ Assert.assertFalse(columns.contains("mcol1"), columns.toString());
+ Assert.assertFalse(columns.contains("mcol2"), columns.toString());
+ Assert.assertTrue(columns.contains("col1"), columns.toString());
+ Assert.assertEquals(unnestNode.getPassthroughInputIndexes(), List.of(0));
+ Assert.assertEquals(unnestNode.getElementIndexes(), List.of(1, 2));
+ Assert.assertEquals(unnestNode.getDataSchema().size(), 3);
+ }
+
+ @Test
+ public void testUnnestColumnPruningViaBrokerDefault() {
+ // With the broker-config default on (and no per-query SET), pruning still
applies.
+ QueryEnvironment env = buildQueryEnvironment(true);
+ DispatchableSubPlan subPlan =
+ env.planQuery("SELECT e.col1, u.s FROM e CROSS JOIN UNNEST(e.mcol1) AS
u(s)");
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ Assert.assertTrue(unnestNodes.get(0).isPrunedPassthrough(),
+ "Broker-config default should enable pruning without a SET option");
+
+ // A per-query SET overrides the broker default back off.
+ DispatchableSubPlan overridden = env.planQuery(
+ "SET unnestColumnPruning=false; SELECT e.col1, u.s FROM e CROSS JOIN
UNNEST(e.mcol1) AS u(s)");
+
Assert.assertFalse(findUnnestNodes(overridden).get(0).isPrunedPassthrough(),
+ "Per-query SET should override the broker-config default");
+ }
+
+ private static QueryEnvironment buildQueryEnvironment(boolean
defaultUnnestColumnPruning) {
+ MockRoutingManagerFactory factory = new MockRoutingManagerFactory(1, 2);
+ TABLE_SCHEMAS.forEach((name, schema) -> factory.registerTable(schema,
name));
+ SERVER1_SEGMENTS.forEach((table, segments) -> segments.forEach(s ->
factory.registerSegment(1, table, s)));
+ SERVER2_SEGMENTS.forEach((table, segments) -> segments.forEach(s ->
factory.registerSegment(2, table, s)));
+ RoutingManager routingManager = factory.buildRoutingManager(null);
+ TableCache tableCache = factory.buildTableCache();
+ return new QueryEnvironment(QueryEnvironment.configBuilder()
+ .requestId(-1L)
+ .database(CommonConstants.DEFAULT_DATABASE)
+ .tableCache(tableCache)
+ .workerManager(new WorkerManager("Broker_localhost", "localhost", 3,
routingManager))
+ .defaultUnnestColumnPruning(defaultUnnestColumnPruning)
+ .build());
+ }
+
+ @Test
+ public void testUnnestColumnPruningToZeroPassthrough() {
+ // Selecting only the unnested element retains zero passthrough columns.
+ String sql = "SET unnestColumnPruning=true; "
+ + "SELECT u.s FROM e CROSS JOIN UNNEST(e.mcol1) AS u(s)";
+ DispatchableSubPlan subPlan = _queryEnvironment.planQuery(sql);
+ List<UnnestNode> unnestNodes = findUnnestNodes(subPlan);
+ Assert.assertEquals(unnestNodes.size(), 1);
+ UnnestNode unnestNode = unnestNodes.get(0);
+
+ Assert.assertTrue(unnestNode.isPrunedPassthrough());
+ Assert.assertTrue(unnestNode.getPassthroughInputIndexes().isEmpty());
+ Assert.assertEquals(unnestNode.getElementIndexes(), List.of(0));
+ Assert.assertEquals(unnestNode.getDataSchema().size(), 1);
+ }
+
@Test
public void testAggregateWithOrdinality() {
String sql = "SELECT SUM(w.ord) FROM e CROSS JOIN UNNEST(e.mcol1) WITH
ORDINALITY AS w(s, ord)";
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/UnnestOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/UnnestOperator.java
index 0594e82a72c..367a565cac9 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/UnnestOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/UnnestOperator.java
@@ -50,6 +50,8 @@ public class UnnestOperator extends MultiStageOperator {
private final boolean _withOrdinality;
private final List<Integer> _elementIndexes;
private final int _ordinalityIndex;
+ private final int[] _passthroughInputIndexes;
+ private final boolean _prunedPassthrough;
private final StatMap<StatKey> _statMap = new StatMap<>(StatKey.class);
private boolean _loggedElementOverflow;
@@ -66,6 +68,13 @@ public class UnnestOperator extends MultiStageOperator {
_withOrdinality = node.isWithOrdinality();
_elementIndexes = node.getElementIndexes();
_ordinalityIndex = node.getOrdinalityIndex();
+ // Resolve to a primitive array once so the per-row hot path avoids
List.get + unboxing.
+ List<Integer> passthroughInputIndexes = node.getPassthroughInputIndexes();
+ _passthroughInputIndexes = new int[passthroughInputIndexes.size()];
+ for (int i = 0; i < _passthroughInputIndexes.length; i++) {
+ _passthroughInputIndexes[i] = passthroughInputIndexes.get(i);
+ }
+ _prunedPassthrough = node.isPrunedPassthrough();
}
@Override
@@ -226,10 +235,21 @@ public class UnnestOperator extends MultiStageOperator {
private Object[] appendElements(Object[] inputRow, List<Object> elements,
int ordinality) {
int outSize = _resultSchema.size();
Object[] out = new Object[outSize];
- // Copy left columns at beginning
- System.arraycopy(inputRow, 0, out, 0, inputRow.length);
- // Determine positions for elements. Track next free slot after the copied
input row.
- int base = inputRow.length;
+ // Copy passthrough (left) columns at the beginning of the output row.
+ int base;
+ if (_prunedPassthrough) {
+ // Only the input columns referenced downstream are retained, in output
order.
+ int numPassthrough = _passthroughInputIndexes.length;
+ for (int o = 0; o < numPassthrough; o++) {
+ out[o] = inputRow[_passthroughInputIndexes[o]];
+ }
+ base = numPassthrough;
+ } else {
+ // Legacy behavior: copy the whole input row (including the unnested
source array).
+ System.arraycopy(inputRow, 0, out, 0, inputRow.length);
+ base = inputRow.length;
+ }
+ // Determine positions for elements. Track next free slot after the copied
passthrough columns.
int nextFreePos = base;
for (int i = 0; i < elements.size(); i++) {
int elemPos = -1;
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/UnnestOperatorTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/UnnestOperatorTest.java
index 124e7f0cb42..b3d32ffc397 100644
---
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/UnnestOperatorTest.java
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/UnnestOperatorTest.java
@@ -165,6 +165,119 @@ public class UnnestOperatorTest {
assertEquals(rows.get(1)[3], "y");
}
+ @Test
+ public void shouldPrunePassthroughColumnsWhenSourceArrayDropped() {
+ // Input keeps the source array, but the pruned output schema drops it:
only "id" passes through.
+ DataSchema inputSchema = new DataSchema(new String[]{"id", "arr"}, new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT_ARRAY
+ });
+ when(_input.nextBlock()).thenReturn(
+ OperatorTestUtil.block(inputSchema,
+ new Object[]{1, new int[]{10, 20}},
+ new Object[]{2, new int[]{30}}));
+
+ DataSchema resultSchema = new DataSchema(new String[]{"id", "elem"}, new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT
+ });
+ RexExpression arrayExpr = new RexExpression.InputRef(1);
+ // Passthrough only input column 0 ("id") to output 0; element lands at
output 1. prunedPassthrough = true.
+ UnnestNode.TableFunctionContext context =
+ new UnnestNode.TableFunctionContext(false, List.of(1),
UnnestNode.UNSPECIFIED_INDEX, List.of(0), true);
+ UnnestNode node = new UnnestNode(-1, resultSchema,
PlanNode.NodeHint.EMPTY, List.of(), List.of(arrayExpr), context);
+ UnnestOperator operator = new
UnnestOperator(OperatorTestUtil.getTracingContext(), _input, inputSchema, node);
+
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 3);
+ for (Object[] row : rows) {
+ assertEquals(row.length, 2); // source array is not carried
+ }
+ assertEquals(rows.get(0)[0], 1);
+ assertEquals(rows.get(0)[1], 10);
+ assertEquals(rows.get(1)[0], 1);
+ assertEquals(rows.get(1)[1], 20);
+ assertEquals(rows.get(2)[0], 2);
+ assertEquals(rows.get(2)[1], 30);
+ }
+
+ @Test
+ public void shouldPrunePassthroughToZeroColumns() {
+ // SELECT only the unnested element: no passthrough columns retained.
+ DataSchema inputSchema = new DataSchema(new String[]{"id", "arr"}, new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT_ARRAY
+ });
+ when(_input.nextBlock()).thenReturn(
+ OperatorTestUtil.block(inputSchema, new Object[]{1, new int[]{10,
20}}));
+
+ DataSchema resultSchema = new DataSchema(new String[]{"elem"}, new
ColumnDataType[]{ColumnDataType.INT});
+ RexExpression arrayExpr = new RexExpression.InputRef(1);
+ UnnestNode.TableFunctionContext context =
+ new UnnestNode.TableFunctionContext(false, List.of(0),
UnnestNode.UNSPECIFIED_INDEX, List.of(), true);
+ UnnestNode node = new UnnestNode(-1, resultSchema,
PlanNode.NodeHint.EMPTY, List.of(), List.of(arrayExpr), context);
+ UnnestOperator operator = new
UnnestOperator(OperatorTestUtil.getTracingContext(), _input, inputSchema, node);
+
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 2);
+ assertEquals(rows.get(0).length, 1);
+ assertEquals(rows.get(0)[0], 10);
+ assertEquals(rows.get(1)[0], 20);
+ }
+
+ @Test
+ public void shouldPrunePassthroughWithOrdinality() {
+ DataSchema inputSchema = new DataSchema(new String[]{"id", "arr"}, new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT_ARRAY
+ });
+ when(_input.nextBlock()).thenReturn(
+ OperatorTestUtil.block(inputSchema, new Object[]{1, new int[]{5, 6}}));
+
+ // Pruned output [id, elem, ord]; source array dropped. element at 1,
ordinality at 2.
+ DataSchema resultSchema = new DataSchema(new String[]{"id", "elem",
"ord"}, new ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT, ColumnDataType.INT
+ });
+ UnnestNode.TableFunctionContext context =
+ new UnnestNode.TableFunctionContext(true, List.of(1), 2, List.of(0),
true);
+ UnnestNode node = new UnnestNode(-1, resultSchema,
PlanNode.NodeHint.EMPTY, List.of(),
+ List.of(new RexExpression.InputRef(1)), context);
+ UnnestOperator operator = new
UnnestOperator(OperatorTestUtil.getTracingContext(), _input, inputSchema, node);
+
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 2);
+ assertEquals(rows.get(0).length, 3);
+ assertEquals(rows.get(0)[0], 1);
+ assertEquals(rows.get(0)[1], 5);
+ assertEquals(rows.get(0)[2], 1);
+ assertEquals(rows.get(1)[1], 6);
+ assertEquals(rows.get(1)[2], 2);
+ }
+
+ @Test
+ public void shouldPrunePassthroughWithMultipleArrays() {
+ DataSchema inputSchema = new DataSchema(new String[]{"id", "a1", "a2"},
new ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT_ARRAY,
ColumnDataType.STRING_ARRAY
+ });
+ when(_input.nextBlock()).thenReturn(
+ OperatorTestUtil.block(inputSchema, new Object[]{10, new int[]{1, 2},
new String[]{"x", "y"}}));
+
+ // Pruned output [id, v1, v2]; both source arrays dropped.
+ DataSchema resultSchema = new DataSchema(new String[]{"id", "v1", "v2"},
new ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.INT, ColumnDataType.STRING
+ });
+ UnnestNode.TableFunctionContext context =
+ new UnnestNode.TableFunctionContext(false, List.of(1, 2),
UnnestNode.UNSPECIFIED_INDEX, List.of(0), true);
+ UnnestNode node = new UnnestNode(-1, resultSchema,
PlanNode.NodeHint.EMPTY, List.of(),
+ List.of(new RexExpression.InputRef(1), new RexExpression.InputRef(2)),
context);
+ UnnestOperator operator = new
UnnestOperator(OperatorTestUtil.getTracingContext(), _input, inputSchema, node);
+
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 2);
+ assertEquals(rows.get(0).length, 3);
+ assertEquals(rows.get(0)[0], 10);
+ assertEquals(rows.get(0)[1], 1);
+ assertEquals(rows.get(0)[2], "x");
+ assertEquals(rows.get(1)[1], 2);
+ assertEquals(rows.get(1)[2], "y");
+ }
+
@Test
public void shouldZipMultipleArraysIntoColumns() {
DataSchema inputSchema = new DataSchema(new String[]{"id", "a1", "a2"},
new ColumnDataType[]{
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 33f94493840..b81f4fb2828 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
@@ -552,6 +552,15 @@ public class CommonConstants {
public static final String CONFIG_OF_SPOOLS =
"pinot.broker.multistage.spools";
public static final boolean DEFAULT_OF_SPOOLS = false;
+ /**
+ * Whether the multistage query engine prunes unused input (passthrough)
columns - notably the unnested source
+ * array - from the UNNEST output by default. This value can always be
overridden by the
+ * {@link Request.QueryOptionKey#UNNEST_COLUMN_PRUNING} query option. Keep
it disabled until all servers support
+ * it (a broker emitting the smaller UNNEST schema cannot be honored by an
un-upgraded server).
+ */
+ public static final String CONFIG_OF_UNNEST_COLUMN_PRUNING =
"pinot.broker.multistage.unnest.column.pruning";
+ public static final boolean DEFAULT_UNNEST_COLUMN_PRUNING = false;
+
/// Whether to only use servers for leaf stages as the workers for the
intermediate stages.
/// This value can always be overridden by
[Request.QueryOptionKey#USE_LEAF_SERVER_FOR_INTERMEDIATE_STAGE].
public static final String
CONFIG_OF_USE_LEAF_SERVER_FOR_INTERMEDIATE_STAGE =
@@ -812,6 +821,12 @@ public class CommonConstants {
public static final String APPLICATION_NAME = "applicationName";
public static final String USE_SPOOLS = "useSpools";
public static final String USE_PHYSICAL_OPTIMIZER =
"usePhysicalOptimizer";
+ // When true, the multi-stage planner prunes input (passthrough)
columns - notably the unnested source array -
+ // from the UNNEST output when they are not referenced downstream,
avoiding copying them into every exploded
+ // row. Defaults to false: enabling it makes the broker emit a smaller
UNNEST output schema, which an
+ // un-upgraded server cannot honor, so only enable it once all servers
support it.
+ // NOTE: This is a no-op under usePhysicalOptimizer (the v2 path does
not go through RelToPlanNodeConverter).
+ public static final String UNNEST_COLUMN_PRUNING =
"unnestColumnPruning";
/**
* When set to true, the broker uses the long-lived {@code
SubmitWithStream} bidi RPC to dispatch the query,
* receiving stage stats out-of-band as {@code OpChainComplete}
messages instead of via mailbox EOS. The
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]