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 fa3fec3591b Don't push non-deterministic or volatile filters below a
join (#19216)
fa3fec3591b is described below
commit fa3fec3591b6288a2d5a7c8d7e0fa454fbdd0ee1
Author: Yash Mayya <[email protected]>
AuthorDate: Tue Aug 18 14:49:03 2026 -0400
Don't push non-deterministic or volatile filters below a join (#19216)
---
.../pinot/common/function/FunctionRegistry.java | 16 +-
.../common/function/sql/PinotSqlFunction.java | 26 ++-
.../pinot/common/function/FunctionUtilsTest.java | 34 ++++
.../calcite/rel/rules/PinotFilterJoinRule.java | 30 +++
.../pinot/calcite/rel/rules/PinotRuleUtils.java | 48 +++++
.../pinot/calcite/sql/fun/PinotOperatorTable.java | 5 +-
.../calcite/rel/rules/PinotRuleUtilsTest.java | 159 +++++++++++++++
.../apache/pinot/query/QueryCompilationTest.java | 28 +++
.../src/test/resources/queries/JoinPlans.json | 220 +++++++++++++++++++++
9 files changed, 563 insertions(+), 3 deletions(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
index f88b73cf740..43b08241967 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
@@ -39,6 +39,7 @@ import org.apache.calcite.sql.type.SqlTypeFamily;
import org.apache.commons.lang3.StringUtils;
import org.apache.pinot.common.function.sql.PinotSqlFunction;
import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
import org.apache.pinot.spi.annotations.ScalarFunction;
import org.apache.pinot.spi.utils.PinotReflectionUtils;
import org.slf4j.Logger;
@@ -249,7 +250,8 @@ public class FunctionRegistry {
@Override
public PinotSqlFunction toPinotSqlFunction() {
- return new PinotSqlFunction(_mainName, getReturnTypeInference(),
getOperandTypeChecker(), isDeterministic());
+ return new PinotSqlFunction(_mainName, getReturnTypeInference(),
getOperandTypeChecker(), isDeterministic(),
+ isVolatile());
}
private SqlReturnTypeInference getReturnTypeInference() {
@@ -328,6 +330,18 @@ public class FunctionRegistry {
return true;
}
+ /// Conservatively reports the function as volatile if any registered
overload is, matching [#isDeterministic()].
+ /// Both are operator-level properties, whereas the operand types that
would select a single overload are only
+ /// known per call site.
+ private boolean isVolatile() {
+ for (FunctionInfo functionInfo : _functionInfoMap.values()) {
+ if (functionInfo.getVolatility() == FunctionVolatility.VOLATILE) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public String getScalarFunctionId() {
if (_functionInfoMap.size() == 1) {
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/function/sql/PinotSqlFunction.java
b/pinot-common/src/main/java/org/apache/pinot/common/function/sql/PinotSqlFunction.java
index 554389947e1..f18bfb079a9 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/function/sql/PinotSqlFunction.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/function/sql/PinotSqlFunction.java
@@ -28,12 +28,25 @@ import org.apache.calcite.sql.type.SqlReturnTypeInference;
/// Pinot custom SqlFunction to be registered into SqlOperatorTable.
public class PinotSqlFunction extends SqlFunction {
private final boolean _deterministic;
+ private final boolean _isVolatile;
public PinotSqlFunction(String name, SqlReturnTypeInference
returnTypeInference,
- SqlOperandTypeChecker operandTypeChecker, boolean deterministic) {
+ SqlOperandTypeChecker operandTypeChecker, boolean deterministic, boolean
isVolatile) {
super(name.toUpperCase(), SqlKind.OTHER_FUNCTION, returnTypeInference,
null, operandTypeChecker,
SqlFunctionCategory.USER_DEFINED_FUNCTION);
_deterministic = deterministic;
+ _isVolatile = isVolatile;
+ }
+
+ /// Derives volatility from determinism: a non-deterministic function is
always volatile, and a deterministic one is
+ /// assumed not to be.
+ ///
+ /// Only the second half is an assumption -- a deterministic function can
still be
+ /// `FunctionVolatility.VOLATILE` (that is exactly what `now()` is). Use the
constructor above to say so explicitly;
+ /// this overload is for operators that are plain immutable functions of
their arguments.
+ public PinotSqlFunction(String name, SqlReturnTypeInference
returnTypeInference,
+ SqlOperandTypeChecker operandTypeChecker, boolean deterministic) {
+ this(name, returnTypeInference, operandTypeChecker, deterministic,
!deterministic);
}
public PinotSqlFunction(String name, SqlReturnTypeInference
returnTypeInference,
@@ -45,4 +58,15 @@ public class PinotSqlFunction extends SqlFunction {
public boolean isDeterministic() {
return _deterministic;
}
+
+ /// Whether the function is `FunctionVolatility.VOLATILE`, i.e. its result
can change on every invocation or it has
+ /// side effects.
+ ///
+ /// This is independent of [#isDeterministic()], which is Pinot's
compile-time-evaluation hint: `now()` is
+ /// deterministic (so it can be constant-folded once at plan time) but
volatile (so it must not be re-evaluated at a
+ /// different point in the plan). `FunctionVolatility.STABLE` is not
reported here, since a stable function is
+ /// constant within a single query and is therefore safe to relocate.
+ public boolean isVolatile() {
+ return _isVolatile;
+ }
}
diff --git
a/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
b/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
index a653a91ecc7..b99e4b737e3 100644
---
a/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
+++
b/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
@@ -25,6 +25,7 @@ import java.time.LocalTime;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
+import org.apache.pinot.common.function.sql.PinotSqlFunction;
import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
import org.apache.pinot.spi.annotations.FunctionVolatility;
import org.apache.pinot.spi.annotations.ScalarFunction;
@@ -125,6 +126,39 @@ public class FunctionUtilsTest {
assertEquals(workerId.getVolatility(), FunctionVolatility.VOLATILE);
}
+ /// The [FunctionInfo] volatility above is per-arity, but the
[PinotSqlFunction] the planner sees is per-operator.
+ /// Both determinism and volatility are therefore aggregated conservatively
across every registered overload.
+ @Test
+ public void testOperatorLevelVolatilityAggregatesAcrossOverloads() {
+ // rand() is VOLATILE and non-deterministic at 0 args, IMMUTABLE and
deterministic at 1 arg. The single operator
+ // has to report the more restrictive of each.
+ PinotSqlFunction rand = toSqlFunction("rand");
+ assertFalse(rand.isDeterministic());
+ assertTrue(rand.isVolatile());
+
+ // now() stays deterministic so it can be constant-folded, but must still
report volatile.
+ PinotSqlFunction now = toSqlFunction("now");
+ assertTrue(now.isDeterministic());
+ assertTrue(now.isVolatile());
+
+ // STABLE is constant within a query, so it must NOT be reported as
volatile.
+ PinotSqlFunction reqId = toSqlFunction("reqid");
+ assertTrue(reqId.isDeterministic());
+ assertFalse(reqId.isVolatile());
+
+ PinotSqlFunction upper = toSqlFunction("upper");
+ assertTrue(upper.isDeterministic());
+ assertFalse(upper.isVolatile());
+ }
+
+ private static PinotSqlFunction toSqlFunction(String canonicalName) {
+ PinotScalarFunction scalarFunction =
FunctionRegistry.getFunctions().get(canonicalName);
+ assertNotNull(scalarFunction, "Failed to find function: " + canonicalName);
+ PinotSqlFunction sqlFunction = scalarFunction.toPinotSqlFunction();
+ assertNotNull(sqlFunction, "Function is not registered as a
PinotSqlFunction: " + canonicalName);
+ return sqlFunction;
+ }
+
@Test
public void testFunctionVolatilityResolution()
throws NoSuchMethodException {
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotFilterJoinRule.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotFilterJoinRule.java
index e350d045a5f..398eb7b9386 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotFilterJoinRule.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotFilterJoinRule.java
@@ -48,9 +48,39 @@ public abstract class PinotFilterJoinRule<C extends
FilterJoinRule.Config> exten
}
// Following code are copy-pasted from Calcite, and modified to not push
down filter into right side of lookup join.
+ // SYNCED WITH Calcite 1.42.0 FilterJoinRule#perform -- re-diff this method
body against upstream on every
+ // calcite.version bump. The intended deviations are the canPushRight
lookup-join restriction and the volatility half
+ // of the isRelocatable guard, both marked PINOT MODIFICATION below.
+ //
+ // Known outstanding drift: upstream's RexUtil.containsCorrelation
partitioning of aboveFilters and the variablesSet
+ // argument on the final RelBuilder#filter (CALCITE-7319) are not ported.
Pinot decorrelates in
+ // QueryEnvironment#toRelation before these rules run, and the
LogicalCorrelate shapes that do survive it
+ // (UNNEST / CROSS JOIN UNNEST) have an Uncollect right input, so a Filter
carrying $cor never sits directly above a
+ // Join here. Revisit if Pinot ever retains a Correlate over a Join.
//@formatter:off
@Override
protected void perform(RelOptRuleCall call, @Nullable Filter filter, Join
join) {
+ // From CALCITE-7373: a non-deterministic conjunct such as rand() < 0.1
has an empty input bitmap, so
+ // classifyFilters would treat it as pushable and relocate it below the
join, evaluating it per input row instead
+ // of per join-output row. Like upstream, this bails on the whole
condition rather than per conjunct, so a
+ // deterministic conjunct sharing the WHERE clause also stays above the
join.
+ // PINOT MODIFICATION to also skip volatile conditions. Upstream uses
RexUtil.isDeterministic, which only covers
+ // @ScalarFunction(isDeterministic = false). Pinot's separate
FunctionVolatility.VOLATILE axis (now(), ago(),
+ // stageId(), ...) keeps isDeterministic() == true so
PinotEvaluateLiteralRule can still fold it once at plan time,
+ // so it needs the wider PinotRuleUtils.isRelocatable check here.
+ // Skip non-deterministic or volatile filter condition
+ if (filter != null &&
!PinotRuleUtils.isRelocatable(filter.getCondition())) {
+ return;
+ }
+ // Skip non-deterministic or volatile join condition.
+ // NOTE: for an INNER join this guard is largely moot for anything
referencing a single side, because
+ // RelOptUtil.pushDownJoinConditions already hoisted such a call into that
input's Project during sql-to-rel,
+ // before any rule ran; the condition seen here is then a bare
RexInputRef. It is still load-bearing for outer
+ // joins, where the ON clause is preserved. See JoinPlans.json for both
shapes.
+ if (!PinotRuleUtils.isRelocatable(join.getCondition())) {
+ return;
+ }
+
List<RexNode> joinFilters =
RelOptUtil.conjunctions(join.getCondition());
final List<RexNode> origJoinFilters = List.copyOf(joinFilters);
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
index 4e876392d8d..b7ad7a4edb2 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtils.java
@@ -38,14 +38,19 @@ import org.apache.calcite.rex.RexCall;
import org.apache.calcite.rex.RexInputRef;
import org.apache.calcite.rex.RexLiteral;
import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.rex.RexVisitorImpl;
import org.apache.calcite.rex.RexWindowBound;
import org.apache.calcite.rex.RexWindowBounds;
import org.apache.calcite.sql.SqlAggFunction;
import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlOperator;
import org.apache.calcite.sql2rel.SqlToRelConverter;
import org.apache.calcite.tools.RelBuilder;
import org.apache.calcite.tools.RelBuilderFactory;
+import org.apache.calcite.util.Util;
import org.apache.pinot.calcite.rel.hint.PinotHintStrategyTable;
+import org.apache.pinot.common.function.sql.PinotSqlFunction;
public class PinotRuleUtils {
@@ -132,6 +137,49 @@ public class PinotRuleUtils {
return funcSqlKind == SqlKind.OTHER_FUNCTION ?
function.getOperator().getName() : funcSqlKind.name();
}
+ /// Returns whether `node` evaluates to the same result no matter where in
the plan it sits, and can therefore be
+ /// relocated -- pushed below a join, duplicated onto another input, and so
on.
+ ///
+ /// An expression must be clear of three axes of variability:
+ ///
+ /// - [SqlOperator#isDeterministic()] -- `false` for `rand()`, `UUID_V4`,
`UUID_V7` and Calcite's own `RAND` /
+ /// `RAND_INTEGER`. Delegated to `RexUtil#isDeterministic` so this half
tracks upstream automatically.
+ /// - [SqlOperator#isDynamicFunction()] -- Calcite's own "fold once per
query, never re-evaluate" marker, used by
+ /// `CURRENT_TIMESTAMP` and friends.
+ /// - [PinotSqlFunction#isVolatile()] -- Pinot's equivalent marker, `true`
for `FunctionVolatility.VOLATILE`
+ /// functions such as `now()`, `ago()` and `stageId()`. These deliberately
stay `isDeterministic() == true` so that
+ /// [PinotEvaluateLiteralRule] can still fold them once at plan time,
which is precisely why
+ /// `RexUtil#isDeterministic` alone does not catch them.
+ ///
+ /// Relocating an expression that fails this check changes how many times,
and in what context, it is evaluated --
+ /// which changes query results. `FunctionVolatility.STABLE` deliberately
passes: it is constant within a single
+ /// query, so moving it is safe.
+ ///
+ /// Note this is a predicate that callers must apply; it is not enforced
globally. Only [PinotFilterJoinRule]
+ /// consults it today, so other rules that relocate expressions can still
move volatile ones.
+ public static boolean isRelocatable(RexNode node) {
+ if (!RexUtil.isDeterministic(node)) {
+ return false;
+ }
+ try {
+ node.accept(new RexVisitorImpl<Void>(true) {
+ @Override
+ public Void visitCall(RexCall call) {
+ SqlOperator operator = call.getOperator();
+ if (operator.isDynamicFunction()
+ || (operator instanceof PinotSqlFunction && ((PinotSqlFunction)
operator).isVolatile())) {
+ throw Util.FoundOne.NULL;
+ }
+ return super.visitCall(call);
+ }
+ });
+ return true;
+ } catch (Util.FoundOne e) {
+ Util.swallow(e, null);
+ return false;
+ }
+ }
+
public static class WindowUtils {
// Supported window functions
// OTHER_FUNCTION supported are: BOOL_AND, BOOL_OR
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
index 773386badca..e05ee827547 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
@@ -324,7 +324,10 @@ public class PinotOperatorTable implements
SqlOperatorTable {
List.of(SqlTypeFamily.CHARACTER, SqlTypeFamily.CHARACTER,
SqlTypeFamily.CHARACTER, SqlTypeFamily.ANY),
i -> i > 1)),
- new PinotSqlFunction("NOW", ReturnTypes.TIMESTAMP, OperandTypes.NILADIC)
+ // Deterministic so PinotEvaluateLiteralRule folds it once at plan time,
but volatile so it is never
+ // re-evaluated at a different point in the plan. This entry shadows the
FunctionRegistry one (see
+ // registerScalarFunctions), so the volatility has to be repeated here.
+ new PinotSqlFunction("NOW", ReturnTypes.TIMESTAMP, OperandTypes.NILADIC,
true, true)
);
private static final List<Pair<SqlOperator, List<String>>>
PINOT_OPERATORS_WITH_ALIASES = List.of(
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java
new file mode 100644
index 00000000000..30fba46e27f
--- /dev/null
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotRuleUtilsTest.java
@@ -0,0 +1,159 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.calcite.rel.rules;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlSyntax;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.pinot.calcite.sql.fun.PinotOperatorTable;
+import org.apache.pinot.common.function.FunctionRegistry;
+import org.apache.pinot.common.function.PinotScalarFunction;
+import org.apache.pinot.common.function.sql.PinotSqlFunction;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Tests [PinotRuleUtils#isRelocatable], which decides whether an expression
may be moved to a different position in
+/// the plan. It must reject all three variability axes: `isDeterministic =
false`, Calcite's `isDynamicFunction()`,
+/// and Pinot's `FunctionVolatility.VOLATILE`.
+public class PinotRuleUtilsTest {
+
+ private final RelDataTypeFactory _typeFactory = new JavaTypeFactoryImpl();
+ private final RexBuilder _rexBuilder = new RexBuilder(_typeFactory);
+
+ private PinotSqlFunction registryFunction(String name) {
+ PinotScalarFunction scalarFunction =
FunctionRegistry.getFunctions().get(FunctionRegistry.canonicalize(name));
+ assertNotNull(scalarFunction, "Failed to find function: " + name);
+ PinotSqlFunction sqlFunction = scalarFunction.toPinotSqlFunction();
+ assertNotNull(sqlFunction, "Function is not registered as a
PinotSqlFunction: " + name);
+ return sqlFunction;
+ }
+
+ /// Resolves the operator a query would actually bind to, which is not
always the [FunctionRegistry] entry --
+ /// `PinotOperatorTable#registerScalarFunctions` skips names already present
in its hard-coded list.
+ private SqlOperator resolvedOperator(String name) {
+ List<SqlOperator> matches = new ArrayList<>();
+ PinotOperatorTable.instance(false).lookupOperatorOverloads(new
SqlIdentifier(name, SqlParserPos.ZERO),
+ SqlFunctionCategory.USER_DEFINED_FUNCTION, SqlSyntax.FUNCTION,
matches, null);
+ assertFalse(matches.isEmpty(), "Failed to resolve operator: " + name);
+ return matches.get(0);
+ }
+
+ private RexNode call(SqlOperator operator, RexNode... operands) {
+ RelDataType returnType = _typeFactory.createSqlType(SqlTypeName.BIGINT);
+ return _rexBuilder.makeCall(returnType, operator, List.of(operands));
+ }
+
+ private RexNode literal(int value) {
+ return _rexBuilder.makeLiteral(value,
_typeFactory.createSqlType(SqlTypeName.INTEGER));
+ }
+
+ @Test
+ public void testLiteralAndInputRefAreRelocatable() {
+ assertTrue(PinotRuleUtils.isRelocatable(literal(1)));
+ assertTrue(PinotRuleUtils.isRelocatable(
+
_rexBuilder.makeInputRef(_typeFactory.createSqlType(SqlTypeName.INTEGER), 0)));
+ }
+
+ @Test
+ public void testDeterministicCallIsRelocatable() {
+ assertTrue(PinotRuleUtils.isRelocatable(call(SqlStdOperatorTable.PLUS,
literal(1), literal(2))));
+ }
+
+ @Test
+ public void testImmutableFunctionIsRelocatable() {
+ PinotSqlFunction upper = registryFunction("upper");
+ assertTrue(upper.isDeterministic());
+ assertFalse(upper.isVolatile());
+ assertTrue(PinotRuleUtils.isRelocatable(call(upper,
_rexBuilder.makeLiteral("x"))));
+ }
+
+ @Test
+ public void testNonDeterministicFunctionIsNotRelocatable() {
+ // rand() is @ScalarFunction(isDeterministic = false); the operator-level
flag is shared with the seeded overload.
+ PinotSqlFunction rand = registryFunction("rand");
+ assertFalse(rand.isDeterministic());
+ assertFalse(PinotRuleUtils.isRelocatable(call(rand)));
+ }
+
+ @Test
+ public void testVolatileFunctionIsNotRelocatable() {
+ // stageId() stays deterministic so it can be constant-folded, but is
VOLATILE, so it must not be relocated.
+ PinotSqlFunction stageId = registryFunction("stageId");
+ assertTrue(stageId.isDeterministic(), "stageId should stay deterministic
for compile-time evaluation");
+ assertTrue(stageId.isVolatile());
+ assertFalse(PinotRuleUtils.isRelocatable(call(stageId, literal(0))));
+ }
+
+ /// `FunctionVolatility.STABLE` is constant within a single query, so it is
safe to relocate. `reqId` gets STABLE
+ /// from the class-level annotation on `InternalFunctions`, which also
covers annotation inheritance.
+ @Test
+ public void testStableFunctionIsRelocatable() {
+ PinotSqlFunction reqId = registryFunction("reqId");
+ assertTrue(reqId.isDeterministic());
+ assertFalse(reqId.isVolatile(), "STABLE must not be reported as volatile");
+ assertTrue(PinotRuleUtils.isRelocatable(call(reqId, literal(0))));
+ }
+
+ /// Calcite's own dynamic functions carry the same "fold once, never
re-evaluate" contract via a different flag.
+ @Test
+ public void testCalciteDynamicFunctionIsNotRelocatable() {
+ assertTrue(SqlStdOperatorTable.CURRENT_TIMESTAMP.isDynamicFunction());
+ assertTrue(SqlStdOperatorTable.CURRENT_TIMESTAMP.isDeterministic(),
+ "guarding on isDeterministic alone would miss this");
+
assertFalse(PinotRuleUtils.isRelocatable(call(SqlStdOperatorTable.CURRENT_TIMESTAMP)));
+ }
+
+ /// Both the registry entry and the hard-coded [PinotOperatorTable] entry
that shadows it must agree that `now()` is
+ /// volatile, otherwise the guard's answer depends on which one a query
happens to bind to.
+ @Test
+ public void testNowIsVolatileOnBothRegistrations() {
+ PinotSqlFunction registryNow = registryFunction("now");
+ assertTrue(registryNow.isDeterministic(), "now() must stay deterministic
so it is folded once at plan time");
+ assertTrue(registryNow.isVolatile());
+ assertFalse(PinotRuleUtils.isRelocatable(call(registryNow)));
+
+ SqlOperator resolvedNow = resolvedOperator("NOW");
+ assertTrue(resolvedNow instanceof PinotSqlFunction, "expected a
PinotSqlFunction, got: " + resolvedNow.getClass());
+ assertTrue(((PinotSqlFunction) resolvedNow).isVolatile(),
+ "the operator NOW() actually binds to must also report volatile");
+ assertFalse(PinotRuleUtils.isRelocatable(call(resolvedNow)));
+ }
+
+ @Test
+ public void testNestedVolatileOperandIsDetected() {
+ // The visitor must recurse into operands, not just inspect the top-level
operator.
+ RexNode nested = call(SqlStdOperatorTable.PLUS, literal(1),
call(registryFunction("stageId"), literal(0)));
+ assertFalse(PinotRuleUtils.isRelocatable(nested));
+ }
+}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index 4416e47bb52..54e916982c5 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -434,6 +434,34 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
}
}
+ /// [org.apache.pinot.calcite.rel.rules.PinotFilterJoinRule] refuses to push
a volatile filter below a join. `now()`
+ /// is volatile, but
[org.apache.pinot.calcite.rel.rules.PinotEvaluateLiteralRule] folds it to a
literal first, so
+ /// the common time-filter-over-a-join pattern must still reach the leaf
scan.
+ ///
+ /// Asserted here rather than in JoinPlans.json because the folded epoch
literal differs on every run.
+ @Test
+ public void testVolatileNowFilterIsStillPushedBelowJoin() {
+ String query =
+ "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1 =
b.col1 WHERE a.ts > now() - 86400000";
+
+ String explain = _queryEnvironment.explainQuery(query,
RANDOM_REQUEST_ID_GEN.nextLong());
+ // now() folds to the current epoch millis, so mask the literal before
comparing.
+ String normalized = explain.replaceAll("(?<=\\$7, )\\d+", "<EPOCH>");
+ //@formatter:off
+ assertEquals(normalized,
+ "Execution Plan\n"
+ + "LogicalProject(col1=[$0], col2=[$2])\n"
+ + " LogicalJoin(condition=[=($0, $1)], joinType=[inner])\n"
+ + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " LogicalProject(col1=[$0])\n"
+ + " LogicalFilter(condition=[>($7, <EPOCH>)])\n"
+ + " PinotLogicalTableScan(table=[[default, a]])\n"
+ + " PinotLogicalExchange(distribution=[hash[0]])\n"
+ + " LogicalProject(col1=[$0], col2=[$1])\n"
+ + " PinotLogicalTableScan(table=[[default, b]])\n");
+ //@formatter:on
+ }
+
@Test
public void testAggregateCaseToFilter() {
// Tests that queries like "SELECT SUM(CASE WHEN col1 = 'a' THEN 1 ELSE 0
END) FROM a" are rewritten to
diff --git a/pinot-query-planner/src/test/resources/queries/JoinPlans.json
b/pinot-query-planner/src/test/resources/queries/JoinPlans.json
index eee3a32cf66..a96520741ea 100644
--- a/pinot-query-planner/src/test/resources/queries/JoinPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/JoinPlans.json
@@ -664,6 +664,210 @@
"\n PinotLogicalTableScan(table=[[default, b]])",
"\n"
]
+ },
+ {
+ "description": "Non-deterministic WHERE filter is not pushed below the
join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 WHERE rand() < 0.1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[<(RAND(), 0.1E0)])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Non-deterministic WHERE filter is not pushed below a
left join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a LEFT JOIN b ON
a.col1 = b.col1 WHERE rand() < 0.1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[<(RAND(), 0.1E0)])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[left])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Non-deterministic WHERE filter on the null-generating
side does not simplify the left join to inner",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a LEFT JOIN b ON
a.col1 = b.col1 WHERE b.col3 > 100 * rand()",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[>(CAST($3):DOUBLE, *(100, RAND()))])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[left])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1], col3=[$2])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Non-deterministic WHERE filter on the null-generating
side does not simplify the full join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a FULL JOIN b ON
a.col1 = b.col1 WHERE b.col3 > 100 * rand()",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[>(CAST($3):DOUBLE, *(100, RAND()))])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[full])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1], col3=[$2])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Non-deterministic ON condition is not pushed below the
join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 AND rand() < 0.1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalJoin(condition=[AND(=($0, $1), <(RAND(), 0.1E0))],
joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Non-deterministic UUID filter is not pushed below the
join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 WHERE uuid_v4() <> 'x'",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[<>(CAST(UUIDV4()):CHAR(1) CHARACTER
SET \"UTF-8\" NOT NULL, _UTF-8'x')])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Non-deterministic filter blocks the semi join rewrite:
the IN sub-query is planned as an inner join over a distinct aggregate, with
the filter kept above the join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1 FROM a WHERE a.col1 IN (SELECT
b.col1 FROM b) AND rand() < 0.1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0])",
+ "\n LogicalFilter(condition=[<(RAND(), 0.1E0)])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n PinotLogicalAggregate(group=[{0}], aggType=[FINAL])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n PinotLogicalAggregate(group=[{0}], aggType=[LEAF])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "A non-deterministic conjunct holds the whole WHERE
filter above the join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 WHERE a.col3 > 5 AND rand() < 0.1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$3])",
+ "\n LogicalFilter(condition=[AND(>($1, 5), <(RAND(), 0.1E0))])",
+ "\n LogicalJoin(condition=[=($0, $2)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col3=[$2])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Volatile WHERE filter is not pushed below the join,
nor duplicated onto both inputs",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 WHERE stageId(a.col1) >= 0",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[>=(STAGEID($0), 0)])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Volatile ON condition is kept in the join condition
for an outer join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a LEFT JOIN b ON
a.col1 = b.col1 AND stageId(b.col3) >= 0",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalJoin(condition=[AND(=($0, $1), >=(STAGEID($3), 0))],
joinType=[left])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1],
EXPR$0=[CAST($2):VARCHAR CHARACTER SET \"UTF-8\" NOT NULL])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "KNOWN GAP: a volatile ON conjunct referencing one side
of an INNER join is hoisted into that input by
RelOptUtil.pushDownJoinConditions during sql-to-rel, before any rule runs, so
the join-condition guard never sees it",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 AND stageId(a.col3) >= 0",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n LogicalFilter(condition=[>=(STAGEID(CAST($2):VARCHAR
CHARACTER SET \"UTF-8\" NOT NULL), 0)])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "Deterministic WHERE filter is still pushed below the
join",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, b.col2 FROM a JOIN b ON a.col1
= b.col1 WHERE a.col3 > 5",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0])",
+ "\n LogicalFilter(condition=[>($2, 5)])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
}
]
},
@@ -684,6 +888,22 @@
"\n"
]
},
+ {
+ "description": "Non-deterministic WHERE filter is not pushed below a
lookup join",
+ "sql": "EXPLAIN PLAN FOR SELECT /*+ joinOptions(join_strategy =
'lookup') */ a.col1, b.col2 FROM a JOIN b ON a.col1 = b.col1 WHERE rand() <
0.1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$2])",
+ "\n LogicalFilter(condition=[<(RAND(), 0.1E0)])",
+ "\n LogicalJoin(condition=[=($0, $1)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[single])",
+ "\n LogicalProject(col1=[$0])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n LogicalProject(col1=[$0], col2=[$1])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
{
"description": "Lookup join with filter on left table",
"sql": "EXPLAIN PLAN FOR SELECT /*+ joinOptions(join_strategy =
'lookup') */ a.col1, b.col2 FROM a JOIN b ON a.col1 = b.col1 WHERE a.col2 =
'foo'",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]