peter-toth commented on code in PR #59715:
URL: https://github.com/apache/spark/pull/59715#discussion_r4377263148
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceRDD.scala:
##########
@@ -187,10 +191,16 @@ class DataSourceRDD(
castPartition(split).inputPartition.iterator.flatMap { inputPartition =>
val iter = if (columnarReads) {
- val batchReader =
partitionReaderFactory.createColumnarReader(inputPartition)
+ val batchReader = storageFilterRequest match {
+ case Some(request) =>
partitionReaderFactory.createColumnarReader(inputPartition, request)
+ case None =>
partitionReaderFactory.createColumnarReader(inputPartition)
+ }
new PartitionIterator[ColumnarBatch](batchReader, _.numRows, taskState)
} else {
- val rowReader = partitionReaderFactory.createReader(inputPartition)
+ val rowReader = storageFilterRequest match {
+ case Some(request) =>
partitionReaderFactory.createReader(inputPartition, request)
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788)
and
[`cb01a6f`](https://github.com/apache/spark/pull/59715/commits/cb01a6f1c54dbd6e29153c6c9d6f1753a731a999).
`DataSourceRDD` checks every row or batch of a reader asked for the column
against the scan's output width. A reader that leaves the column out fails the
task with `DATA_SOURCE_STORAGE_FILTER_CHECKED_COLUMN_MISSING`, which names the
factory class. `StorageFilterRequest.appendsCheckedColumn` states it. A test
covers both read modes, with a reader that never appends the column and one
that stops after its first row or batch.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/PartitionReaderFactory.java:
##########
@@ -56,10 +56,40 @@ default PartitionReader<ColumnarBatch>
createColumnarReader(InputPartition parti
"DATA_SOURCE_COLUMNAR_READER_NOT_SUPPORTED", Map.of("class",
getClass().getName()));
}
+ /**
+ * Returns a row-based partition reader that may apply the storage filter of
{@code request} while
+ * it reads the given {@link InputPartition}. Spark calls this instead of
+ * {@link #createReader(InputPartition)} for a {@link
SupportsStorageFilters} scan it offered a
+ * filter, so the factory of such a scan must implement it for row-based
reads.
+ *
+ * @since 5.0.0
+ */
+ default PartitionReader<InternalRow> createReader(
Review Comment:
I'd leave this one out. The default of `createColumnarReader(partition)`
fails the same way. A factory that overrides the overload but forwards to a
delegate without it would also pass a check through `getDeclaringClass`.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/expressions/filter/StorageFilter.java:
##########
@@ -0,0 +1,111 @@
+/*
+ * 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.expressions.filter;
+
+import org.apache.spark.annotation.Evolving;
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.read.Scan;
+import org.apache.spark.sql.connector.read.StorageFilterRequest;
+import org.apache.spark.sql.connector.read.SupportsStorageFilters;
+import org.apache.spark.sql.vectorized.ColumnarBatch;
+
+/**
+ * A row-level predicate a scan may evaluate inside its readers, to skip
reading the other columns
+ * of the rows it rejects. The reader reads the columns of {@link
#references()} first, evaluates
+ * the filter, and reads the remaining columns only for the rows it keeps.
+ * <p>
+ * The filter is a predicate on the rows the scan returns. Spark offers none
to a scan that took a
+ * pushed operation that decides which rows it returns, such as a limit, an
offset, a top-N, a
+ * sample or an aggregate.
+ * <p>
+ * Spark implements the predicate, and the reader only supplies the values.
For example, a scan
+ * does not have to reproduce the Spark sketch and hash of a runtime bloom
filter. The values must
+ * be the ones the scan returns for those columns, in the types of {@link
Scan#readSchema()}, nulls
+ * included.
+ * <p>
+ * Applying it is optional, see {@link SupportsStorageFilters}.
+ * <p>
+ * A row's result depends only on its own values. So with one reference, a
reader may evaluate each
+ * distinct value once, such as each entry of a dictionary, and reuse the
result for every row
+ * holding that value. A dictionary entry is evaluated as the value the scan
returns for it, decoded
+ * and converted.
+ * <p>
+ * Spark hands the filter to each task in a {@link StorageFilterRequest}. The
readers of a task
+ * share one instance, which is not thread-safe.
+ *
+ * @since 5.0.0
+ */
+@Evolving
+public abstract class StorageFilter extends Predicate {
Review Comment:
Thanks, changed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
`StorageFilter` is now a standalone abstract class that implements
`Serializable`. `NAME`, the constructor, and `StorageFilterImpl`'s `equals` and
`hashCode` are gone.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/expressions/filter/StorageFilterNotAppliedException.java:
##########
@@ -0,0 +1,52 @@
+/*
+ * 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.expressions.filter;
+
+import java.util.Map;
+
+import org.apache.spark.SparkThrowable;
+import org.apache.spark.SparkThrowableHelper;
+import org.apache.spark.annotation.Evolving;
+
+/**
+ * Thrown by a {@link StorageFilter} that cannot be applied to a row, for
instance because
+ * evaluating it raised an error. The reader catches it and reads the rest of
the unit it was
+ * filtering, such as a file or a row group, without the filter. The cause is
the error the filter
+ * met.
+ * <p>
+ * It is checked, so a reader handles it where it can fall back to reading
without the filter. A
Review Comment:
Thanks, added in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788),
in the exception's Javadoc and in `docs/sql-v2-data-sources.md`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -98,13 +116,22 @@ case class BatchScanExec(
override lazy val readerFactory: PartitionReaderFactory =
batch.createReaderFactory()
+ // What every task's readers get, the storage filters ANDed into one. A
runtime bloom arrives as
+ // a ScalarSubquery over the build side, which SparkPlan has computed by the
time this is forced.
+ // The readers get its literal, so the task does not carry the subquery's
plan.
+ @transient private lazy val storageFilterRequest:
Option[StorageFilterRequest] =
+ storageFilters.map(_.transform { case s: ExecScalarSubquery => s.toLiteral
})
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
I measured it first. In a two-key join, a storage filter with one offered 1 MB
bloom shipped 2.1 MB, and with both blooms 4.2 MB instead of 2.1 MB.
`StorageFilterPlanning.withSubqueryResults` now folds each bloom argument to
its own binary literal. The file-source side had the same problem in
`FileSourceScanExec.preparedStorageFilters`, so it uses the same helper. A new
two-key join test checks that the filter ships each bloom once.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -200,10 +223,20 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
// rewritten it to TrueLiteral by the time we get here.
val runtimeFilters = dynamicFilters ++ scalarSubqueryFilters
- val batchExec = BatchScanExec(relation.output, relation.scan,
runtimeFilters,
- relation.ordering, relation.relation.table,
relation.keyGroupedPartitioning)
- DataSourceV2Strategy.withProjectAndFilter(
- project, postScanFilters.diff(fullyPushedRuntimeFilters),
+ // Whether the scan gets the checked column does not depend on whether
it is columnar. Asking
+ // that can plan the scan's partitions, which the node built afterwards
would plan again. A
+ // row-based scan gets a Project for the UnsafeRow conversion anyway, so
dropping the column
+ // there copies nothing more.
+ val filtersAbove = postScanFilters.diff(fullyPushedRuntimeFilters)
+ val storageFilters = storageFiltersFor(relation, filtersAbove)
+ val checked = StorageFilterPlanning.checkedColumn(
+ session.sessionState.conf, storageFilters, relation.output)
+ val batchExec = BatchScanExec(relation.output ++ checked, relation.scan,
runtimeFilters,
Review Comment:
Thanks. The mechanism is right. With the test source our counts differ a
bit: 6 `toBatch` and 3 `planInputPartitions` calls with AQE on, and 3 and 2
with the conf off. Both consequences already hold on master, though:
- `planInputPartitions` is called twice with the conf off too;
- SPARK-56467 puts the same subquery into `runtimeFilters`, so
`ReuseAdaptiveSubquery` copies a runtime-filtered scan under a
`GroupPartitionsExec` the same way.
So I'd leave carrying the planned `Batch` over to the copies for a separate
change, since it touches every V2 scan, runtime filtering included. I listed it
under the possible follow-ups. In
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788)
and
[`cb01a6f`](https://github.com/apache/spark/pull/59715/commits/cb01a6f1c54dbd6e29153c6c9d6f1753a731a999)
`Batch.planInputPartitions` says Spark may call it more than once, and that a
source whose planning is costly can cache the partitions in its `Scan` and plan
them again once runtime filtering narrows the scan. The `GroupPartitionsExec`
doc names `ReuseAdaptiveSubquery` too.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -200,10 +223,20 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
// rewritten it to TrueLiteral by the time we get here.
val runtimeFilters = dynamicFilters ++ scalarSubqueryFilters
- val batchExec = BatchScanExec(relation.output, relation.scan,
runtimeFilters,
- relation.ordering, relation.relation.table,
relation.keyGroupedPartitioning)
- DataSourceV2Strategy.withProjectAndFilter(
- project, postScanFilters.diff(fullyPushedRuntimeFilters),
+ // Whether the scan gets the checked column does not depend on whether
it is columnar. Asking
+ // that can plan the scan's partitions, which the node built afterwards
would plan again. A
+ // row-based scan gets a Project for the UnsafeRow conversion anyway, so
dropping the column
+ // there copies nothing more.
+ val filtersAbove = postScanFilters.diff(fullyPushedRuntimeFilters)
+ val storageFilters = storageFiltersFor(relation, filtersAbove)
+ val checked = StorageFilterPlanning.checkedColumn(
Review Comment:
I'd leave this one. The column saves one `xxhash64` and one bloom probe per
row the reader returned, and those are the rows the bloom kept. The gain also
needs a connector that overrides `columnarSupportMode`. Iceberg does not, in
any of its Spark versions, so its scans are `PARTITION_DEFINED`. One gate
shared with the file-source side seems simpler to me.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/StorageFilterImpl.scala:
##########
@@ -0,0 +1,132 @@
+/*
+ * 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 scala.util.control.NonFatal
+
+import org.apache.spark.{SparkEnv, SparkException, TaskContext}
+import org.apache.spark.executor.Executor
+import org.apache.spark.internal.{Logging, LogKeys}
+import org.apache.spark.internal.config.KILL_ON_FATAL_ERROR_DEPTH
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BindReferences,
Expression => CatalystExpression, Predicate => CatalystPredicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference,
NamedReference}
+import org.apache.spark.sql.connector.expressions.filter.{StorageFilter,
StorageFilterNotAppliedException}
+
+/**
+ * A [[StorageFilter]] wrapping a Catalyst expression, so a scan can apply a
predicate it does not
+ * have to understand. It mirrors [[PartitionPredicateImpl]], which evaluates
a partition key
+ * rather than a data row.
+ *
+ * `attrs` is the layout of the row [[eval]] expects, in that order, and the
expression is bound to
+ * it. The binding is built on first use, so an instance created on the driver
can be evaluated on
+ * an executor.
+ */
+class StorageFilterImpl private (
+ private val catalystExpr: CatalystExpression,
+ private val attrs: Seq[Attribute])
+ extends StorageFilter with Logging {
+
+ // The width of the row `eval` takes.
+ private val numReferences = attrs.length
+
+ // Whether this instance has logged a failure, so a task logs only its first.
+ @transient private var loggedFailure = false
+
+ @transient private lazy val boundPredicate: InternalRow => Boolean = {
+ val bound = BindReferences.bindReference(catalystExpr, attrs)
+ CatalystPredicate.create(bound).eval
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
`StorageFilterImpl` drops its expression once it has built the predicate, as
`ParquetStorageFilter` does. With `equals` and `hashCode` gone (item 4),
nothing else reads it. So an evaluated instance cannot be copied, which the
`StorageFilter` doc states since
[`cb01a6f`](https://github.com/apache/spark/pull/59715/commits/cb01a6f1c54dbd6e29153c6c9d6f1753a731a999),
and a unit test pins. The 8 MiB is the cap, though. A bloom built without a
row count is 1 MiB.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -200,10 +223,20 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
// rewritten it to TrueLiteral by the time we get here.
val runtimeFilters = dynamicFilters ++ scalarSubqueryFilters
- val batchExec = BatchScanExec(relation.output, relation.scan,
runtimeFilters,
- relation.ordering, relation.relation.table,
relation.keyGroupedPartitioning)
- DataSourceV2Strategy.withProjectAndFilter(
- project, postScanFilters.diff(fullyPushedRuntimeFilters),
+ // Whether the scan gets the checked column does not depend on whether
it is columnar. Asking
+ // that can plan the scan's partitions, which the node built afterwards
would plan again. A
+ // row-based scan gets a Project for the UnsafeRow conversion anyway, so
dropping the column
+ // there copies nothing more.
+ val filtersAbove = postScanFilters.diff(fullyPushedRuntimeFilters)
+ val storageFilters = storageFiltersFor(relation, filtersAbove)
+ val checked = StorageFilterPlanning.checkedColumn(
+ session.sessionState.conf, storageFilters, relation.output)
+ val batchExec = BatchScanExec(relation.output ++ checked, relation.scan,
runtimeFilters,
+ relation.ordering, relation.relation.table,
relation.keyGroupedPartitioning,
+ storageFilters, checked)
+ // With the checked column the scan's output differs from `project`, so
a Project drops it.
+ DataSourceV2Strategy.withProjectAndFilter(project,
+ StorageFilterPlanning.skipCheckedRows(filtersAbove, storageFilters,
checked),
Review Comment:
Thanks. I measured the non-AQE case with `reuseSubquery=false`. Exchange
reuse still reuses the bloom's partial-aggregate shuffle, so only the final
aggregation runs twice (3 jobs instead of 2). The file-source side and master's
runtime-filter routing do the same. The description now says so, next to the
note on the task binary.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -127,6 +127,29 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
tableSpec.withNewLocation(newLoc)
}
+ /**
+ * The conjuncts of `filters` offered to the scan as storage filters, see
+ * `SupportsStorageFilters` and `StorageFilterPlanning.offeredConjuncts`.
Only runtime blooms for
+ * now, as on the file-source side.
+ */
+ private def storageFiltersFor(
+ relation: DataSourceV2ScanRelation,
+ filters: Seq[Expression]): Seq[Expression] = {
+ if (!session.sessionState.conf.v2StorageFilterPushdownEnabled) return Nil
+ if (relation.storageFilterAttrs.isEmpty) return Nil
+ // A scan that took a pushed limit, offset, top-N or sample, or an
aggregate or join, would have
+ // its readers filter ahead of it. `V2ScanRelationPushDown` leaves every
such scan, and any scan
+ // a later pushdown builds, not mergeable.
+ if (!relation.mergeableScan) return Nil
+ // Over the whole table, as on the file-source side, since
`SupportsStorageFilters` reserves
+ // the name.
+ if
(StorageFilterPlanning.clashesWithCheckedColumn(relation.relation.output.map(_.name)))
{
+ return Nil
+ }
+ StorageFilterPlanning.offeredConjuncts(filters,
relation.storageFilterAttrs,
+ AttributeSet(relation.output), _.isInstanceOf[BloomFilterMightContain])
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788)
and
[`cb01a6f`](https://github.com/apache/spark/pull/59715/commits/cb01a6f1c54dbd6e29153c6c9d6f1753a731a999).
The rule leaves out the relation's metadata columns, so it counts the data
columns, as the file-source side does. They are matched by name, since a
variant-extraction scan's output has fresh exprIds. A test reads `k` with a
metadata column, and then with `v` as well.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/StorageFilterTestSource.scala:
##########
@@ -0,0 +1,303 @@
+/*
+ * 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 java.util
+import java.util.OptionalLong
+import java.util.concurrent.atomic.{AtomicInteger, AtomicLong}
+
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.GenericInternalRow
+import org.apache.spark.sql.connector.catalog.{SupportsRead, Table,
TableCapability, TableProvider}
+import org.apache.spark.sql.connector.expressions.{FieldReference,
NamedReference, SortOrder, Transform}
+import org.apache.spark.sql.connector.expressions.filter.{StorageFilter,
StorageFilterNotAppliedException}
+import org.apache.spark.sql.connector.read._
+import org.apache.spark.sql.execution.vectorized.OnHeapColumnVector
+import org.apache.spark.sql.types.{BooleanType, LongType, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
+
+/**
+ * A read-only v2 source over a fixed table of `(k, v)` longs that implements
+ * [[SupportsStorageFilters]]. Its reader gets the filter in the task and
drops the rows the filter
+ * rejects. Its options:
+ * - `columnar`: reads in batches, through `StorageFilter.select`, rather
than row by row;
+ * - `giveUp`: reads without the filter, as a reader does for a unit it
cannot use the filter on.
+ * Here the unit is the one partition;
+ * - `markAllChecked`: reads without the filter but marks every row checked,
which breaks the
+ * contract on purpose;
+ * - `declared`: the comma-separated storage-filter columns, `k` by default,
a dot separating a
+ * nested field;
+ * - `valueName`: the name of the value column, `v` by default;
+ * - `push`: the comma-separated pushed operations it takes whole without
applying them, of
+ * `limit`, `offset`, `topN` and `sample`, for planning tests only.
+ *
+ * A filter that throws `StorageFilterNotAppliedException` is given up for the
rest of the
+ * partition too. The counters below record what the readers got, evaluated
and returned.
+ */
+class StorageFilterTestSource extends TableProvider {
+
+ override def inferSchema(options: CaseInsensitiveStringMap): StructType =
+ new StructType().add("k", LongType).add(options.getOrDefault("valueName",
"v"), LongType)
+
+ override def getTable(
+ schema: StructType,
+ partitioning: Array[Transform],
+ properties: util.Map[String, String]): Table = new
StorageFilterTestTable(schema)
+}
+
+object StorageFilterTestSource {
+
+ /** Rows the source serves, `(k, k * 10)` for k in 0 until numRows. */
+ val numRows: Int = 200
+
+ /** Rows a batch holds at most in a columnar read. */
+ val batchSize: Int = 64
+
+ /** How many readers were created with a storage-filter request. */
+ val filteredReaders = new AtomicInteger(0)
+
+ /** How many of those requests asked for the checked column. */
+ val checkedColumnRequests = new AtomicInteger(0)
+
+ /** Rows the reader evaluated the storage filter against. */
+ val rowsEvaluated = new AtomicLong(0)
+
+ /** Rows the reader returned. */
+ val rowsReturned = new AtomicLong(0)
+
+ def reset(): Unit = {
+ filteredReaders.set(0)
+ checkedColumnRequests.set(0)
+ rowsEvaluated.set(0)
+ rowsReturned.set(0)
+ }
+}
+
+class StorageFilterTestTable(tableSchema: StructType) extends Table with
SupportsRead {
+
+ override def name(): String = "storage_filter_test"
+
+ override def schema(): StructType = tableSchema
+
+ override def capabilities(): util.Set[TableCapability] =
+ Set(TableCapability.BATCH_READ).asJava
+
+ override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder
= {
+ val factory = StorageFilterTestReaderFactory(
+ columnar = options.getBoolean("columnar", false),
+ giveUp = options.getBoolean("giveUp", false),
+ marksAllChecked = options.getBoolean("markAllChecked", false))
+ val declared = options.getOrDefault("declared",
"k").split(",").filter(_.nonEmpty).toSeq
+ val scan = () => new StorageFilterTestScan(tableSchema, factory, declared)
+ val pushes = options.getOrDefault("push",
"").split(",").filter(_.nonEmpty).toSet
+ if (pushes.nonEmpty) new StorageFilterTestPushingScanBuilder(scan, pushes)
else () => scan()
+ }
+}
+
+/**
+ * Takes the pushed operations named in `pushes` whole. Its scan does not
apply them, so it serves
+ * planning tests only.
+ */
+class StorageFilterTestPushingScanBuilder(scan: () => Scan, pushes:
Set[String])
+ extends SupportsPushDownLimit
+ with SupportsPushDownOffset
+ with SupportsPushDownTopN
+ with SupportsPushDownTableSample {
+
+ override def build(): Scan = scan()
+ override def pushLimit(limit: Int): Boolean = pushes("limit")
+ override def pushOffset(offset: Int): Boolean = pushes("offset")
+ override def pushTopN(orders: Array[SortOrder], limit: Int): Boolean =
pushes("topN")
+ override def pushTableSample(
+ lowerBound: Double,
+ upperBound: Double,
+ withReplacement: Boolean,
+ seed: Long,
+ sampleMethod: SampleMethod): Boolean = pushes("sample")
+ override def isPartiallyPushed: Boolean = false
+}
+
+class StorageFilterTestScan(
+ tableSchema: StructType,
+ factory: StorageFilterTestReaderFactory,
+ declared: Seq[String])
+ extends Scan with Batch with SupportsReportStatistics with
SupportsStorageFilters {
+
+ override def readSchema(): StructType = tableSchema
+
+ override def toBatch: Batch = this
+
+ // Over the suite's application-side scan size threshold, so
InjectRuntimeFilter considers this
+ // side worth filtering.
+ override def estimateStatistics(): Statistics = new Statistics {
+ override def sizeInBytes(): OptionalLong = OptionalLong.of(10L * 1024 *
1024)
+ override def numRows(): OptionalLong =
OptionalLong.of(StorageFilterTestSource.numRows.toLong)
+ }
+
+ override def storageFilterAttributes(): Array[NamedReference] =
+ declared.map(name =>
FieldReference(name.split('.').toSeq)).toArray[NamedReference]
+
+ override def planInputPartitions(): Array[InputPartition] =
+ Array(StorageFilterTestPartition)
+
+ override def createReaderFactory(): PartitionReaderFactory = factory
+}
+
+case object StorageFilterTestPartition extends InputPartition
+
+case class StorageFilterTestReaderFactory(
+ columnar: Boolean,
+ giveUp: Boolean,
+ marksAllChecked: Boolean)
+ extends PartitionReaderFactory {
+
+ override def supportColumnarReads(partition: InputPartition): Boolean =
columnar
+
+ // The filter the reader applies, none when it gives the filter up or only
marks the rows.
+ private def filterOf(request: StorageFilterRequest): Option[StorageFilter] =
{
+ StorageFilterTestSource.filteredReaders.incrementAndGet()
+ if (request.appendsCheckedColumn()) {
+ StorageFilterTestSource.checkedColumnRequests.incrementAndGet()
+ }
+ if (giveUp || marksAllChecked) None else Some(request.filter())
+ }
+
+ override def createReader(partition: InputPartition):
PartitionReader[InternalRow] =
+ new StorageFilterTestReader(None, appendsChecked = false)
+
+ override def createReader(
+ partition: InputPartition,
+ request: StorageFilterRequest): PartitionReader[InternalRow] =
+ new StorageFilterTestReader(filterOf(request),
request.appendsCheckedColumn(), marksAllChecked)
+
+ override def createColumnarReader(partition: InputPartition):
PartitionReader[ColumnarBatch] =
+ new StorageFilterTestColumnarReader(None, appendsChecked = false)
+
+ override def createColumnarReader(
+ partition: InputPartition,
+ request: StorageFilterRequest): PartitionReader[ColumnarBatch] =
+ new StorageFilterTestColumnarReader(
+ filterOf(request), request.appendsCheckedColumn(), marksAllChecked)
+}
+
+/**
+ * Serves `(k, k * 10)` row by row and drops any row `filter` rejects, with
the checked column
+ * last when `appendsChecked`. The filter's row layout is its `references()`,
only `k` here.
+ * `filter` is cleared when it cannot be applied. `marksAllChecked` marks
every row checked.
+ */
+class StorageFilterTestReader(
+ private var filter: Option[StorageFilter],
+ appendsChecked: Boolean,
+ marksAllChecked: Boolean = false)
+ extends PartitionReader[InternalRow] {
+
+ private var current: Long = -1L
+ private var row: InternalRow = _
+
+ override def next(): Boolean = {
+ var found = false
+ while (!found && current + 1 < StorageFilterTestSource.numRows) {
+ current += 1
+ val key = current
+ val passes = filter.forall { f =>
+ StorageFilterTestSource.rowsEvaluated.incrementAndGet()
+ try f.eval(new GenericInternalRow(Array[Any](key))) catch {
Review Comment:
Thanks, added in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
The test source can now have a third column, prunes columns, and builds the
filter's row from `references()`. It can also have several partitions, a
metadata column and runtime filtering. New tests:
- a two-key join, where one merged subquery returns both blooms. The scan
gets one filter over both columns, ships each bloom once, and a conjunct over
the third column stays in the `Filter`;
- a `SupportsRuntimeV2Filtering` scan with iterative pushdown, which gets
the bloom and takes it as a runtime filter too;
- a `SupportsRuntimeCatalystFiltering` scan that evaluates the bloom fully,
which is offered nothing;
- `coalesce(1)` over two partitions, whose readers share the one filter
their task deserialized.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/StorageFilterPushDownSuite.scala:
##########
@@ -0,0 +1,314 @@
+/*
+ * 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 scala.collection.mutable
+
+import org.apache.spark.sql.{DataFrame, QueryTest}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap,
AttributeReference, AttributeSet, BloomFilterMightContain, Cast, Divide,
EvalMode, GreaterThan, If, Literal, NumericEvalContext, Rand}
+import org.apache.spark.sql.execution.{BaseLimitExec, FilterExec, SampleExec,
SparkPlan, TakeOrderedAndProjectExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.{FileFormat,
StorageFilterPlanning}
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.execution.joins.SortMergeJoinExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.internal.connector.StorageFilterImpl
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{DoubleType, LongType}
+
+/**
+ * Covers the storage-filter contract for a data source v2 scan. With
+ * `V2_STORAGE_FILTER_PUSHDOWN_ENABLED` on, a scan that declares columns
through
+ * `SupportsStorageFilters` gets the runtime bloom filter over those columns
in every reader. The
+ * bloom stays in the Filter above the scan, skipped on the rows the reader
marks as checked.
+ */
+class StorageFilterPushDownSuite
+ extends QueryTest with SharedSparkSession with AdaptiveSparkPlanHelper {
+
+ private def sourceName: String = classOf[StorageFilterTestSource].getName
+
+ // Runs `body` with runtime blooms and V2 storage filters on, `conf` on top,
and the build table.
+ private def withBloomJoin(conf: (String, String)*)(body: => Unit): Unit = {
+ val bloomJoinConf = Seq(
+ SQLConf.V2_STORAGE_FILTER_PUSHDOWN_ENABLED.key -> "true",
+ SQLConf.RUNTIME_BLOOM_FILTER_ENABLED.key -> "true",
+ SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key ->
"1000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false")
+ withSQLConf(bloomJoinConf ++ conf: _*) {
+ withTable("bf_build") {
+ spark.range(0, 30).selectExpr("id AS bk", "id AS bv")
+ .write.format("parquet").saveAsTable("bf_build")
+ body
+ }
+ }
+ }
+
+ // A join whose build side is small and carries a selective predicate, which
is the shape
+ // InjectRuntimeFilter builds a bloom filter for. The bloom lands on
`probe`'s `k`.
+ private def bloomJoin(probe: DataFrame): DataFrame = {
+ val build = spark.table("bf_build").where("bv = 5")
+ probe.join(build, probe("k") ===
build("bk")).select(probe.columns.map(probe(_)).toSeq: _*)
+ }
+
+ private def v2Probe(options: (String, String)*): DataFrame =
+ spark.read.format(sourceName).options(options.toMap).load()
+
+ private val checkedName = FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME
+
+ private def scanOf(plan: SparkPlan): BatchScanExec =
+ collect(plan) { case b: BatchScanExec => b }.headOption
+ .getOrElse(fail(s"no BatchScanExec in plan:\n$plan"))
+
+ // The blooms the Filters above the scan evaluate on every row, and those
they skip on the rows
+ // the reader marks as checked.
+ private def bloomsAbove(plan: SparkPlan): (Int, Int) = {
+ val conditions = collect(plan) { case f: FilterExec => f.condition }
+ val all = conditions.map(_.collect { case _: BloomFilterMightContain => 1
}.sum).sum
+ val skipped = conditions.map(_.collect {
+ case If(a: Attribute, Literal.TrueLiteral, _: BloomFilterMightContain)
+ if a.name == checkedName => 1
+ }.sum).sum
+ (all - skipped, skipped)
+ }
+
+ // The rows the Filter on the scan returned, counted before the join.
+ private def rowsOutOfFilter(plan: SparkPlan): Long = {
+ val filters = collect(plan) {
+ case f: FilterExec if f.exists(_.isInstanceOf[BatchScanExec]) => f
+ }
+ assert(filters.size == 1, s"expected one Filter on the scan:\n$plan")
+ filters.head.metrics("numOutputRows").value
+ }
+
+ // Runs the bloom join over the v2 source read with `options`, and returns
its rows and plan.
+ private def runBloomJoin(options: (String, String)*): (Set[(Long, Long)],
SparkPlan) = {
+ StorageFilterTestSource.reset()
+ val df = bloomJoin(v2Probe(options: _*))
+ (df.collect().map(r => (r.getLong(0), r.getLong(1))).toSet,
df.queryExecution.executedPlan)
+ }
+
+ // The build side keeps `bv = 5`, so the join returns that one row.
+ private val joinResult = Set((5L, 50L))
+
+ for (columnar <- Seq(false, true); aqe <- Seq(false, true)) {
+ val kind = if (columnar) "columnar" else "row-based"
+ test(s"a $kind scan gets the checked column, and the Filter skips the
bloom on checked rows " +
+ s"(AQE $aqe)") {
+ withBloomJoin(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ val (result, plan) = runBloomJoin("columnar" -> columnar.toString)
+ val scan = scanOf(plan)
+ assert(scan.supportsColumnar == columnar, s"the scan must read
$kind:\n$plan")
+ assert(scan.storageFilters.size == 1,
+ s"the bloom filter must be on the scan; got
${scan.storageFilters}\n$plan")
+ assert(scan.checkedColumn.isDefined && scan.checkedColumn ==
scan.output.lastOption,
+ s"the scan must get the checked column, last:\n$plan")
+ assert(bloomsAbove(plan) == (0, 1),
+ s"the Filter must skip the bloom on checked rows:\n$plan")
+ assert(rowsOutOfFilter(plan) ==
StorageFilterTestSource.rowsReturned.get(),
+ s"the Filter must keep every checked row; got
${rowsOutOfFilter(plan)} of " +
+ s"${StorageFilterTestSource.rowsReturned.get()}")
+ val probeSide = collectFirst(plan) { case j: SortMergeJoinExec =>
j.left.output }
+ assert(probeSide.exists(!_.exists(_.name == checkedName)),
+ s"the Project above the scan must drop the checked column; got
$probeSide")
+ assert(StorageFilterTestSource.filteredReaders.get() == 1,
+ s"the one partition's reader must get the filter; " +
+ s"got ${StorageFilterTestSource.filteredReaders.get()}")
+ assert(StorageFilterTestSource.checkedColumnRequests.get() == 1,
+ "the reader must be asked for the checked column")
+ assert(result == joinResult, s"got $result")
+ assert(StorageFilterTestSource.rowsEvaluated.get() ==
StorageFilterTestSource.numRows,
+ s"the reader must evaluate every row; got
${StorageFilterTestSource.rowsEvaluated.get()}")
+ assert(StorageFilterTestSource.rowsReturned.get() <
StorageFilterTestSource.numRows,
+ "the reader must have dropped rows, otherwise the filter did
nothing")
+ }
+ }
+ }
+
+ Seq(false, true).foreach { columnar =>
+ val kind = if (columnar) "columnar" else "row-based"
+ test(s"a $kind reader that gives the filter up marks its rows unchecked,
and the Filter " +
+ "filters them") {
+ withBloomJoin() {
+ val (result, plan) = runBloomJoin("columnar" -> columnar.toString,
"giveUp" -> "true")
+ assert(bloomsAbove(plan) == (0, 1), s"the plan is that of a filtering
reader:\n$plan")
+ assert(StorageFilterTestSource.rowsEvaluated.get() == 0,
+ s"the reader must evaluate nothing; got
${StorageFilterTestSource.rowsEvaluated.get()}")
+ assert(StorageFilterTestSource.rowsReturned.get() ==
StorageFilterTestSource.numRows,
+ s"the reader must return every row; got
${StorageFilterTestSource.rowsReturned.get()}")
+ assert(rowsOutOfFilter(plan) < StorageFilterTestSource.numRows,
+ s"the Filter must apply the bloom to unchecked rows; got
${rowsOutOfFilter(plan)}")
+ assert(result == joinResult, s"got $result")
+ }
+ }
+
+ test(s"the Filter takes a $kind reader's word on the rows it marks
checked") {
+ // This reader breaks the contract on purpose: it applies no filter but
marks every row
+ // checked. Only skipping the bloom on those rows keeps all of them.
+ withBloomJoin() {
+ val (result, plan) =
+ runBloomJoin("columnar" -> columnar.toString, "markAllChecked" ->
"true")
+ assert(rowsOutOfFilter(plan) == StorageFilterTestSource.numRows,
+ s"the Filter must keep every row marked checked; got
${rowsOutOfFilter(plan)}")
+ assert(result == joinResult, s"the join must still filter; got
$result")
+ }
+ }
+ }
+
+ test("a filter that cannot be applied is given up, and its rows come back
unchecked") {
+ // `1 / k` under ANSI throws at k = 0, the partition's first row, so the
reader gives the
+ // filter up before it keeps or drops anything.
+ val k = AttributeReference("k", LongType)()
+ val inverse = Divide(Literal(1.0), Cast(k, DoubleType),
NumericEvalContext(EvalMode.ANSI))
+ val filter = StorageFilterImpl(GreaterThan(inverse, Literal(0.0)), Seq(k))
+ val reader = new StorageFilterTestColumnarReader(Some(filter),
appendsChecked = true)
+ val rows = mutable.ArrayBuffer.empty[(Long, Boolean)]
+ try {
+ while (reader.next()) {
+ val batch = reader.get()
+ (0 until batch.numRows()).foreach { i =>
+ rows += ((batch.column(0).getLong(i), batch.column(2).getBoolean(i)))
+ }
+ }
+ } finally {
+ reader.close()
+ }
+ assert(rows == (0L until StorageFilterTestSource.numRows.toLong).map(_ ->
false),
+ s"expected every row, unchecked; got ${rows.take(5)} and ${rows.size}
rows in all")
+ }
+
+ // Under a limit of two fields, the checked column would make three.
+ Seq(
+ ("too wide for whole-stage codegen", SQLConf.WHOLESTAGE_MAX_NUM_FIELDS.key
-> "2"),
+ ("without whole-stage codegen", SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key ->
"false")
+ ).foreach { case (when, conf) =>
+ test(s"a scan $when gets the bloom without the checked column") {
+ withBloomJoin(conf) {
+ val (result, plan) = runBloomJoin("columnar" -> "true")
+ val scan = scanOf(plan)
+ assert(scan.storageFilters.size == 1,
+ s"the bloom filter must be on the scan; got
${scan.storageFilters}\n$plan")
+ assert(scan.checkedColumn.isEmpty, s"the scan must not get the checked
column:\n$plan")
+ assert(StorageFilterTestSource.checkedColumnRequests.get() == 0,
+ "the reader must not be asked for the checked column")
+ assert(bloomsAbove(plan) == (1, 0), s"the Filter must keep the bloom
as it is:\n$plan")
+ assert(result == joinResult, s"got $result")
+ assert(StorageFilterTestSource.rowsReturned.get() <
StorageFilterTestSource.numRows,
+ "the reader must have dropped rows, otherwise the filter did
nothing")
+ }
+ }
+ }
+
+ Seq(
+ ("with the conf off", Seq(SQLConf.V2_STORAGE_FILTER_PUSHDOWN_ENABLED.key
-> "false"), Nil),
+ ("to a scan that declares no column", Nil, Seq("declared" -> "")),
+ ("to a scan that declares only another column", Nil, Seq("declared" ->
"v")),
+ ("to a scan that declares only a nested field", Nil, Seq("declared" ->
"k.x")),
+ ("to a scan with a column named like the checked column", Nil,
+ Seq("valueName" -> checkedName.toUpperCase(java.util.Locale.ROOT)))
+ ).foreach { case (when, conf, options) =>
+ test(s"nothing is offered $when") {
+ withBloomJoin(conf: _*) {
+ val (result, plan) = runBloomJoin(options :+ ("columnar" -> "true"):
_*)
+ val scan = scanOf(plan)
+ assert(scan.storageFilters.isEmpty && scan.checkedColumn.isEmpty,
+ s"the scan must get no storage filter:\n$plan")
+ assert(bloomsAbove(plan) == (1, 0), s"the Filter must keep the bloom
as it is:\n$plan")
+ assert(StorageFilterTestSource.filteredReaders.get() == 0,
+ "no reader may get a storage filter")
+ assert(result == joinResult, s"got $result")
+ }
+ }
+ }
+
+ test("the storage filter shows in the scan description and separates
canonicalized plans") {
+ withBloomJoin() {
+ val plan = bloomJoin(v2Probe("columnar" ->
"true")).queryExecution.executedPlan
+ val scan = scanOf(plan)
+ assert(scan.simpleString(100).contains("StorageFilters:"),
+ s"the scan description must name the filters:
${scan.simpleString(100)}")
+ assert(scan.verboseStringWithOperatorId().contains("StorageFilters:"),
+ s"EXPLAIN FORMATTED must name the filters:
${scan.verboseStringWithOperatorId()}")
+
+ intercept[IllegalArgumentException](scan.copy(storageFilters = Nil))
+ val plain = scan.copy(output = scan.output.init, storageFilters = Nil,
checkedColumn = None)
+ assert(scan != plain, "equals must see storageFilters")
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
The test now compares two scans that differ only in `storageFilters`, both
without the checked column, through `equals`, `hashCode` and `sameResult`. It
fails with `storageFilters` taken out of `equals`.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/StorageFilterPushDownSuite.scala:
##########
@@ -0,0 +1,314 @@
+/*
+ * 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 scala.collection.mutable
+
+import org.apache.spark.sql.{DataFrame, QueryTest}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap,
AttributeReference, AttributeSet, BloomFilterMightContain, Cast, Divide,
EvalMode, GreaterThan, If, Literal, NumericEvalContext, Rand}
+import org.apache.spark.sql.execution.{BaseLimitExec, FilterExec, SampleExec,
SparkPlan, TakeOrderedAndProjectExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.{FileFormat,
StorageFilterPlanning}
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.execution.joins.SortMergeJoinExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.internal.connector.StorageFilterImpl
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{DoubleType, LongType}
+
+/**
+ * Covers the storage-filter contract for a data source v2 scan. With
+ * `V2_STORAGE_FILTER_PUSHDOWN_ENABLED` on, a scan that declares columns
through
+ * `SupportsStorageFilters` gets the runtime bloom filter over those columns
in every reader. The
+ * bloom stays in the Filter above the scan, skipped on the rows the reader
marks as checked.
+ */
+class StorageFilterPushDownSuite
+ extends QueryTest with SharedSparkSession with AdaptiveSparkPlanHelper {
+
+ private def sourceName: String = classOf[StorageFilterTestSource].getName
+
+ // Runs `body` with runtime blooms and V2 storage filters on, `conf` on top,
and the build table.
+ private def withBloomJoin(conf: (String, String)*)(body: => Unit): Unit = {
+ val bloomJoinConf = Seq(
+ SQLConf.V2_STORAGE_FILTER_PUSHDOWN_ENABLED.key -> "true",
+ SQLConf.RUNTIME_BLOOM_FILTER_ENABLED.key -> "true",
+ SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key ->
"1000",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false")
+ withSQLConf(bloomJoinConf ++ conf: _*) {
+ withTable("bf_build") {
+ spark.range(0, 30).selectExpr("id AS bk", "id AS bv")
+ .write.format("parquet").saveAsTable("bf_build")
+ body
+ }
+ }
+ }
+
+ // A join whose build side is small and carries a selective predicate, which
is the shape
+ // InjectRuntimeFilter builds a bloom filter for. The bloom lands on
`probe`'s `k`.
+ private def bloomJoin(probe: DataFrame): DataFrame = {
+ val build = spark.table("bf_build").where("bv = 5")
+ probe.join(build, probe("k") ===
build("bk")).select(probe.columns.map(probe(_)).toSeq: _*)
+ }
+
+ private def v2Probe(options: (String, String)*): DataFrame =
+ spark.read.format(sourceName).options(options.toMap).load()
+
+ private val checkedName = FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME
+
+ private def scanOf(plan: SparkPlan): BatchScanExec =
+ collect(plan) { case b: BatchScanExec => b }.headOption
+ .getOrElse(fail(s"no BatchScanExec in plan:\n$plan"))
+
+ // The blooms the Filters above the scan evaluate on every row, and those
they skip on the rows
+ // the reader marks as checked.
+ private def bloomsAbove(plan: SparkPlan): (Int, Int) = {
+ val conditions = collect(plan) { case f: FilterExec => f.condition }
+ val all = conditions.map(_.collect { case _: BloomFilterMightContain => 1
}.sum).sum
+ val skipped = conditions.map(_.collect {
+ case If(a: Attribute, Literal.TrueLiteral, _: BloomFilterMightContain)
+ if a.name == checkedName => 1
+ }.sum).sum
+ (all - skipped, skipped)
+ }
+
+ // The rows the Filter on the scan returned, counted before the join.
+ private def rowsOutOfFilter(plan: SparkPlan): Long = {
+ val filters = collect(plan) {
+ case f: FilterExec if f.exists(_.isInstanceOf[BatchScanExec]) => f
+ }
+ assert(filters.size == 1, s"expected one Filter on the scan:\n$plan")
+ filters.head.metrics("numOutputRows").value
+ }
+
+ // Runs the bloom join over the v2 source read with `options`, and returns
its rows and plan.
+ private def runBloomJoin(options: (String, String)*): (Set[(Long, Long)],
SparkPlan) = {
+ StorageFilterTestSource.reset()
+ val df = bloomJoin(v2Probe(options: _*))
+ (df.collect().map(r => (r.getLong(0), r.getLong(1))).toSet,
df.queryExecution.executedPlan)
+ }
+
+ // The build side keeps `bv = 5`, so the join returns that one row.
+ private val joinResult = Set((5L, 50L))
+
+ for (columnar <- Seq(false, true); aqe <- Seq(false, true)) {
+ val kind = if (columnar) "columnar" else "row-based"
+ test(s"a $kind scan gets the checked column, and the Filter skips the
bloom on checked rows " +
+ s"(AQE $aqe)") {
+ withBloomJoin(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ val (result, plan) = runBloomJoin("columnar" -> columnar.toString)
+ val scan = scanOf(plan)
+ assert(scan.supportsColumnar == columnar, s"the scan must read
$kind:\n$plan")
+ assert(scan.storageFilters.size == 1,
+ s"the bloom filter must be on the scan; got
${scan.storageFilters}\n$plan")
+ assert(scan.checkedColumn.isDefined && scan.checkedColumn ==
scan.output.lastOption,
+ s"the scan must get the checked column, last:\n$plan")
+ assert(bloomsAbove(plan) == (0, 1),
+ s"the Filter must skip the bloom on checked rows:\n$plan")
+ assert(rowsOutOfFilter(plan) ==
StorageFilterTestSource.rowsReturned.get(),
+ s"the Filter must keep every checked row; got
${rowsOutOfFilter(plan)} of " +
+ s"${StorageFilterTestSource.rowsReturned.get()}")
+ val probeSide = collectFirst(plan) { case j: SortMergeJoinExec =>
j.left.output }
+ assert(probeSide.exists(!_.exists(_.name == checkedName)),
+ s"the Project above the scan must drop the checked column; got
$probeSide")
+ assert(StorageFilterTestSource.filteredReaders.get() == 1,
+ s"the one partition's reader must get the filter; " +
+ s"got ${StorageFilterTestSource.filteredReaders.get()}")
+ assert(StorageFilterTestSource.checkedColumnRequests.get() == 1,
+ "the reader must be asked for the checked column")
+ assert(result == joinResult, s"got $result")
+ assert(StorageFilterTestSource.rowsEvaluated.get() ==
StorageFilterTestSource.numRows,
+ s"the reader must evaluate every row; got
${StorageFilterTestSource.rowsEvaluated.get()}")
+ assert(StorageFilterTestSource.rowsReturned.get() <
StorageFilterTestSource.numRows,
+ "the reader must have dropped rows, otherwise the filter did
nothing")
+ }
+ }
+ }
+
+ Seq(false, true).foreach { columnar =>
+ val kind = if (columnar) "columnar" else "row-based"
+ test(s"a $kind reader that gives the filter up marks its rows unchecked,
and the Filter " +
+ "filters them") {
+ withBloomJoin() {
+ val (result, plan) = runBloomJoin("columnar" -> columnar.toString,
"giveUp" -> "true")
+ assert(bloomsAbove(plan) == (0, 1), s"the plan is that of a filtering
reader:\n$plan")
+ assert(StorageFilterTestSource.rowsEvaluated.get() == 0,
+ s"the reader must evaluate nothing; got
${StorageFilterTestSource.rowsEvaluated.get()}")
+ assert(StorageFilterTestSource.rowsReturned.get() ==
StorageFilterTestSource.numRows,
+ s"the reader must return every row; got
${StorageFilterTestSource.rowsReturned.get()}")
+ assert(rowsOutOfFilter(plan) < StorageFilterTestSource.numRows,
+ s"the Filter must apply the bloom to unchecked rows; got
${rowsOutOfFilter(plan)}")
+ assert(result == joinResult, s"got $result")
+ }
+ }
+
+ test(s"the Filter takes a $kind reader's word on the rows it marks
checked") {
+ // This reader breaks the contract on purpose: it applies no filter but
marks every row
+ // checked. Only skipping the bloom on those rows keeps all of them.
+ withBloomJoin() {
+ val (result, plan) =
+ runBloomJoin("columnar" -> columnar.toString, "markAllChecked" ->
"true")
+ assert(rowsOutOfFilter(plan) == StorageFilterTestSource.numRows,
+ s"the Filter must keep every row marked checked; got
${rowsOutOfFilter(plan)}")
+ assert(result == joinResult, s"the join must still filter; got
$result")
+ }
+ }
+ }
+
+ test("a filter that cannot be applied is given up, and its rows come back
unchecked") {
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
No runtime bloom throws, so no query reaches this path today. The test now
drives both test readers, so the row reader's `catch` runs too, and the
description says it covers the test readers. `runBloomJoin` checks the rows
itself, and the two "nothing offered" checks share a helper. `FileFormat`'s
name is the only one left, since item 16 removed
`SupportsStorageFilters.CHECKED_COLUMN_NAME`.
##########
docs/sql-v2-data-sources.md:
##########
@@ -319,7 +319,10 @@ for data locality.
is serialized to executors and creates a
[`PartitionReader`](api/java/org/apache/spark/sql/connector/read/PartitionReader.html)
for each
input partition. The reader iterates over rows (or `ColumnarBatch` instances
for columnar sources)
-and is closed after consumption.
+and is closed after consumption. For a `SupportsStorageFilters` scan that
Spark offered a storage
+filter, `createReader(partition, request)` or `createColumnarReader(partition,
request)` creates the
+reader instead. The `StorageFilterRequest` carries the filter, and whether the
reader appends a
+column that marks the rows it applied the filter to.
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788)
with your wording.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -127,6 +127,29 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
tableSpec.withNewLocation(newLoc)
}
+ /**
+ * The conjuncts of `filters` offered to the scan as storage filters, see
+ * `SupportsStorageFilters` and `StorageFilterPlanning.offeredConjuncts`.
Only runtime blooms for
+ * now, as on the file-source side.
+ */
+ private def storageFiltersFor(
+ relation: DataSourceV2ScanRelation,
+ filters: Seq[Expression]): Seq[Expression] = {
+ if (!session.sessionState.conf.v2StorageFilterPushdownEnabled) return Nil
+ if (relation.storageFilterAttrs.isEmpty) return Nil
+ // A scan that took a pushed limit, offset, top-N or sample, or an
aggregate or join, would have
+ // its readers filter ahead of it. `V2ScanRelationPushDown` leaves every
such scan, and any scan
+ // a later pushdown builds, not mergeable.
+ if (!relation.mergeableScan) return Nil
+ // Over the whole table, as on the file-source side, since
`SupportsStorageFilters` reserves
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788),
the second way. `BatchScanExec` passes its output without the checked column
to `replanWithRuntimeFilters`. So the V2 path no longer reserves the name, and
`SupportsStorageFilters.CHECKED_COLUMN_NAME` is gone. The name is internal to
`FileFormat` again. A test reads a table with a column of that name through a
`SupportsRuntimeV2Filtering` scan with iterative pushdown. With the widened
output it fails on the ambiguous filter attribute.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/StorageFilterPlanning.scala:
##########
@@ -0,0 +1,95 @@
+/*
+ * 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.execution.datasources
+
+import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference, AttributeSet, Expression, ExpressionSet, If, Literal}
+import org.apache.spark.sql.connector.read.SupportsStorageFilters
+import org.apache.spark.sql.execution.WholeStageCodegenExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.BooleanType
+
+/**
+ * The planning rules file-source and DSv2 scans share for storage filters, see
+ * `FileFormat.buildReaderWithStorageFilters` and `SupportsStorageFilters`.
Each strategy decides
+ * on its own whether its scan can take any.
+ */
+object StorageFilterPlanning {
+
+ /** Whether one of `names` is the checked column's, in which case no filter
is offered. */
+ def clashesWithCheckedColumn(names: Seq[String]): Boolean =
+
names.exists(_.equalsIgnoreCase(SupportsStorageFilters.CHECKED_COLUMN_NAME))
+
+ /**
+ * The conjuncts of `filters` offered to the scan, each once, as an
`ExpressionSet` counts them.
+ * Each one:
+ * - is deterministic, since the post-scan `Filter` takes the reader's word
on the rows it
+ * checked;
+ * - references at least one column, and only `keyCandidates`;
+ * - is taken by `accepts`.
+ *
+ * Nothing is offered when the offered conjuncts reference every one of
`readColumns`, since the
+ * reader then reads the same columns for the same rows either way.
+ */
+ def offeredConjuncts(
+ filters: Seq[Expression],
+ keyCandidates: AttributeSet,
+ readColumns: AttributeSet,
+ accepts: Expression => Boolean): Seq[Expression] = {
+ val offered = ExpressionSet(filters).toSeq.filter { expr =>
+ val refs = expr.references
+ expr.deterministic && refs.nonEmpty && refs.subsetOf(keyCandidates) &&
accepts(expr)
+ }
+ if (offered.isEmpty) return Nil
+ val storageKeyAttrs =
AttributeSet.fromAttributeSets(offered.map(_.references))
+ if (readColumns.subsetOf(storageKeyAttrs)) Nil else offered
+ }
+
+ /**
+ * The column the reader marks the rows it checked in, added after `output`
when a filter is
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
The doc says the caller places the column, and names a conjunct that cannot be
generated, with SPARK-60034's trade-off. The description's sentence is reworded
the same way.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -127,6 +127,29 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
tableSpec.withNewLocation(newLoc)
}
+ /**
+ * The conjuncts of `filters` offered to the scan as storage filters, see
+ * `SupportsStorageFilters` and `StorageFilterPlanning.offeredConjuncts`.
Only runtime blooms for
+ * now, as on the file-source side.
+ */
+ private def storageFiltersFor(
+ relation: DataSourceV2ScanRelation,
+ filters: Seq[Expression]): Seq[Expression] = {
+ if (!session.sessionState.conf.v2StorageFilterPushdownEnabled) return Nil
+ if (relation.storageFilterAttrs.isEmpty) return Nil
+ // A scan that took a pushed limit, offset, top-N or sample, or an
aggregate or join, would have
+ // its readers filter ahead of it. `V2ScanRelationPushDown` leaves every
such scan, and any scan
+ // a later pushdown builds, not mergeable.
+ if (!relation.mergeableScan) return Nil
Review Comment:
Thanks, changed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788)
and
[`cb01a6f`](https://github.com/apache/spark/pull/59715/commits/cb01a6f1c54dbd6e29153c6c9d6f1753a731a999).
`DataSourceV2ScanRelation` has a new `onlyRowWisePushdowns` flag, false by
default. The plain and the variant-extraction builds set it from
`hasRowSelectingPushdown`, the one list of the pushdowns that decide which rows
a scan returns. `hasBlockingPushdown` reads the same list, so a new pushdown
listed there blocks merging as well. The storage-filter gate reads the flag,
and `mergeableScan` means only scan merging again. A test plans the same
relation with the two flags set apart, both ways.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -29,22 +29,37 @@ import org.apache.spark.sql.connector.catalog.Table
import org.apache.spark.sql.connector.read._
import org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE
import org.apache.spark.sql.connector.write.RowLevelOperationTable
-import org.apache.spark.sql.execution.EmptyRDDWithPartitions
+import org.apache.spark.sql.execution.{EmptyRDDWithPartitions, ScalarSubquery
=> ExecScalarSubquery}
import org.apache.spark.sql.execution.metric.{SQLLastAttemptMetrics,
SQLMetric, SQLMetrics}
+import org.apache.spark.sql.internal.connector.{StorageFilterImpl,
StorageFilterRequestImpl}
import org.apache.spark.util.ArrayImplicits._
/**
* Physical plan node for scanning a batch of data from a data source v2.
+ *
+ * @param storageFilters Predicates the scan's readers may evaluate, to skip
reading the other
+ * columns of the rows they reject, see
[[SupportsStorageFilters]]. Non-empty
+ * only for a scan that declared their columns. Each
task's readers get them
+ * ANDed into one filter, in a [[StorageFilterRequest]].
They stay in the
+ * post-scan `Filter` too.
+ * @param checkedColumn The column the readers mark the rows they checked in,
last in `output`, when
+ * the planner asked for one, see
`StorageFilterPlanning.checkedColumn`.
*/
case class BatchScanExec(
output: Seq[AttributeReference],
@transient scan: Scan,
runtimeFilters: Seq[Expression],
ordering: Option[Seq[SortOrder]] = None,
@transient table: Table,
- keyGroupedPartitioning: Option[Seq[Expression]] = None
+ keyGroupedPartitioning: Option[Seq[Expression]] = None,
+ storageFilters: Seq[Expression] = Nil,
+ checkedColumn: Option[AttributeReference] = None
Review Comment:
Thanks, changed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
`BatchScanExec` has `appendsCheckedColumn: Boolean` now. The `require` checks
that the last output column is a boolean, and the extra normalization and the
twin's remapping are gone.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/connector/StorageFilterImpl.scala:
##########
@@ -0,0 +1,132 @@
+/*
+ * 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 scala.util.control.NonFatal
+
+import org.apache.spark.{SparkEnv, SparkException, TaskContext}
+import org.apache.spark.executor.Executor
+import org.apache.spark.internal.{Logging, LogKeys}
+import org.apache.spark.internal.config.KILL_ON_FATAL_ERROR_DEPTH
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BindReferences,
Expression => CatalystExpression, Predicate => CatalystPredicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference,
NamedReference}
+import org.apache.spark.sql.connector.expressions.filter.{StorageFilter,
StorageFilterNotAppliedException}
+
+/**
+ * A [[StorageFilter]] wrapping a Catalyst expression, so a scan can apply a
predicate it does not
+ * have to understand. It mirrors [[PartitionPredicateImpl]], which evaluates
a partition key
+ * rather than a data row.
+ *
+ * `attrs` is the layout of the row [[eval]] expects, in that order, and the
expression is bound to
+ * it. The binding is built on first use, so an instance created on the driver
can be evaluated on
+ * an executor.
+ */
+class StorageFilterImpl private (
+ private val catalystExpr: CatalystExpression,
+ private val attrs: Seq[Attribute])
+ extends StorageFilter with Logging {
+
+ // The width of the row `eval` takes.
+ private val numReferences = attrs.length
Review Comment:
Thanks, fixed in
[`4b01898`](https://github.com/apache/spark/pull/59715/commits/4b01898da695d7f6b5cce1ddca29d5c040dcf788).
The messages interpolate `$this`, and the comment stays once, on `toString`. I
kept `numReferences`, since `eval` reads it on every row and `attrs` can be a
`List`.
--
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]