peter-toth commented on code in PR #57727:
URL: https://github.com/apache/spark/pull/57727#discussion_r3711189546
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -213,6 +217,22 @@ object PushDownUtils extends Logging {
}
translatedFiltersPushed || partPredicatesPushed
+
+ case catalystScan: SupportsRuntimeCatalystFiltering if
runtimeFilters.nonEmpty =>
+ // A DPP filter degrades to TrueLiteral when its subquery is pruned
away; it carries no
+ // information for the source. The V2 path above drops these
implicitly because
+ // translateRuntimeFilterV2 returns None; here we push Catalyst
expressions directly,
+ // so filter them out explicitly.
+ val catalystFilters = runtimeFilters
+ .flatMap(unwrapRuntimeFilterExpression)
+ .filterNot(_ == Literal.TrueLiteral)
Review Comment:
**Finding 1.** This branch pushes every unwrapped runtime filter with no
determinism guard. Every sibling screens for it: the V2 second pass runs its
runtime filters through `isPushablePartitionFilter` (`PushDownUtils.scala:471`
— `deterministic && !hasSubquery && no PythonUDF`), both `pushFilters` branches
partition out non-deterministic filters (SPARK-58112, SPARK-58207), and the
Catalyst pushdown path this interface is modelled on does the same —
`FileScanBuilder.pushFilters` (`FileScanBuilder.scala:73-79`) splits on
`_.deterministic` and drops subquery/`PythonUDF` filters from the partition
filters it keeps. Here nothing does.
Run on this head against your own fixture:
SELECT * FROM tbl WHERE part = (SELECT max(val) FROM dim) OR rand() < 0.5
pushes `((part#270 = 3) OR (rand(6694523947714441432) < 0.5))` —
`deterministic = false` — while the same predicate stays in the post-scan
`FilterExec`. Any partition the source prunes on its own roll of `rand()` is
gone for good, and Spark re-rolls for the rows that survive, so rows that
should have passed are dropped.
Add `TBLPROPERTIES('fully-pushed-filter-attributes' = 'part')` and it gets
worse — the post-scan filter disappears entirely and the connector is the only
evaluator of `rand()`, once per partition rather than once per row:
*(1) Project [id#37, part#38]
+- BatchScan ...tbl_f[id#37, part#38] ... RuntimeFilters: [((part#38 =
Subquery subquery#36, [id=#100]) OR (rand(-656537516222552506) < 0.5))]
The V2 first pass shares the missing guard
(`translateScalarSubqueryFilterV2` translates `Rand` fine —
`V2ExpressionBuilder.scala:155`), so that half is pre-existing and deserves its
own ticket. But the V2 path can never drop a post-scan filter, so the
"evaluated nowhere" case is new here.
Screening with the guard the sibling pass already applies keeps the two
consistent:
```suggestion
.filterNot(_ == Literal.TrueLiteral)
.filter(isPushablePartitionFilter)
```
DPP filters still go through: `isPushablePartitionFilter`'s subquery check
is on catalyst `SubqueryExpression`, and `InSubqueryExec`/`ScalarSubquery` are
`ExecSubqueryExpression`.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala:
##########
@@ -0,0 +1,77 @@
+/*
+ * 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.spark.sql.internal.connector
+
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.connector.expressions.NamedReference
+import org.apache.spark.sql.connector.read.Scan
+
+/**
+ * A mix-in interface for [[Scan]]. Data sources can implement this interface
if they can
+ * filter initially planned
[[org.apache.spark.sql.connector.read.InputPartition]]s using
+ * Catalyst [[Expression]]s Spark infers at runtime.
+ * Only one runtime filtering interface should be implemented by a data source.
+ *
+ * Spark considers a runtime predicate fully pushed when all attributes
referenced by the
+ * predicate are returned by [[fullyPushedFilterAttributes]]. Fully pushed
predicates are not
+ * evaluated again after the scan.
+ *
+ * Note that Spark will push runtime filters only if they are beneficial.
+ */
+trait SupportsRuntimeCatalystFiltering extends Scan {
+
+ /**
+ * Returns attributes this scan can be filtered by at runtime.
+ *
+ * Spark will call [[filter]] if it can derive a runtime filter for any of
these attributes.
+ */
+ def filterAttributes(): Array[NamedReference]
+
+ /**
+ * Returns attributes for which this scan fully evaluates runtime predicates.
+ *
+ * Any runtime predicate that references only attributes in this set is
considered fully pushed
+ * and will not be evaluated again after the scan. These attributes must
also be returned by
+ * [[filterAttributes]].
+ */
+ def fullyPushedFilterAttributes(): Array[NamedReference] = Array.empty
Review Comment:
**Finding 2.** No objection to the attribute-level shape — v2 file sources
already work this way. `FileScanBuilder.pushFilters`
(`FileScanBuilder.scala:72-95`) keeps every deterministic partition filter for
itself and returns only `dataFilters ++ nonDeterministicFilters` as post-scan
filters, so "any predicate over these attributes is fully evaluated by the
source" is established practice. What I'd like is for the Javadoc to say what
makes it sound, since nothing in the tree implements this trait yet and two
things are easy to get wrong, both silently:
1. **Exactness, not just reachability.** The file-source precedent holds
because partition pruning is exact — every row of a surviving file carries that
partition value. Nothing restricts `filterAttributes` to partition columns:
`SupportsRuntimeV2Filtering` documents it as "attributes this scan can be
filtered by at runtime", and a scan may prune files or row groups by min/max
statistics on a data column. Statistics-based pruning is not exact, so
declaring such an attribute here returns extra rows with no error.
2. **Any shape, not the shapes you recognize.** The source can't refuse an
individual predicate — by the time `filter()` runs, `DataSourceV2Strategy` has
already removed the `FilterExec`. On this head, with
`fully-pushed-filter-attributes='part'`:
SELECT * FROM t WHERE part > (SELECT max(val) FROM dim) + 1 AND
CAST(part AS STRING) RLIKE '4'
leaves only `Filter (isnotnull(part#341) AND RLIKE(cast(part#341 as
string), 4))` above the scan; `part > (2 + 1)` is gone, pushed as `(part#341 >
(2 + 1))`. A source that hand-matches operators and ignores the rest —
`InMemoryTableWithV2Filter.filter` handles only `=` and `IN` — drops it on the
floor. `InMemoryEnhancedRuntimePartitionFilterTable` gets it right by
delegating to `PartitionPredicate.eval`, i.e. bind and interpret
(`PartitionPredicateImpl.boundPredicate`), which is what the file index does
too.
Something along these lines:
```scala
/**
* Returns attributes for which this scan fully evaluates runtime
predicates.
*
* Any runtime predicate that references only attributes in this set is
considered fully pushed
* and will not be evaluated again after the scan. These attributes must
also be returned by
* [[filterAttributes]].
*
* Only declare an attribute here if this scan evaluates an arbitrary
deterministic Catalyst
* predicate over it exactly, for every row it returns -- e.g. an identity
partition column,
* whose value is known for every row of a surviving partition. Do not
declare an attribute
* whose predicates only guide approximate pruning, such as file or
row-group statistics.
* Spark may push any expression that references only these attributes, so
do not assume a
* fixed set of operators: bind and evaluate the expression (see
* [[PartitionPredicateImpl]]) instead of pattern matching it.
*/
```
While you're here, it would help to name the intended implementor in the PR
description — it makes the contract judgeable and tells a reader why the
interface is internal.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala:
##########
@@ -0,0 +1,77 @@
+/*
+ * 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.spark.sql.internal.connector
+
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.connector.expressions.NamedReference
+import org.apache.spark.sql.connector.read.Scan
+
+/**
+ * A mix-in interface for [[Scan]]. Data sources can implement this interface
if they can
+ * filter initially planned
[[org.apache.spark.sql.connector.read.InputPartition]]s using
+ * Catalyst [[Expression]]s Spark infers at runtime.
+ * Only one runtime filtering interface should be implemented by a data source.
+ *
+ * Spark considers a runtime predicate fully pushed when all attributes
referenced by the
+ * predicate are returned by [[fullyPushedFilterAttributes]]. Fully pushed
predicates are not
+ * evaluated again after the scan.
+ *
+ * Note that Spark will push runtime filters only if they are beneficial.
+ */
+trait SupportsRuntimeCatalystFiltering extends Scan {
+
+ /**
+ * Returns attributes this scan can be filtered by at runtime.
+ *
+ * Spark will call [[filter]] if it can derive a runtime filter for any of
these attributes.
+ */
+ def filterAttributes(): Array[NamedReference]
+
+ /**
+ * Returns attributes for which this scan fully evaluates runtime predicates.
+ *
+ * Any runtime predicate that references only attributes in this set is
considered fully pushed
+ * and will not be evaluated again after the scan. These attributes must
also be returned by
+ * [[filterAttributes]].
+ */
+ def fullyPushedFilterAttributes(): Array[NamedReference] = Array.empty
+
+ /**
+ * Filters this scan using runtime Catalyst expressions.
+ *
+ * The provided expressions must be interpreted as a set of predicates that
are ANDed together.
+ * Implementations may use the expressions to prune initially planned
+ * [[org.apache.spark.sql.connector.read.InputPartition]]s.
+ *
+ * Note that Spark will call [[Scan.toBatch]] again after filtering the scan
at runtime.
+ */
+ def filter(expressions: Array[Expression]): Unit
Review Comment:
**Finding 4.** `SupportsRuntimeV2Filtering.filter` documents the
partitioning-preservation contract — *"If the scan also implements
SupportsReportPartitioning, it must preserve the originally reported
partitioning ... The scan must not report new partition values that were not
present in the original partitioning"* — and
`PushDownUtils.replanWithRuntimeFilters` enforces it for whatever scan it was
handed, this interface included: it calls `pushRuntimeFilters`, then
`scan.toBatch.planInputPartitions()`, then the `KeyedPartitioning` checks that
throw `SparkException` on a missing `HasPartitionKey`, a new partition key, or
a grown per-key partition count. An SPJ-active adopter reading only this
Javadoc finds out from `"Data source must have preserved the original
partitioning during runtime filtering"`. Please carry that paragraph over.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala:
##########
@@ -0,0 +1,77 @@
+/*
+ * 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.spark.sql.internal.connector
+
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.connector.expressions.NamedReference
+import org.apache.spark.sql.connector.read.Scan
+
+/**
+ * A mix-in interface for [[Scan]]. Data sources can implement this interface
if they can
+ * filter initially planned
[[org.apache.spark.sql.connector.read.InputPartition]]s using
+ * Catalyst [[Expression]]s Spark infers at runtime.
+ * Only one runtime filtering interface should be implemented by a data source.
+ *
+ * Spark considers a runtime predicate fully pushed when all attributes
referenced by the
+ * predicate are returned by [[fullyPushedFilterAttributes]]. Fully pushed
predicates are not
+ * evaluated again after the scan.
+ *
+ * Note that Spark will push runtime filters only if they are beneficial.
+ */
+trait SupportsRuntimeCatalystFiltering extends Scan {
+
+ /**
+ * Returns attributes this scan can be filtered by at runtime.
+ *
+ * Spark will call [[filter]] if it can derive a runtime filter for any of
these attributes.
+ */
+ def filterAttributes(): Array[NamedReference]
+
+ /**
+ * Returns attributes for which this scan fully evaluates runtime predicates.
+ *
+ * Any runtime predicate that references only attributes in this set is
considered fully pushed
+ * and will not be evaluated again after the scan. These attributes must
also be returned by
+ * [[filterAttributes]].
+ */
+ def fullyPushedFilterAttributes(): Array[NamedReference] = Array.empty
+
+ /**
+ * Filters this scan using runtime Catalyst expressions.
+ *
+ * The provided expressions must be interpreted as a set of predicates that
are ANDed together.
+ * Implementations may use the expressions to prune initially planned
+ * [[org.apache.spark.sql.connector.read.InputPartition]]s.
+ *
+ * Note that Spark will call [[Scan.toBatch]] again after filtering the scan
at runtime.
+ */
+ def filter(expressions: Array[Expression]): Unit
+
+ /**
+ * Returns the predicates that are pushed to the data source via [[filter]].
+ *
+ * This method does not indicate whether a predicate is fully pushed. Spark
infers that from
+ * [[fullyPushedFilterAttributes]]. The returned predicates may fully or
partially help the data
+ * source prune initially planned
+ * [[org.apache.spark.sql.connector.read.InputPartition]]s.
+ *
+ * It's possible that there are no runtime predicates and [[filter]] is
never called;
+ * an empty array should be returned for this case.
+ */
+ def pushedPredicates(): Array[Expression] = Array.empty
Review Comment:
**Finding 5.** Nothing in Spark reads this.
`SupportsRuntimeV2Filtering.pushedPredicates()` earns its place —
`PushDownUtils.scala:133,205` uses it to avoid pushing the same predicate twice
across the two iterative passes — but this path pushes once and never consults
the result; grepping `sql/core/src/main` and `sql/catalyst/src/main` for
`pushedPredicates` finds no call on this trait, only the new suite's assertions.
Either drop it and let the fixture expose its own accessor
(`InMemoryEnhancedRuntimePartitionFilterTable.pushedPartitionPredicates` is the
precedent), or keep it and say in the Javadoc that it exists for
inspection/testing and Spark does not consult it — as written the doc reads
like part of a contract Spark relies on.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Relation.scala:
##########
@@ -196,14 +196,30 @@ case class DataSourceV2ScanRelation(
/**
* Resolved attributes that the scan declares for runtime filtering via
- * [[SupportsRuntimeV2Filtering.filterAttributes]]. Empty when the scan
- * does not implement [[SupportsRuntimeV2Filtering]] or exposes no
attributes.
+ * [[SupportsRuntimeV2Filtering.filterAttributes]] or
+ * [[SupportsRuntimeCatalystFiltering.filterAttributes]]. Empty when the scan
+ * implements neither interface or exposes no attributes.
*/
- lazy val runtimeFilterAttrs: AttributeSet = scan match {
- case s: SupportsRuntimeV2Filtering =>
- AttributeSet(V2ExpressionUtils.resolveRefs[Attribute](
- s.filterAttributes.toImmutableArraySeq, this))
- case _ => AttributeSet.empty
+ lazy val runtimeFilterAttrs: AttributeSet = {
+ val filterAttrs = scan match {
+ case s: SupportsRuntimeV2Filtering => s.filterAttributes
+ case s: SupportsRuntimeCatalystFiltering => s.filterAttributes()
+ case _ => Array.empty[NamedReference]
+ }
+ AttributeSet(V2ExpressionUtils.resolveRefs[Attribute](
+ filterAttrs.toImmutableArraySeq, this))
+ }
+
+ /**
+ * Resolved attributes for which a Catalyst runtime-filtering scan fully
evaluates predicates.
+ */
+ lazy val fullyPushedRuntimeFilterAttrs: AttributeSet = {
+ val filterAttrs = scan match {
+ case s: SupportsRuntimeCatalystFiltering =>
s.fullyPushedFilterAttributes()
+ case _ => Array.empty[NamedReference]
+ }
+ AttributeSet(V2ExpressionUtils.resolveRefs[Attribute](
Review Comment:
**Finding 6.** `resolveRefs` → `V2ExpressionUtils.resolveRef` throws
`cannotResolveAttributeError` when a reference doesn't resolve against the
plan's output, and casts the result to `Attribute` — so a nested reference,
which `LogicalPlan.resolve` hands back as an `Alias(GetStructField(...))`,
throws a `ClassCastException`. `fullyPushedFilterAttributes()` therefore has an
unwritten requirement: top-level attributes only, and only ones that survived
column pruning into the scan's `readSchema`. Break it and the query fails at
planning time.
`filterAttributes` carries the same requirement and is equally undocumented,
but it's forced for every scan relation, so an adopter trips it on the first
query. This one is only forced when a scalar-subquery runtime filter is present
(`scalarSubqueryFilters.filter` doesn't evaluate its closure on an empty Seq),
which makes it a query-shape-dependent failure. Worth a line on the trait
alongside finding 2; the new fixture quietly depends on it via the
`scanFields.contains(name)` guard at
`InMemoryCatalystRuntimeFilterTable.scala:267`.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala:
##########
@@ -0,0 +1,308 @@
+/*
+ * 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.spark.sql.connector
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.catalyst.expressions.{Add, AttributeReference,
DynamicPruning, DynamicPruningExpression, EqualTo, Expression, GreaterThan,
Literal}
+import
org.apache.spark.sql.connector.catalog.{InMemoryCatalystRuntimeFilterTable,
InMemoryTableCatalystRuntimeFilterCatalog}
+import org.apache.spark.sql.execution.{FilterExec, ScalarSubquery =>
ExecScalarSubquery}
+import org.apache.spark.sql.execution.ExplainUtils.stripAQEPlan
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.IntegerType
+
+/**
+ * Tests for scans that implement
+ *
[[org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering]],
+ * where runtime filters are pushed once as Catalyst expressions instead of
connector
+ * predicates.
+ */
+class DataSourceV2CatalystRuntimeFilterSuite extends SharedSparkSession {
+
+ protected val v2Source = classOf[FakeV2ProviderWithCustomSchema].getName
+ protected val catalogName = "testcatalystruntimefilter"
+
+ override def sparkConf: SparkConf = super.sparkConf
+ .set(s"spark.sql.catalog.$catalogName",
+ classOf[InMemoryTableCatalystRuntimeFilterCatalog].getName)
+
+ private def withDPPConf(f: => Unit): Unit = {
+ withSQLConf(
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10")(f)
+ }
+
+ test("scalar subquery on partition column -> pushed as Catalyst expression")
{
+ val tbl = s"$catalogName.tbl1"
+ val dim = s"$catalogName.dim1"
+ withTable(tbl, dim) {
+ sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED
BY (part)")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $tbl VALUES ($i, $i)")
+ }
+ sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (3)")
+
+ val df = sql(s"SELECT * FROM $tbl WHERE part = (SELECT max(val) FROM
$dim)")
+ checkAnswer(df, Row(3, 3))
+
+ assertScalarSubqueryRuntimeFilters(df)
+ val part = AttributeReference("part", IntegerType, nullable = false)()
+ assertPushedCatalystPredicatesEqual(df, EqualTo(part, Literal(3)))
+ // `part` is not declared fully pushed, so Spark still evaluates the
filter after the scan.
+ assertScalarSubqueryEvaluatedAfterScan(df, expected = true)
+ }
+ }
+
+ test("predicate on fully pushed filter attributes -> not evaluated after the
scan") {
+ val tbl = s"$catalogName.tbl_fully_pushed"
+ val dim = s"$catalogName.dim_fully_pushed"
+ withTable(tbl, dim) {
+ sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED
BY (part) " +
+ "TBLPROPERTIES('fully-pushed-filter-attributes' = 'part')")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $tbl VALUES ($i, 3)")
+ }
+ sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (3)")
+
+ val df = sql(s"SELECT * FROM $tbl WHERE part = (SELECT max(val) FROM
$dim)")
+ checkAnswer(df, (0 until 5).map(i => Row(i, 3)))
+
+ assertScalarSubqueryRuntimeFilters(df)
+ val part = AttributeReference("part", IntegerType, nullable = false)()
+ assertPushedCatalystPredicatesEqual(df, EqualTo(part, Literal(3)))
+ assertScalarSubqueryEvaluatedAfterScan(df, expected = false)
+ }
+ }
+
+ test("predicate on partly fully pushed filter attributes -> evaluated after
the scan") {
+ val tbl = s"$catalogName.tbl_partly_pushed"
+ val dim = s"$catalogName.dim_partly_pushed"
+ withTable(tbl, dim) {
+ sql(s"CREATE TABLE $tbl (id INT, p1 INT, p2 INT) USING $v2Source " +
+ "PARTITIONED BY (p1, p2) " +
+ "TBLPROPERTIES('fully-pushed-filter-attributes' = 'p1')")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $tbl VALUES ($i, 1, 2)")
+ }
+ sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (3)")
+
+ // The predicate also references p2, which is not declared fully pushed,
so it is not
+ // considered fully pushed and Spark keeps evaluating it after the scan.
+ val df = sql(s"SELECT * FROM $tbl WHERE p1 + p2 = (SELECT max(val) FROM
$dim)")
+ checkAnswer(df, (0 until 5).map(i => Row(i, 1, 2)))
+
+ assertScalarSubqueryRuntimeFilters(df)
+ val p1 = AttributeReference("p1", IntegerType, nullable = false)()
+ val p2 = AttributeReference("p2", IntegerType, nullable = false)()
+ assertPushedCatalystPredicatesEqual(df, EqualTo(Add(p1, p2), Literal(3)))
+ assertScalarSubqueryEvaluatedAfterScan(df, expected = true)
+ }
+ }
+
+ test("untranslatable filter -> pushed instead of dropped") {
+ val tbl = s"$catalogName.tbl2"
+ val dim = s"$catalogName.dim2"
+ withTable(tbl, dim) {
+ sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED
BY (part)")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $tbl VALUES ($i, $i)")
+ }
+ sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (2)")
+
+ // `part > sub + 1` has no data source V2 translation, so the V2
interfaces would never
+ // see it. The scalar subquery is literalized but the surrounding
expression is kept.
+ val df = sql(s"SELECT * FROM $tbl WHERE part > (SELECT max(val) FROM
$dim) + 1")
+ checkAnswer(df, Row(4, 4))
+
+ assertScalarSubqueryRuntimeFilters(df)
+ val part = AttributeReference("part", IntegerType, nullable = false)()
+ assertPushedCatalystPredicatesEqual(
+ df, GreaterThan(part, Add(Literal(2), Literal(1))))
+ }
+ }
+
+ test("DPP filter -> pushed as InSubqueryExec expression") {
+ val fact = s"$catalogName.fact3"
+ val dim = s"$catalogName.dim3"
+ withTable(fact, dim) {
+ sql(s"CREATE TABLE $fact (id INT, part INT) USING $v2Source PARTITIONED
BY (part)")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $fact VALUES ($i, $i)")
+ }
+ sql(s"CREATE TABLE $dim (dim_id INT, dim_val STRING) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (2, 'two')")
+
+ withDPPConf {
+ val df = sql(
+ s"""SELECT f.id, f.part FROM $fact f JOIN $dim d
+ |ON f.part = d.dim_id WHERE d.dim_val = 'two'""".stripMargin)
+ checkAnswer(df, Row(2, 2))
+
+ assertDPPRuntimeFilters(df)
+ val dppPredicate = collectBatchScan(df).runtimeFilters.collectFirst {
+ case DynamicPruningExpression(e) => e
+ }.get
+ assertPushedCatalystPredicatesEqual(df, dppPredicate)
+ }
+ }
+ }
+
+ test("filter on column outside filterAttributes -> not pushed") {
+ val tbl = s"$catalogName.tbl4"
+ val dim = s"$catalogName.dim4"
+ withTable(tbl, dim) {
+ sql(s"CREATE TABLE $tbl (id INT, p1 INT, p2 INT) USING $v2Source " +
+ "PARTITIONED BY (p1, p2) " +
+ "TBLPROPERTIES('filter-attributes' = 'p1')")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $tbl VALUES ($i, $i, 10)")
+ }
+ sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (10)")
+
+ // p2 is a partition column but is not declared filterable, so no
runtime filter is derived.
+ val df = sql(s"SELECT * FROM $tbl WHERE p2 = (SELECT max(val) FROM
$dim)")
+ checkAnswer(df, (0 until 5).map(i => Row(i, i, 10)))
+
+ assert(collectBatchScan(df).runtimeFilters.isEmpty,
+ "Expected no runtime filters for a column outside filterAttributes")
+ assertPushedCatalystPredicates(df, 0)
+ }
+ }
+
+ test("no runtime filter -> filter() is never called") {
+ val tbl = s"$catalogName.tbl5"
+ withTable(tbl) {
+ sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED
BY (part)")
+ for (i <- 0 until 5) {
+ sql(s"INSERT INTO $tbl VALUES ($i, $i)")
+ }
+
+ val df = sql(s"SELECT * FROM $tbl WHERE part = 3")
+ checkAnswer(df, Row(3, 3))
+
+ assert(collectBatchScan(df).runtimeFilters.isEmpty)
+ assertPushedCatalystPredicates(df, 0)
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // Helper methods
+ //
---------------------------------------------------------------------------
+
+ private def assertDPPRuntimeFilters(
+ df: DataFrame, expectedCount: Int = 1): Unit = {
+ val batchScan = collectBatchScan(df)
+ val dppFilters = batchScan.runtimeFilters.collect {
+ case d: DynamicPruningExpression => d
+ }
+ assert(dppFilters.size === expectedCount,
+ s"Expected $expectedCount DynamicPruningExpression(s) " +
+ s"in runtimeFilters, got ${dppFilters.size}")
+ }
+
+ private def assertScalarSubqueryRuntimeFilters(
+ df: DataFrame, expectedCount: Int = 1): Unit = {
+ val batchScan = collectBatchScan(df)
+ val scalarFilters = batchScan.runtimeFilters.collect {
+ case f if !f.isInstanceOf[DynamicPruning] => f
+ }
+ val dppFilters = batchScan.runtimeFilters.collect {
+ case d: DynamicPruning => d
+ }
+ assert(scalarFilters.size === expectedCount,
+ s"Expected $expectedCount scalar subquery runtime filter(s), " +
+ s"got ${scalarFilters.size}")
+ assert(dppFilters.isEmpty,
+ "Expected non-DPP runtime filters (scalar subquery)")
+ }
+
+ /**
+ * Checks whether a scalar subquery runtime filter is still evaluated by a
[[FilterExec]] above
+ * the scan. Filters that only reference `fullyPushedFilterAttributes` are
dropped from it.
+ */
+ private def assertScalarSubqueryEvaluatedAfterScan(
+ df: DataFrame,
+ expected: Boolean): Unit = {
+ val postScanConditions =
stripAQEPlan(df.queryExecution.executedPlan).collect {
+ case f: FilterExec => f.condition
+ }
+ val evaluated =
postScanConditions.exists(_.exists(_.isInstanceOf[ExecScalarSubquery]))
+ assert(evaluated === expected,
+ s"Expected scalar subquery evaluated after scan to be $expected, " +
+ s"post-scan filter conditions: $postScanConditions")
+ }
+
+ private def collectBatchScan(df: DataFrame): BatchScanExec = {
+ stripAQEPlan(df.queryExecution.executedPlan).collectFirst {
+ case b: BatchScanExec => b
+ }.getOrElse(fail("Expected BatchScanExec in plan"))
+ }
+
+ private def getPushedCatalystPredicates(df: DataFrame): Seq[Expression] = {
+ collectBatchScan(df).scan match {
+ case s:
InMemoryCatalystRuntimeFilterTable#InMemoryCatalystRuntimeFilterBatchScan =>
+ s.pushedPredicates().toSeq
+ case _ => Seq.empty
Review Comment:
**Finding 7.** This swallows a wrong-scan-type case, and the two
`assertPushedCatalystPredicates(df, 0)` assertions (`:191`, `:207`) are exactly
the ones that then pass for the wrong reason — "nothing was pushed" and "we
didn't find the scan we expected" become indistinguishable.
```suggestion
case other => fail(s"Expected InMemoryCatalystRuntimeFilterBatchScan,
got $other")
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala:
##########
@@ -86,6 +87,14 @@ object PartitionPruning extends Rule[LogicalPlan] with
PredicateHelper with Join
} else {
None
}
+ case (resExp, r @ ExtractV2Scan(scan: SupportsRuntimeCatalystFiltering))
=>
Review Comment:
**Finding 3.** This is one of three places that dispatch on
`SupportsRuntimeV2Filtering`; the third isn't updated.
`RowLevelOperationRuntimeGroupFiltering.scala:54,58` matches
`ExtractV2Scan(scan: SupportsRuntimeV2Filtering)` for group-based and
delta-based row-level operations, and `canInjectGroupFilters` /
`injectGroupFilters` are typed on that interface. Since the Javadoc you added
says only one runtime filtering interface should be implemented, a source that
adopts `SupportsRuntimeCatalystFiltering` silently loses runtime group
filtering for MERGE/UPDATE/DELETE — no error, just a rule that stops firing and
whole unmodified groups getting read.
The rule needs nothing but `filterAttributes` and injects a plain
`DynamicPruningExpression(InSubquery(...))`, which the new branch in
`pushRuntimeFilters` already handles, so adding the two cases looks mechanical.
If you'd rather keep it out of scope, please say so in the trait's Javadoc so
an adopter isn't surprised.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]