peter-toth commented on code in PR #57727:
URL: https://github.com/apache/spark/pull/57727#discussion_r3726895511
##########
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()
Review Comment:
**Finding 9.** Every other dispatch resolves `SupportsRuntimeV2Filtering`
first — `runtimeFilterAttrs` right above (`:205` before `:206`),
`pushRuntimeFilters` (`PushDownUtils.scala:190` before `:226`),
`PartitionPruning.getFilterableTableScan` (`:82` before `:90`),
`RowLevelOperationRuntimeGroupFiltering` (`:56` before `:61`). This one
doesn't: it matches only the new interface, so for a scan mixing in both it
reports non-empty fully-pushed attributes while every push decision goes down
the V2 path.
Concretely, with such a scan and `fullyPushedFilterAttributes() = [part]`:
```sql
SELECT * FROM t WHERE part > (SELECT max(val) FROM dim) + 1
```
`scalarSubqueryFilters` routes it (the V2 `filterAttributes` covers `part`),
`fullyPushedRuntimeFilters` drops it from `postScanFilters`, and then whether
it is pushed at all depends on `translateFilterV2` succeeding and, failing
that, on `supportsIterativePushdown()` plus `getPartitionPredicateSchema`
returning a schema. Any of those falling through leaves the predicate with no
evaluator and extra rows in the answer.
The Javadoc does say only one runtime filtering interface should be
implemented, but this is the one place where Spark's own behaviour depends on
that and doesn't check it, and the failure is silent. Making the precedence
uniform is enough:
```suggestion
// Matched after SupportsRuntimeV2Filtering, as `runtimeFilterAttrs`
and
// `pushRuntimeFilters` do: a scan taking the V2 push path must keep
its post-scan filters.
case _: SupportsRuntimeV2Filtering => Array.empty[NamedReference]
case s: SupportsRuntimeCatalystFiltering =>
s.fullyPushedFilterAttributes()
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -218,6 +222,26 @@ 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.
+ // Screen with the same pushability guard as the V2 PartitionPredicate
path
+ // (deterministic, no subquery, no Python UDF). Keeps
non-deterministic filters
+ // from being the sole evaluator when fullyPushedFilterAttributes
drops FilterExec.
+ val catalystFilters = runtimeFilters
+ .flatMap(unwrapRuntimeFilterExpression)
Review Comment:
**Finding 11.** The V2 partition-predicate path flattens nested partition
references before pushing; this branch doesn't, so the two interfaces hand a
source different expression shapes for the same table.
`createRuntimePartitionPredicates` (`:465-467`) runs
`flattenNestedPartitionFilters`, which rewrites `GetStructField(s#1, "tz")`
into `attr("s.tz")` — its own scaladoc uses exactly that example (`:500-509`) —
and `getPartitionPredicateSchema` supports identity transforms on nested fields
via `resolveIdentityPartitionField` / `findNestedField` (`:394-409`). Here the
unwrapped expression goes out as-is.
It's reachable. `filterAttributes()` now documents that references must be
top-level, so a table partitioned by `s.tz` can only declare `s`.
`GetStructField(s, "tz").references` is `{s}`, so
`resExp.references.subsetOf(filterAttrs)` in `PartitionPruning` and
`f.references.subsetOf(runtimeFilterAttrs)` in `DataSourceV2Strategy` both
pass, and the source receives `GetStructField(s#1, "tz") = 3` while its
partition-key row is laid out as a flat `s.tz` field.
A line on `filter()` is the smaller fix: say the pushed expression may
contain nested field accesses over a declared top-level attribute, and the scan
is responsible for matching them against its own partition layout. Flattening
here is also possible — `pushRuntimeFilters` already has `table` and `output`,
so `getPartitionPredicateSchema(table,
output).map(flattenNestedPartitionFilters(catalystFilters, _).keys.toSeq)`
mirrors the V2 path — but that also rewrites attribute references in the flat
case, which is a bigger change than this branch needs.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -267,6 +270,94 @@ class InMemoryRowLevelOperationTable private (
override def abort(messages: Array[WriterCommitMessage]): Unit = {}
}
+
+ /**
+ * Builds a scan for row-level operations. When
+ * `use-catalyst-runtime-filtering` is set, the scan implements
+ * [[SupportsRuntimeCatalystFiltering]] so group filtering goes through the
Catalyst path.
+ */
+ private def newRowLevelScanBuilder(
+ options: CaseInsensitiveStringMap)(
+ onBuild: BatchScanBaseClass => Unit): ScanBuilder = {
+ new InMemoryScanBuilder(schema, options) {
+ override protected def createScan(
+ partitions: Seq[InputPartition],
+ readSchema: StructType,
+ tableSchema: StructType,
+ options: CaseInsensitiveStringMap): BatchScanBaseClass = {
+ if (useCatalystRuntimeFiltering) {
+ InMemoryCatalystRowLevelBatchScan(partitions, readSchema,
tableSchema, options)
+ } else {
+ super.createScan(partitions, readSchema, tableSchema, options)
+ }
+ }
+
+ override def build: Scan = {
+ val scan = super.build().asInstanceOf[BatchScanBaseClass]
+ onBuild(scan)
+ scan
+ }
+ }
+ }
+
+ /**
+ * Row-level batch scan that receives runtime filters as Catalyst
expressions.
+ * Evaluates partition-column predicates against each partition key so group
filtering
+ * actually prunes partitions (needed for `replacedPartitions` assertions).
+ */
+ case class InMemoryCatalystRowLevelBatchScan(
+ var _data: Seq[InputPartition],
+ readSchema: StructType,
+ tableSchema: StructType,
+ options: CaseInsensitiveStringMap)
+ extends BatchScanBaseClass(_data, readSchema, tableSchema)
+ with SupportsRuntimeCatalystFiltering {
+
+ private val _catalystPredicates = ArrayBuffer.empty[Expression]
+
+ override def filterAttributes(): Array[NamedReference] = {
+ val scanFields = readSchema.fields.map(_.name).toSet
+ partitioning.flatMap(_.references())
+ .filter(ref => scanFields.contains(ref.fieldNames.mkString(".")))
+ }
+
+ override def filter(expressions: Array[Expression]): Unit = {
Review Comment:
**Finding 12.** This `filter` / `partitionAttributes` /
`pushedCatalystPredicates` block and `InMemoryCatalystRuntimeFilterBatchScan`'s
(`InMemoryCatalystRuntimeFilterTable.scala:105-141`) are the same ~40 lines
twice: the reference remap, the all-refs-are-partition-columns test,
`BindReferences.bindReference` + `Predicate.createInterpreted`, the
`data.filter` loop, the swallow-and-keep `catch`, and the accessor. Only
`filterAttributes()` genuinely differs — the other fixture also honours a
`filter-attributes` property.
Both are inner classes of tables extending `InMemoryBaseTable`, so a trait
next to `BatchScanBaseClass` carrying `filter`, `partitionAttributes` and
`pushedCatalystPredicates` would leave each scan with just its
`filterAttributes()` override — the same kind of sharing the new `createScan`
hook does for scan construction. Worth doing at two copies rather than three,
since this is the bind-and-interpret reference implementation an adopter will
read.
Also, the `catch { case _: Exception => true }` here lost the comment its
twin has (`InMemoryCatalystRuntimeFilterTable.scala:125`: "Keep the partition
on eval failure, matching PartitionPredicateImpl"), which is what tells a
reader it's deliberate.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -218,6 +222,26 @@ 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.
+ // Screen with the same pushability guard as the V2 PartitionPredicate
path
+ // (deterministic, no subquery, no Python UDF). Keeps
non-deterministic filters
+ // from being the sole evaluator when fullyPushedFilterAttributes
drops FilterExec.
Review Comment:
**Finding 13.** Two claims around this guard no longer hold. The guard
itself is fine — defence-in-depth is the right call — it's the wording I'd fix.
*"Keeps non-deterministic filters from being the sole evaluator"* — after
the rebase, `scalarSubqueryFilters` requires `f.deterministic`
(`DataSourceV2Strategy.scala:183`), and `dynamicFilters` can't carry
non-determinism either (`CleanupDynamicPruningFilters` rewrites those to
`TrueLiteral`), so no non-deterministic filter reaches this line. Your commit
message says exactly that; the comment doesn't. What's left for the screen to
reject is a `PythonUDF` — also unreachable, since `ExtractPythonUDFs` runs in
`SparkOptimizer`'s "Extract Python UDFs" batch (`SparkOptimizer.scala:92`) and
lifts the UDF into its own eval node below the `Filter`, so a post-scan filter
can't hold one — and a residual `SubqueryExpression`.
*"so both sides apply the same test"* (`DataSourceV2Strategy.scala:193`) —
not literally. `DataSourceV2Strategy` tests the logical filter with
`includeSubquery = true`; this line tests the *unwrapped* expression with
`includeSubquery = false`. Screening before the unwrap with the same flag makes
them identical, which is what the comment promises and what stops them drifting
again:
```scala
val catalystFilters = runtimeFilters
.filter(isPushablePartitionFilter(_, includeSubquery = true))
.flatMap(unwrapRuntimeFilterExpression)
.filterNot(_ == Literal.TrueLiteral)
```
No behaviour change today — `DynamicPruningExpression` is deterministic and
`SubqueryExpression.hasSubquery` doesn't match `InSubqueryExec` — just one
predicate applied to one form on both sides.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala:
##########
@@ -0,0 +1,337 @@
+/*
+ * 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')")
+ // Matching and nonmatching partitions: the scan must prune nonmatching
ones itself
+ // because Spark drops the post-scan FilterExec for fully pushed
attributes.
+ 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)))
+ assertScalarSubqueryEvaluatedAfterScan(df, expected = false)
+ }
+ }
+
+ test("non-deterministic predicate on fully pushed attributes -> evaluated
after the scan") {
+ val tbl = s"$catalogName.tbl_nondeterministic"
+ val dim = s"$catalogName.dim_nondeterministic"
+ 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, $i)")
+ }
+ sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+ sql(s"INSERT INTO $dim VALUES (3)")
+
+ // A non-deterministic filter is never pushed, so it must keep its
post-scan FilterExec even
+ // though it only references a fully pushed attribute. Dropping it there
would leave nothing
+ // to evaluate it and the scan would return the nonmatching partitions
too.
+ val df = sql(
+ s"SELECT * FROM $tbl WHERE part = (SELECT max(val) FROM $dim) OR
rand() < 0.5")
+ // The row in the matching partition always qualifies, the others
qualify at random.
+ assert(df.collect().contains(Row(3, 3)))
+
+ assertScalarSubqueryEvaluatedAfterScan(df, expected = true)
+ assertScalarSubqueryRuntimeFilters(df, expectedCount = 0)
+ assertPushedCatalystPredicates(df, 0)
+ }
+ }
+
+ 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
Review Comment:
**Finding 15.** This filter does translate, so the test isn't covering the
case it names — and nothing else in the suite covers it either.
The V2 branch routes a non-DPP runtime filter through
`DataSourceV2Strategy.translateScalarSubqueryFilterV2`
(`PushDownUtils.scala:195`), which literalizes `ExecScalarSubquery` **first**
and only then calls `translateFilterV2`
(`DataSourceV2Strategy.scala:997-1003`). So the V2 interfaces see `part > (2 +
1)`, not `part > (scalar-subquery) + 1`. And that translates:
- `Literal.contextIndependentFoldable` is `true` (`literals.scala:503`) and
`BinaryArithmetic.contextIndependentFoldable` is `left && right`
(`arithmetic.scala:210-211`), so `Add(Literal(2), Literal(1))` is
context-independent foldable;
- `V2ExpressionBuilder.generateExpression` folds exactly that case to a
`LiteralValue` (`V2ExpressionBuilder.scala:97-100`), and
`spark.sql.optimizer.datasourceV2ExprFolding` defaults to `true` (asserted in
`SQLConfSuite.scala:248`);
- the outer `GreaterThan` is a `BinaryComparison`, which `canTranslate`
accepts unconditionally (`V2ExpressionBuilder.scala:78`).
So `translateFilterV2` returns `V2Predicate(">", [FieldReference("part"),
LiteralValue(3)])`. Even with folding turned off it still translates —
`canTranslate` accepts `Add` when `evalMode == ANSI`, which is the default
(`SQLConf.scala:5752`).
Three things say otherwise: this comment, the test name, and the "Why are
the changes needed?" paragraph, which leads with `part > (subquery) + 1` as a
filter that is "silently dropped and never reach[es] the data source". More
importantly, the capability this PR exists for — a runtime filter with *no* V2
translation reaching the source — is not tested.
The description's second example does hold: `V2ExpressionBuilder` has no
`RLike` case (only `STARTS_WITH` / `ENDS_WITH` / `CONTAINS`, `:138-140`), so
```sql
SELECT * FROM t WHERE CAST(part AS STRING) RLIKE CAST((SELECT max(val) FROM
dim) AS STRING)
```
references only `part`, carries a `SCALAR_SUBQUERY`, and has no translation
at all. With `dim = (3)` and `part = 0..4` it should push `RLIKE(cast(part as
string), 3)` and answer `Row(3, 3)`; the pattern is still a subquery at
optimization time, so nothing rewrites the `RLIKE` into a `Contains` beforehand.
Worth keeping the current test as well — it's a good check that the
surrounding arithmetic survives and the subquery is literalized — just renamed
to say that instead of "untranslatable".
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/SupportsRuntimeCatalystFiltering.scala:
##########
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ * Each reference must be a top-level attribute present in
[[Scan.readSchema]]. Nested
+ * references and attributes pruned out of the read schema fail to resolve
when Spark builds
+ * the scan relation.
+ */
+ 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]].
+ *
+ * Each reference must be a top-level attribute present in
[[Scan.readSchema]]. Nested
+ * references and attributes pruned out of the read schema fail to resolve
when Spark builds
+ * the scan relation.
+ */
+ 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.
+ *
+ * If the scan also implements
+ * [[org.apache.spark.sql.connector.read.SupportsReportPartitioning]], it
must preserve
+ * the originally reported partitioning during runtime filtering. While
applying runtime
+ * predicates, the scan may detect that some
+ * [[org.apache.spark.sql.connector.read.InputPartition]]s have no matching
data, in which
+ * case it can either replace the initially planned
+ * [[org.apache.spark.sql.connector.read.InputPartition]]s that have no
matching data with
+ * empty [[org.apache.spark.sql.connector.read.InputPartition]]s, or report
only a subset of
+ * the original partition values (omitting those with no data) via
+ * [[org.apache.spark.sql.connector.read.Batch#planInputPartitions]]. The
scan must not
+ * report new partition values that were not present in the original
partitioning.
+ *
+ * Note that Spark will call [[Scan.toBatch]] again after filtering the scan
at runtime.
+ */
+ def filter(expressions: Array[Expression]): Unit
Review Comment:
**Finding 10.** Spark calls this more than once on the same scan instance,
and nothing here says so.
A group-based UPDATE is rewritten into a union of two branches sharing one
`Scan`; each `BatchScanExec` has its own `filteredPartitions`, so each one runs
`replanWithRuntimeFilters` -> `pushRuntimeFilters` -> `filter()`. Your own test
pins that down: `RowLevelOperationCatalystRuntimeFilterSuiteBase.scala:1540`,
`pushed.size === batchScans.size`, "expected each of the N scan node(s) to push
the filter once", with the scaladoc above it explaining the union.
`SupportsRuntimeV2Filtering` documents its multi-call case explicitly (the
"Iterative filtering" paragraph, gated on `supportsIterativePushdown()`). Here
there's no gate and no note, and `pushRuntimeFilters`' own scaladoc says the
opposite — *"Note: Do not call multiple times for the same `scan` instance"*
(`PushDownUtils.scala:170`, repeated at `:257`). An implementor who reads
`filter()` as "here is the complete predicate set" and replaces its state per
call un-prunes on the second call. Today both calls happen to carry equivalent
predicates (same subquery, different expr IDs), so it works by luck — which is
the reason to write it down rather than leave it to be discovered.
```scala
* 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.
*
* Spark may call this method more than once for the same scan instance: a
plan can hold several
* scan nodes sharing one scan (e.g. the two branches of a group-based
UPDATE), and each pushes
* its own copy of the runtime filters. Implementations must treat
successive calls as additive,
* ANDing the new expressions with those already pushed rather than
replacing them.
```
The stale note in `pushRuntimeFilters` / `replanWithRuntimeFilters` is worth
correcting in the same pass, since it's the note that would stop a future
reader from relying on this.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsRuntimeFiltering.java:
##########
@@ -26,6 +26,7 @@
/**
* A mix-in interface for {@link Scan}. Data sources can implement this
interface if they can
* filter initially planned {@link InputPartition}s using predicates Spark
infers at runtime.
+ * Only one runtime filtering interface should be implemented by a data source.
Review Comment:
**Finding 14.** On this interface the sentence can't be followed:
`SupportsRuntimeFiltering extends SupportsRuntimeV2Filtering` (`:36`), so
implementing it always implements two runtime filtering interfaces. It isn't
actionable either — the third interface it's warning about,
`SupportsRuntimeCatalystFiltering`, is internal and doesn't appear in the
published Javadoc, so a reader takes "only one" to mean the two names on this
page, which is the one pair the `extends` makes inseparable.
`SupportsRuntimeV2Filtering` already carries the version that works
(*"`SupportsRuntimeV2Filtering` is preferred over `SupportsRuntimeFiltering`"*
plus the same "only one" line, where it at least reads as a choice between that
interface and something else). I'd just drop the line here. If you want
something on this page, make it about Spark's behaviour rather than a count —
e.g. "Spark takes exactly one runtime filtering path per scan" — so it doesn't
read as advice to not implement the supertype.
--
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]