HyukjinKwon commented on code in PR #59943:
URL: https://github.com/apache/spark/pull/59943#discussion_r4465615373
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/MicroBatchExecution.scala:
##########
@@ -261,6 +260,19 @@ class MicroBatchExecution(
}
import
org.apache.spark.sql.execution.datasources.v2.DataSourceV2Implicits._
+ // The columns and the filters that the query reads from each Data Source
V2 source, which the
+ // scan of the source gets if it supports them. They come from the plan of
the query optimized
+ // once as its micro-batches are, with their current time and their
configuration, which a
+ // restarted query takes from the offset log. The plan is only optimized
for a scan builder
+ // that supports a pushdown.
+ lazy val sourceReads = {
+ val batchConf = streamConf.clone()
+ initialLatestOffsetSeq.flatMap(_._2.metadataOpt).foreach { metadata =>
+ OffsetSeqMetadata.setSessionConf(metadata, batchConf, logChanges =
false)
+ }
+
StreamingV2ScanPushDown.reads(sparkSessionForStream.sessionState.optimizer,
batchConf,
+ withBatchTimestamp(planWithDedupKeys, batchTimestampMs = 0L))
Review Comment:
Fixed in 9ef62c1, for both modes. The plan optimized at the start no longer
has any value of the time: every expression with the pattern `CURRENT_LIKE`
(including `current_time()`, `now()` and the nanosecond variants) and the casts
of times to timestamps, which take the current date, become placeholders that
keep their children, which the optimizer does not take as deterministic, and no
filter that has one is pushed down. `ContinuousExecution` calls the same
`reads` now, so a reconfigured continuous query plans with its new time while
its scan has no filter on the time. The new tests "do not push down a filter on
a value that depends on when it is evaluated: ..." include `tod <
current_time()`, `CAST(tod AS TIMESTAMP_NTZ) < ...` and `CAST(tod AS TIMESTAMP)
< ...`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/StreamingV2ScanPushDown.scala:
##########
@@ -0,0 +1,241 @@
+/*
+ * 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.streaming.runtime
+
+import scala.collection.mutable
+import scala.util.control.NonFatal
+
+import org.apache.spark.internal.{Logging, LogKeys}
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference,
AttributeSet, Expression, ExpressionSet, SubqueryExpression}
+import org.apache.spark.sql.catalyst.planning.PhysicalOperation
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.rules.RuleExecutor
+import org.apache.spark.sql.catalyst.streaming.StreamingRelationV2
+import org.apache.spark.sql.catalyst.types.DataTypeUtils
+import org.apache.spark.sql.connector.catalog.SupportsRead
+import org.apache.spark.sql.connector.read.{Scan, ScanBuilder,
SupportsPushDownFilters, SupportsPushDownRequiredColumns,
SupportsPushDownV2Filters}
+import org.apache.spark.sql.connector.read.streaming.SparkDataStream
+import org.apache.spark.sql.execution.datasources.DataSourceStrategy
+import org.apache.spark.sql.execution.datasources.v2.PushDownUtils
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.internal.connector.SupportsPushDownCatalystFilters
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * Pushes the top-level columns and the filters that the optimized plan of a
streaming query reads
+ * from each Data Source V2 streaming source down to the scan of the source, as
+ * `V2ScanRelationPushDown` does for the scans of batch queries.
+ *
+ * The stream of a source is created from its scan, and has to exist before
any micro-batch is
+ * planned, so the scan is built when the query starts. The query is optimized
once at that point,
+ * by the optimizer of its micro-batches and with their configuration, and the
scan of each source
+ * gets what the planner takes from the optimized plan for the relation of the
source (see
+ * `DataSourceV2Strategy`): the projections and the filters over each
occurrence of the relation.
+ *
+ * The plan of the query does not change, so each micro-batch is optimized as
without these
+ * pushdowns: the optimizer keeps deciding which columns every operator gets,
and in which order,
+ * including the stateful ones, and Spark still evaluates all the filters over
the relation. Only
+ * the scan node of the physical plan outputs the columns that the scan reads,
and the planner
+ * checks that the micro-batch reads no other column of the relation.
+ *
+ * The other operations that the scans of batch queries can apply, such as
aggregates and limits,
+ * apply to the data of all the micro-batches of a streaming query, which the
scan of a micro-batch
+ * does not read, so they are not pushed down.
+ */
+private[sql] object StreamingV2ScanPushDown extends Logging {
+
+ /**
+ * What the optimized plan of a streaming query reads from a streaming
relation.
+ *
+ * @param requiredColumns the columns of the relation that the plan reads
+ * @param filters the deterministic filters without subqueries that the plan
applies over every
+ * occurrence of the relation, which its scan may apply too
+ */
+ case class Reads(requiredColumns: AttributeSet, filters: Seq[Expression])
+
+ /**
+ * Returns what the optimized `plan` reads from each of its streaming
relations of Data Source
+ * V2: what the planner takes from the projections and the filters over each
occurrence of the
+ * relation, or all its columns and no filter where nothing is projected or
filtered over it. A
+ * relation that the optimized plan does not have, or any relation if the
optimizer fails, has no
+ * entry, so that its scan reads all its columns and gets no filter.
+ *
+ * @param optimizer the optimizer of the micro-batches of the query
+ * @param conf the configuration that the micro-batches of the query run with
+ * @param plan the plan of the query, with the current time of a micro-batch
as the micro-batches
+ * have it, which the optimizer does not fold, so that no filter
on it is pushed down
+ */
+ def reads(
+ optimizer: RuleExecutor[LogicalPlan],
+ conf: SQLConf,
+ plan: LogicalPlan): Map[StreamingRelationV2, Reads] = {
+ SQLConf.withExistingConf(conf) {
+ try {
+ val occurrences = mutable.HashMap.empty[StreamingRelationV2,
mutable.Buffer[Reads]]
+ // Visits the nodes as the planner plans them: the projections and the
filters over a
+ // relation are planned with its scan, and the other nodes on their
own.
+ def visit(node: LogicalPlan): Unit = node match {
+ case PhysicalOperation(project, filters, relation:
StreamingRelationV2) =>
+ occurrences.getOrElseUpdate(relation, mutable.Buffer.empty) +=
Reads(
+ AttributeSet((project ++ filters).flatMap(_.references)),
+ filters.filter(f => f.deterministic &&
!SubqueryExpression.hasSubquery(f)))
+ case _ =>
+ node.children.foreach(visit)
+ }
+ visit(optimizer.execute(plan))
+ occurrences.map { case (relation, reads) =>
+ // The scan of a relation reads the rows of all its occurrences, so
it can only apply the
+ // filters that all of them apply.
+ val filters = reads.map(r => ExpressionSet(r.filters)).reduce {
(left, right) =>
+ ExpressionSet(left.filter(right.contains))
+ }
+ relation -> Reads(
+ AttributeSet.fromAttributeSets(reads.map(_.requiredColumns)),
filters.toSeq)
+ }.toMap
+ } catch {
+ case NonFatal(e) =>
+ logWarning(log"Could not optimize the streaming query to push down
what it reads to " +
+ log"the scans of its sources, which read all their columns with no
filter.", e)
+ Map.empty
+ }
+ }
+ }
+
+ /**
+ * Builds the scan of a streaming source, and creates its stream with
`createStream`. If its
+ * scan builder supports it, and `conf` enables it, the scan only reads the
columns of `output`
+ * that the query reads, and gets the filters that the query applies to all
the rows that it reads
+ * from the source (see `reads`). `reads` is only evaluated for such a scan
builder. Returns the
+ * scan, its stream, and the columns of `output` that the scan reads, in the
order of its read
+ * schema, unless these are `output` itself.
+ *
+ * A pruned scan must read the required columns and only columns of
`output`, each once, as
+ * `SupportsPushDownRequiredColumns` requires. A scan that does not is
replaced by a scan of all
+ * the columns, as without pruning, and its stream is stopped. Spark asks a
scan for its read
+ * schema once the stream is created, since a scan may only know then what
its stream reads.
+ *
+ * @param output the output of the relation of the source, with the metadata
columns that the
+ * query reads, which the analyzer added to it, so that
connectors implementing
+ * `SupportsMetadataColumns` include them in `readSchema()`
+ * @param conf the configuration of the query
+ * @param reads what the query reads from the relation, or None to read all
its columns
+ */
+ def buildScan[S <: SparkDataStream](
+ table: SupportsRead,
+ options: CaseInsensitiveStringMap,
+ output: Seq[AttributeReference],
+ conf: SQLConf,
+ reads: => Option[Reads])(
+ createStream: Scan => S): (Scan, S, Option[Seq[AttributeReference]]) = {
+ val builder = table.newScanBuilder(options)
+ val prunesColumns = conf.streamingV2ColumnPruningEnabled &&
+ builder.isInstanceOf[SupportsPushDownRequiredColumns]
+ val pushesFilters = conf.streamingV2FilterPushdownEnabled &&
supportsFilters(builder)
+ val known = if (prunesColumns || pushesFilters) reads else None
+ val requiredOutput = known.filter(_ => prunesColumns)
+ .map(r => output.filter(r.requiredColumns.contains))
+ .getOrElse(output)
+ val filters = known.filter(_ => pushesFilters)
+ .map(r => DataSourceStrategy.normalizeExprs(r.filters, output))
+ .getOrElse(Nil)
+
+ def build(builder: ScanBuilder, columns: Seq[AttributeReference]): Scan = {
+ if (filters.nonEmpty) {
+ // The plan of the query keeps the filters, which Spark evaluates
after the scan, so the
+ // scan may apply any of them.
+ PushDownUtils.pushFilters(builder, filters, partitionFields = None)
+ }
+ builder match {
+ case pruning: SupportsPushDownRequiredColumns =>
+ pruning.pruneColumns(DataTypeUtils.fromAttributes(columns))
+ case _ =>
+ }
+ builder.build()
+ }
+ def buildFullScan(builder: ScanBuilder): (Scan, S,
Option[Seq[AttributeReference]]) = {
+ val scan = build(builder, output)
+ (scan, createStream(scan), None)
+ }
+
+ if (requiredOutput.length == output.length) {
+ buildFullScan(builder)
+ } else {
+ val scan = build(builder, requiredOutput)
+ val stream = createStream(scan)
+ val (readSchema, scanOutput) = try {
+ val readSchema = scan.readSchema()
+ (readSchema, prunedScanOutput(readSchema, output, requiredOutput))
+ } catch {
+ case e: Throwable =>
+ try stream.stop() catch {
+ case NonFatal(suppressed) => e.addSuppressed(suppressed)
+ }
+ throw e
+ }
+ scanOutput match {
+ case Right(scanOutput) =>
+ val readsOutput = scanOutput.map(_.exprId) == output.map(_.exprId)
+ (scan, stream, if (readsOutput) None else Some(scanOutput))
+ case Left(reason) =>
+ val columns = requiredOutput.map(_.name).mkString(", ")
+ logWarning(log"The scan of the streaming table " +
+ log"${MDC(LogKeys.STREAMING_TABLE, table)} was asked to read the
columns " +
+ log"${MDC(LogKeys.COLUMN_NAMES, columns)}, but its read schema " +
+ log"${MDC(LogKeys.SCHEMA, readSchema.simpleString)} " +
+ log"${MDC(LogKeys.REASON, reason)}. It reads all the columns
instead.")
+ stream.stop()
+ buildFullScan(table.newScanBuilder(options))
+ }
+ }
+ }
+
+ private def supportsFilters(builder: ScanBuilder): Boolean = builder match {
Review Comment:
Fixed in 9ef62c1: `supportsFilters` excludes `PythonScanBuilder`, so a
streaming scan of a Python data source gets no filter, and since the builder
does not support column pruning either, the startup optimization does not run
for it: no `pushdownFiltersInPython` worker, `reader(schema)` call or
`partitions()` at stream start. The new test in
`PythonStreamingDataSourceSuite` filters the stream of a source that only has
`streamReader`, with `spark.sql.python.filterPushdown.enabled`; it fails with
`PySparkNotImplementedError: [NOT_IMPLEMENTED] reader is not implemented`
without the fix. The PR description and the migration guide are updated.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -1101,6 +1103,30 @@ private[sql] object DataSourceV2Strategy extends Logging
{
withFilter
}
}
+
+ /**
+ * Returns the output of the scan node of a streaming relation: the columns
that its scan reads.
+ * A scan built to read fewer columns than the relation outputs only gets
them when the
+ * projections and the filters over the relation, which the scan node is
planned with, read no
+ * other column of the relation (see `StreamingV2ScanPushDown`).
+ */
+ private def streamingScanOutput(
Review Comment:
Done in 9ef62c1. `StreamingDataSourceV2ScanRelation.pushedFilters` records
the filters that the scan got (remapped by `newInstance` as `prunedOutput` is),
and `DataSourceV2Strategy.streamingScanOutput` fails with an internal error
that names `spark.sql.streaming.v2FilterPushdown.enabled` when the filters over
the relation do not contain them (`ExpressionSet`). New test: "fail to plan a
micro-batch that does not apply a filter that its scan got". With the startup
optimization of the previous commit, it turns the `now()` case into such an
error at the first micro-batch.
To make that check hold in every micro-batch of a valid query, the startup
optimization no longer infers filters from constraints
(`InferFiltersFromConstraints` is excluded there). A filter inferred from
another relation is not always over the relation in a micro-batch: in a
stream-stream join `s.join(o.where("oid < 3"), id = oid)`, `id < 3` is inferred
for `s`, but not in a micro-batch where `o` has no new data, since
`PropagateEmptyRelation` removes the filter with its empty relation (a cached
static side loses its constraints the same way). The pushed filters are now the
filters of the query over the relation, which the micro-batches apply too, and
they still infer their own. New test: "push down no filter that the optimizer
infers from the filters of another source", which runs such micro-batches.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/StreamingV2ScanPushDown.scala:
##########
@@ -0,0 +1,241 @@
+/*
+ * 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.streaming.runtime
+
+import scala.collection.mutable
+import scala.util.control.NonFatal
+
+import org.apache.spark.internal.{Logging, LogKeys}
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference,
AttributeSet, Expression, ExpressionSet, SubqueryExpression}
+import org.apache.spark.sql.catalyst.planning.PhysicalOperation
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.rules.RuleExecutor
+import org.apache.spark.sql.catalyst.streaming.StreamingRelationV2
+import org.apache.spark.sql.catalyst.types.DataTypeUtils
+import org.apache.spark.sql.connector.catalog.SupportsRead
+import org.apache.spark.sql.connector.read.{Scan, ScanBuilder,
SupportsPushDownFilters, SupportsPushDownRequiredColumns,
SupportsPushDownV2Filters}
+import org.apache.spark.sql.connector.read.streaming.SparkDataStream
+import org.apache.spark.sql.execution.datasources.DataSourceStrategy
+import org.apache.spark.sql.execution.datasources.v2.PushDownUtils
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.internal.connector.SupportsPushDownCatalystFilters
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * Pushes the top-level columns and the filters that the optimized plan of a
streaming query reads
+ * from each Data Source V2 streaming source down to the scan of the source, as
+ * `V2ScanRelationPushDown` does for the scans of batch queries.
+ *
+ * The stream of a source is created from its scan, and has to exist before
any micro-batch is
+ * planned, so the scan is built when the query starts. The query is optimized
once at that point,
+ * by the optimizer of its micro-batches and with their configuration, and the
scan of each source
+ * gets what the planner takes from the optimized plan for the relation of the
source (see
+ * `DataSourceV2Strategy`): the projections and the filters over each
occurrence of the relation.
+ *
+ * The plan of the query does not change, so each micro-batch is optimized as
without these
+ * pushdowns: the optimizer keeps deciding which columns every operator gets,
and in which order,
+ * including the stateful ones, and Spark still evaluates all the filters over
the relation. Only
+ * the scan node of the physical plan outputs the columns that the scan reads,
and the planner
+ * checks that the micro-batch reads no other column of the relation.
+ *
+ * The other operations that the scans of batch queries can apply, such as
aggregates and limits,
+ * apply to the data of all the micro-batches of a streaming query, which the
scan of a micro-batch
+ * does not read, so they are not pushed down.
+ */
+private[sql] object StreamingV2ScanPushDown extends Logging {
+
+ /**
+ * What the optimized plan of a streaming query reads from a streaming
relation.
+ *
+ * @param requiredColumns the columns of the relation that the plan reads
+ * @param filters the deterministic filters without subqueries that the plan
applies over every
+ * occurrence of the relation, which its scan may apply too
+ */
+ case class Reads(requiredColumns: AttributeSet, filters: Seq[Expression])
+
+ /**
+ * Returns what the optimized `plan` reads from each of its streaming
relations of Data Source
+ * V2: what the planner takes from the projections and the filters over each
occurrence of the
+ * relation, or all its columns and no filter where nothing is projected or
filtered over it. A
+ * relation that the optimized plan does not have, or any relation if the
optimizer fails, has no
+ * entry, so that its scan reads all its columns and gets no filter.
+ *
+ * @param optimizer the optimizer of the micro-batches of the query
+ * @param conf the configuration that the micro-batches of the query run with
+ * @param plan the plan of the query, with the current time of a micro-batch
as the micro-batches
+ * have it, which the optimizer does not fold, so that no filter
on it is pushed down
+ */
+ def reads(
+ optimizer: RuleExecutor[LogicalPlan],
+ conf: SQLConf,
+ plan: LogicalPlan): Map[StreamingRelationV2, Reads] = {
+ SQLConf.withExistingConf(conf) {
+ try {
+ val occurrences = mutable.HashMap.empty[StreamingRelationV2,
mutable.Buffer[Reads]]
+ // Visits the nodes as the planner plans them: the projections and the
filters over a
+ // relation are planned with its scan, and the other nodes on their
own.
+ def visit(node: LogicalPlan): Unit = node match {
+ case PhysicalOperation(project, filters, relation:
StreamingRelationV2) =>
+ occurrences.getOrElseUpdate(relation, mutable.Buffer.empty) +=
Reads(
+ AttributeSet((project ++ filters).flatMap(_.references)),
+ filters.filter(f => f.deterministic &&
!SubqueryExpression.hasSubquery(f)))
+ case _ =>
+ node.children.foreach(visit)
+ }
+ visit(optimizer.execute(plan))
Review Comment:
In 9ef62c1, the relations of the batch scans are placeholders in the plan
optimized at the start (`DataSourceV2Relation`, non-streaming `LogicalRelation`
and `HiveTableRelation`, also in subqueries), so `V2ScanRelationPushDown`,
`PruneFileSourcePartitions`, `PruneHiveTablePartitions`, `PartitionPruning` and
`InjectRuntimeFilter` have nothing to build, list or plan for them: no batch
scan, Python worker or partition listing at the start. The new test "do not
build the scans of the batch relations of a streaming query when it starts"
checks it with a batch table that counts its scan builders.
I kept the other rules: the planner now checks that each micro-batch reads
no other column and applies the pushed filters, so the startup optimization has
to end up with the same projections and filters over the relation as the
optimizer of the micro-batches. A subset of the rules, e.g. without the
subquery rewrites, `InferFiltersFromGenerate` or the extraction of Python UDFs
in the later batches, would leave other projections and filters over the
relation. The pass runs once per start, only for a source whose scan builder
supports one of the pushdowns, which excludes the built-in sources, and every
micro-batch runs the same optimizer anyway.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarTable.scala:
##########
@@ -123,39 +124,59 @@ class ColumnarScanBuilder(
override def pruneColumns(requiredSchema: StructType): Unit = {
Review Comment:
Fixed in 9ef62c1: the read schema of `ColumnarScan` is now the required
columns for streams too, and its stream drops the other columns from the
batches of the stream reader, which reads the full schema until #59931 passes
the columns to read to it. So the scan node of a micro-batch only outputs the
required columns, and the startup optimization is not wasted.
`ColumnarDataSourceSuite` checks that the scan of `select("name")` reads and
outputs only `name`.
--
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]