Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* Note that Spark will push runtime filters only if they are beneficial.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
* filter initially planned {@link InputPartition}s using predicates Spark infers at runtime.
* This interface is very similar to {@link SupportsRuntimeFiltering} except it uses
* data source V2 {@link Predicate} instead of data source V1 {@link Filter}.
* {@link SupportsRuntimeV2Filtering} is preferred over {@link SupportsRuntimeFiltering}
* and only one of them should be implemented by the data sources.
* {@link SupportsRuntimeV2Filtering} is preferred over {@link SupportsRuntimeFiltering}.
* Only one runtime filtering interface should be implemented by a data source.
* <p>
* <b>Iterative filtering:</b> When {@link #supportsIterativePushdown()} returns true,
* {@link #filter(Predicate[])} may be called <i>multiple times</i> on the same
Expand Down Expand Up @@ -82,6 +82,10 @@ public interface SupportsRuntimeV2Filtering extends Scan {
* Returns the predicates that are pushed to the data source via
* {@link #filter(Predicate[])}.
* <p>
* These are not fully pushed predicates: Spark may still evaluate them after the scan.
* They are predicates that fully or partially help the data source prune initially planned
* {@link InputPartition}s.
* <p>
* When iterative filtering is supported and {@link #filter(Predicate[])} was called
* multiple times, this method must return predicates from <i>all</i> calls.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import org.apache.spark.sql.connector.expressions.{FieldReference, NamedReferenc
import org.apache.spark.sql.connector.read.{Scan, Statistics => V2Statistics, SupportsReportStatistics, SupportsRuntimeV2Filtering}
import org.apache.spark.sql.connector.read.colstats.{ColumnStatistics, Histogram => V2Histogram, HistogramBin => V2HistogramBin}
import org.apache.spark.sql.connector.read.streaming.{Offset, SparkDataStream}
import org.apache.spark.sql.internal.connector.V2StatisticsUtils
import org.apache.spark.sql.internal.connector.{SupportsRuntimeCatalystFiltering, V2StatisticsUtils}
import org.apache.spark.sql.types.{DataType, StructType}
import org.apache.spark.sql.util.CaseInsensitiveStringMap
import org.apache.spark.util.ArrayImplicits._
Expand Down Expand Up @@ -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](

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 6. resolveRefsV2ExpressionUtils.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.

filterAttrs.toImmutableArraySeq, this))
}

override def name: String = relation.name
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  /**
   * 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correction here in light of #57760: that PR adds f.deterministic to the scalarSubqueryFilters routing in DataSourceV2Strategy, so a non-deterministic filter never reaches runtimeFilters and can never be a fully-pushed candidate. My "including rand() (finding 1)" above is wrong once you rebase — the set of expressions this promise has to cover is still unbounded in shape (> with arithmetic, RLIKE, a cast chain, ...) but bounded to deterministic ones, which is what the suggested wording already says. Nothing else in this finding changes.


/**
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


/**
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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.catalog

import java.util

import scala.collection.mutable.ArrayBuffer

import InMemoryCatalystRuntimeFilterTable._

import org.apache.spark.sql.catalyst.expressions.Expression
import org.apache.spark.sql.connector.expressions.{NamedReference, Transform}
import org.apache.spark.sql.connector.read.{InputPartition, Scan, ScanBuilder}
import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap
import org.apache.spark.util.ArrayImplicits._

/**
* In-memory table whose batch scan implements
* [[SupportsRuntimeCatalystFiltering]], so runtime filters arrive as Catalyst
* [[Expression]]s rather than connector predicates.
*
* Table properties:
* - `filter-attributes` (default: all partition cols): comma-separated list of
* column names to expose from `filterAttributes`.
* - `fully-pushed-filter-attributes` (default: none): comma-separated list of
* column names to expose from `fullyPushedFilterAttributes`.
*/
class InMemoryCatalystRuntimeFilterTable(
name: String,
columns: Array[Column],
partitioning: Array[Transform],
properties: util.Map[String, String])
extends InMemoryTableWithV2Filter(name, columns, partitioning, properties) {

override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder = {
new InMemoryCatalystRuntimeFilterScanBuilder(schema, options)
}

class InMemoryCatalystRuntimeFilterScanBuilder(
tableSchema: StructType,
options: CaseInsensitiveStringMap)
extends InMemoryScanBuilder(tableSchema, options) {
override def build: Scan = InMemoryCatalystRuntimeFilterBatchScan(
data.map(_.asInstanceOf[InputPartition]).toImmutableArraySeq,
schema, tableSchema, options)
}

/**
* Scan that receives runtime filters as Catalyst expressions.
* Records what was pushed; pruning is left to the
* [[org.apache.spark.sql.execution.FilterExec]] above the scan, so the recorded
* expressions are the only observable effect.
*/
case class InMemoryCatalystRuntimeFilterBatchScan(
var _data: Seq[InputPartition],
readSchema: StructType,
tableSchema: StructType,
options: CaseInsensitiveStringMap)
extends BatchScanBaseClass(_data, readSchema, tableSchema)
with SupportsRuntimeCatalystFiltering {

private val _catalystPredicates = ArrayBuffer.empty[Expression]

private val restrictedFilterAttrs: Option[Set[String]] =
Option(InMemoryCatalystRuntimeFilterTable.this.properties.get(FilterAttributesKey))
.map(_.split(",").map(_.trim).toSet)

override def filterAttributes(): Array[NamedReference] = {
val scanFields = readSchema.fields.map(_.name).toSet
partitioning.flatMap(_.references()).filter { ref =>
val name = ref.fieldNames.mkString(".")
scanFields.contains(name) &&
restrictedFilterAttrs.forall(_.contains(name))
}
}

override def fullyPushedFilterAttributes(): Array[NamedReference] = {
val fullyPushedFilterAttrs = Option(
InMemoryCatalystRuntimeFilterTable.this.properties.get(FullyPushedFilterAttributesKey))
.map(_.split(",").map(_.trim).toSet)
.getOrElse(Set.empty)
filterAttributes().filter { ref =>
fullyPushedFilterAttrs.contains(ref.fieldNames.mkString("."))
}
}

override def filter(expressions: Array[Expression]): Unit =
_catalystPredicates ++= expressions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A scan that declares a filter fully pushed must ensure its returned partitions satisfy that predicate. This implementation only records the expression, while the fully-pushed test uses rows that all happen to match, so checkAnswer cannot catch an incorrect post-scan-filter removal. Please make this fixture filter its partitions (or use a dedicated fully-evaluating fixture) and test with both matching and nonmatching partitions.


override def pushedPredicates(): Array[Expression] =
_catalystPredicates.toArray
}
}

object InMemoryCatalystRuntimeFilterTable {
/**
* Table property: comma-separated column names to expose from
* filterAttributes. Default: all partition columns.
*/
private[catalog] val FilterAttributesKey = "filter-attributes"

/**
* Table property: comma-separated column names to expose from
* fullyPushedFilterAttributes. Default: none.
*/
private[catalog] val FullyPushedFilterAttributesKey = "fully-pushed-filter-attributes"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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.catalog

import java.util

import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException
import org.apache.spark.sql.connector.expressions.Transform

class InMemoryTableCatalystRuntimeFilterCatalog extends InMemoryTableCatalog {
import CatalogV2Implicits._

override def createTable(
ident: Identifier,
columns: Array[Column],
partitions: Array[Transform],
properties: util.Map[String, String]): Table = {
if (tables.containsKey(ident)) {
throw new TableAlreadyExistsException(ident.asMultipartIdentifier)
}

InMemoryTableCatalog.maybeSimulateFailedTableCreation(properties)

val tableName = s"$name.${ident.quoted}"
val table = new InMemoryCatalystRuntimeFilterTable(
tableName, columns, partitions, properties)
tables.put(ident, table)
namespaces.putIfAbsent(ident.namespace.toList, Map())
table
}

override def createTable(ident: Identifier, tableInfo: TableInfo): Table = {
createTable(ident, tableInfo.columns(), tableInfo.partitions(), tableInfo.properties)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ class DataSourceV2Strategy(session: SparkSession) extends Strategy with Predicat
// Extract scalar subquery filters on runtime-filterable columns for runtime pushdown.
// These filters stay in postScanFilters for correctness (FilterExec above scan),
// but are also routed into runtimeFilters so BatchScanExec can use them for
// partition pruning via SupportsRuntimeV2Filtering.filter().
// partition pruning via SupportsRuntimeV2Filtering.filter(). The exception is filters

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plural subject needs agreement here: The exceptions are filters.

// that only reference attributes the scan fully evaluates, which are dropped from
// postScanFilters below.
val scalarSubqueryFilters = if (relation.runtimeFilterAttrs.nonEmpty) {
postScanFilters.filter { f =>
f.containsPattern(SCALAR_SUBQUERY) &&
Expand All @@ -181,12 +183,16 @@ class DataSourceV2Strategy(session: SparkSession) extends Strategy with Predicat
} else {
Seq.empty
}
val fullyPushedRuntimeFilters = scalarSubqueryFilters.filter { f =>
f.references.subsetOf(relation.fullyPushedRuntimeFilterAttrs)
}
val runtimeFilters = dynamicFilters ++ scalarSubqueryFilters

val batchExec = BatchScanExec(relation.output, relation.scan, runtimeFilters,
relation.ordering, relation.relation.table, relation.keyGroupedPartitioning)
DataSourceV2Strategy.withProjectAndFilter(
project, postScanFilters, batchExec, !batchExec.supportsColumnar) :: Nil
project, postScanFilters.diff(fullyPushedRuntimeFilters),
batchExec, !batchExec.supportsColumnar) :: Nil

case PhysicalOperation(p, f, r: StreamingDataSourceV2ScanRelation)
if r.startOffset.isDefined && r.endOffset.isDefined =>
Expand Down
Loading