dongjoon-hyun commented on code in PR #55579:
URL: https://github.com/apache/spark/pull/55579#discussion_r4503842447


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -230,48 +238,92 @@ object PushDownUtils extends Logging {
 
         translatedFiltersPushed || partPredicatesPushed
 
-      case catalystScan: SupportsRuntimeCatalystFiltering if 
pushableFilters.nonEmpty =>
-        // A runtime filter is normally evaluated twice: the source prunes 
with it, and the
-        // FilterExec above the scan applies it again. The two have to agree, 
so this screen
-        // pushes only predicates the source can be trusted to evaluate in 
Spark's place.
-        //
-        // But pushing a non-deterministic one would let the source prune on 
its own coin flip and
-        // Spark flip again for the rows that survive, so we handle that 
specially.
-        //
-        // Not pushing a filter is safe only while its FilterExec still 
evaluates it. A fully
-        // pushed filter has none, so the source is its only evaluator. That 
is why
-        // DataSourceV2Strategy deletes a FilterExec at planning time only for 
a filter we push
-        // below, and we use the same method (isPushablePartitionFilter) to 
determine if we
-        // should push it.
-        //
-        // A DPP filter degrades to TrueLiteral once its subquery is pruned 
away, so it matches
-        // every row. The V2 path above drops these implicitly, since 
translateRuntimeFilterV2
-        // returns None; here we push Catalyst expressions directly, so we 
remove them explicitly.
-        val catalystFilters = pushableFilters
-          .flatMap(unwrapRuntimeFilterExpression)
-          .filterNot(_ == Literal.TrueLiteral)
-        if (catalystFilters.nonEmpty) {
-          catalystScan.filter(catalystFilters.toArray)
-          true
-        } else {
-          false
+      case catalystScan: SupportsRuntimeCatalystFiltering
+          if runtimeFilters.nonEmpty && 
implementsOnlyDeprecatedFilter(catalystScan) =>
+        filterWithDeprecatedMethod(catalystScan, runtimeFilters, output)
+
+      case catalystScan: SupportsRuntimeCatalystFiltering =>
+        // Resolved only when a filter could be fully pushed, so a node with 
no such filter does
+        // not ask the scan.
+        lazy val fullyPushedAttrs = 
V2ExpressionUtils.resolveDataSourceRuntimeFilterRefs(
+          catalystScan.fullyPushedFilterAttributes(),
+          output,
+          "fullyPushedFilterAttributes()",
+          catalystScan.getClass.getName)
+        val evaluatedOnlyByScan =
+          runtimeFilters.filter(isFullyPushedRuntimeFilter(_, 
fullyPushedAttrs))
+        if (evaluatedOnlyByScan.nonEmpty) {
+          throw SparkException.internalError(
+            s"${scan.getClass.getName} fully evaluates the runtime filters " +
+            s"${evaluatedOnlyByScan.mkString(", ")}, so they must go through " 
+
+            "PushDownUtils.replanWithRuntimeFilters, not pushRuntimeFilters.")
         }
+        false
 
       case _ =>
         false
     }
   }
 
+  /**
+   * Screens runtime filters for a [[SupportsRuntimeCatalystFiltering]] scan 
and unwraps them to the
+   * Catalyst predicates the scan plans with, untranslated.
+   *
+   * A filter is kept only if it references attributes the scan declared 
filterable and passes
+   * `isPushablePartitionFilter`, the check `DataSourceV2Strategy` uses to 
drop a pushed filter's
+   * `FilterExec`. A DPP filter degraded to true is removed, as the V2 path 
does by not translating
+   * it.
+   */
+  private def catalystRuntimeFilters(
+      scan: SupportsRuntimeCatalystFiltering,
+      runtimeFilters: Seq[Expression],
+      output: Seq[AttributeReference]): Seq[Expression] = {
+    val filterAttrs = V2ExpressionUtils.resolveDataSourceRuntimeFilterRefs(
+      scan.filterAttributes(),
+      output,
+      "filterAttributes()",
+      scan.getClass.getName)
+    runtimeFilters
+      .filter(f => f.references.subsetOf(filterAttrs))
+      .filter(isPushablePartitionFilter(_, includeSubquery = true))
+      .flatMap(unwrapRuntimeFilterExpression)
+      .filterNot(_ == Literal.TrueLiteral)
+  }
+
+  /**
+   * Whether `scan` gets `planInputPartitionsWithRuntimeFilters` from the 
default in
+   * [[SupportsRuntimeCatalystFiltering]], such as a class compiled against 
Spark 4.3 or a Java
+   * class implementing the trait directly that overrides only `filter`. A 
Scala class that mixes in
+   * the trait gets a forwarder, so it and its subclasses take the general 
path.
+   */
+  private def implementsOnlyDeprecatedFilter(scan: 
SupportsRuntimeCatalystFiltering): Boolean =
+    scan.getClass.getMethod("planInputPartitionsWithRuntimeFilters", 
classOf[Array[Expression]])

Review Comment:
   **26. [Low] `implementsOnlyDeprecatedFilter` can fail a query with 
`NoClassDefFoundError`**
   
   `getMethod` builds the public methods of each class it searches, starting 
with the scan's own class, and that resolves every type in their signatures. So 
a `SupportsRuntimeCatalystFiltering` scan with a public method over a type that 
is missing at runtime, e.g. one from an optional dependency, now fails every 
query that has a runtime filter on it with `NoClassDefFoundError` at L242 or 
L356, while 4.3.0 called `filter` without reflection and ran the query. The 
lookup also runs again on every execution of every such scan node. 
`CodeGenerator.leavesGenCodeToExpression` (CodeGenerator.scala:2929-2936) makes 
the same inherited-default check behind a `ClassValue` cache and `catch { case 
_: LinkageError => false }`. Could this do the same? With `false` such a scan 
takes the general path, whose default still calls `filter`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -226,22 +227,29 @@ class DataSourceV2Strategy(session: SparkSession) extends 
Strategy with Predicat
       }
       // Screen with the same test pushdown applies, or a filter dropped here 
and rejected there
       // would be evaluated nowhere.
-      val fullyPushedRuntimeFilters = scalarSubqueryFilters.filter { f =>
-        f.references.subsetOf(relation.fullyPushedRuntimeFilterAttrs) &&
-          PushDownUtils.isPushablePartitionFilter(f, includeSubquery = true)
-      }
+      val fullyPushedRuntimeFilters = scalarSubqueryFilters.filter(
+        PushDownUtils.isFullyPushedRuntimeFilter(_, 
relation.fullyPushedRuntimeFilterAttrs))
       // dynamicFilters need no such check: a DynamicPruningSubquery over a 
non-deterministic
       // filtering plan is itself non-deterministic, so 
CleanupDynamicPruningFilters has already
       // rewritten it to TrueLiteral by the time we get here.
+      // A Catalyst runtime-filtering scan only applies a filter over 
attributes it declared, so a
+      // DPP filter over any other column is dropped here rather than planned 
for nothing. One
+      // PartitionPruning inserted over a union lands in every branch, 
including one that cannot
+      // use it; the join it was derived from still applies it.
+      val usableDynamicFilters = relation.scan match {
+        case _: SupportsRuntimeCatalystFiltering =>
+          
dynamicFilters.filter(_.references.subsetOf(relation.runtimeFilterAttrs))

Review Comment:
   **31. [Nit] An unpartitioned branch of a union gets no runtime filter when 
the DPP filter is kept**
   
   When `PartitionPruning` inserts a DPP filter over a union of a partitioned 
V2 file table and an unpartitioned one, `InjectRuntimeFilter` finds it on the 
key and adds no bloom filter (InjectRuntimeFilter.scala:423), the filter then 
lands in both branches (L236-238), and this line drops the copy in the 
unpartitioned branch. When that DPP filter is kept, e.g. as its own subquery 
with `reuseBroadcastOnly=false`, the unpartitioned branch gets no runtime 
filter, while on master, where a V2 file scan gets no DPP filter, the bloom 
filter goes into both branches where its checks pass. SPARK-60702 does not 
change this, since such a DPP filter counts as kept. The results are the same. 
Could the description list it with the bloom-filter bullet?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala:
##########
@@ -233,6 +233,10 @@ object PartitionPruning extends Rule[LogicalPlan] with 
PredicateHelper with Join
 
   private def hasSelectivePredicate(plan: LogicalPlan): Boolean = plan.exists {
     case f: Filter => isLikelySelective(f.condition)
+    // A built-in V2 file scan takes its partition filters in without leaving 
a `Filter` above it,

Review Comment:
   **29. [Low] A V2 file dim filtered only on its partition column cannot be a 
bloom filter's creation side**
   
   Item 12 fixed this for DPP, but 
`InjectRuntimeFilter.extractSelectiveFilterOverScan` makes the same check for a 
bloom filter's creation side: it counts a selective predicate only at a 
`Filter` (InjectRuntimeFilter.scala:140) and accepts a leaf only after one 
(L237). A V2 file dim filtered only on its partition column keeps no `Filter` 
above its scan, so it cannot be the creation side, while V1 can, since 
`PruneFileSourcePartitions` keeps the `Filter`. This already holds on master 
for any DSv2 dim, but it matters more after merging master. With SPARK-60702, a 
key whose `onlyInBroadcast` DPP filter physical planning drops now gets a bloom 
filter when this path finds a creation side. So with the default 
`reuseBroadcastOnly` on a shuffle join, the query of "DPP for a dimension 
filtered only on its partition column, as in V1" would get a bloom filter in 
V1, where its size checks pass, and no runtime filter at all in V2. 
`InjectRuntimeFilter` is in sql/catalyst and cannot see this trait
 , but it could read `DataSourceV2ScanRelation.pushedFilters`, as the check 
below does. InjectRuntimeFilter.scala is outside this diff, so I left this 
here. Could it be a follow-up JIRA, or listed in the description next to the 
bloom-filter bullet?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {
+
+  /**
+   * The file index listing and its packing, each taken once per scan so 
planning and execution
+   * read one snapshot. A `lazy val` would compute them while holding the 
scan's monitor.
+   */
+  private val listing = new TransientBestEffortLazyVal(() =>
+    fileIndex.listFiles(partitionFilters, dataFilters))
+
+  private val allPartitions = new TransientBestEffortLazyVal(() => 
filePartitions(listing()))
+
+  // Final so that a subclass of a built-in scan cannot change one read path 
without the other.
+  final override protected def partitions: Seq[FilePartition] = allPartitions()
+
+  final override def planInputPartitions(): Array[InputPartition] = 
allPartitions().toArray
+
+  /**
+   * The read partition columns that `readSchema()` still exposes; a 
pushed-down aggregate keeps
+   * only the ones it groups by. None is declared fully pushed, so a scalar 
subquery filter over
+   * them is evaluated again above the scan.
+   */
+  override def filterAttributes(): Array[NamedReference] = {
+    val readFields = readSchema().fieldNames.map(normalizePartitionName).toSet
+    readPartitionSchema.fieldNames
+      .filter(name => readFields.contains(normalizePartitionName(name)))
+      .map(FieldReference.column)
+  }
+
+  /**
+   * Keeps the listed partition directories whose values satisfy 
`expressions`, then splits and
+   * packs their files for their own size, as V1 does for 
`dynamicallySelectedPartitions`.
+   *
+   * A filter that fails to evaluate on a directory keeps it, since the join 
or the `FilterExec`
+   * above the scan applies the filter again.
+   */
+  override def planInputPartitionsWithRuntimeFilters(

Review Comment:
   **28. [Nit] `planInputPartitionsWithRuntimeFilters` is still open to a 
subclass**
   
   L50 makes `partitions` and `planInputPartitions()` final "so that a subclass 
of a built-in scan cannot change one read path without the other", and the 
description says such a subclass "can change the packing only by overriding 
`filePartitions`". This method is not final, though, so a subclass can still 
change the runtime-filtered path alone. Could it be `final override def` as 
well? Nothing in-tree overrides it on a built-in scan.
   
   The two finals are also a binary change for such a subclass. scalac emits 
them as `final` forwarders in each built-in scan class, so a subclass built 
against 4.3.0 that overrides `planInputPartitions()` now fails at class loading 
("overrides final method"), and a recompiled one fails to compile. MiMa does 
not report it, since `org.apache.spark.sql.execution.*` is excluded 
(MimaExcludes.scala:191). Could the user-facing section mention both finals?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {
+
+  /**
+   * The file index listing and its packing, each taken once per scan so 
planning and execution
+   * read one snapshot. A `lazy val` would compute them while holding the 
scan's monitor.
+   */
+  private val listing = new TransientBestEffortLazyVal(() =>
+    fileIndex.listFiles(partitionFilters, dataFilters))
+
+  private val allPartitions = new TransientBestEffortLazyVal(() => 
filePartitions(listing()))
+
+  // Final so that a subclass of a built-in scan cannot change one read path 
without the other.
+  final override protected def partitions: Seq[FilePartition] = allPartitions()
+
+  final override def planInputPartitions(): Array[InputPartition] = 
allPartitions().toArray
+
+  /**
+   * The read partition columns that `readSchema()` still exposes; a 
pushed-down aggregate keeps
+   * only the ones it groups by. None is declared fully pushed, so a scalar 
subquery filter over
+   * them is evaluated again above the scan.
+   */
+  override def filterAttributes(): Array[NamedReference] = {

Review Comment:
   **24. [Low] A group-based row-level operation on a built-in scan now gets a 
group filter that the scan does not record**
   
   With the partition columns declared here, 
`RowLevelOperationRuntimeGroupFiltering` injects a group filter into a 
group-based (`ReplaceData`) operation whose scan is a built-in file scan, since 
it checks only `runtimeFilterAttrs.nonEmpty` 
(RowLevelOperationRuntimeGroupFiltering.scala:67-72). The read then gets only 
the partition directories that have a matching row. The trait asks a 
group-based scan whose write replaces the groups that were read to record what 
each call returned (SupportsRuntimeCatalystFiltering.scala:96-98), but this one 
records nothing, and `planInputPartitions()` still returns every directory. So 
a connector whose `SupportsRowLevelOperations` builds its scan from a built-in 
one, and whose write replaces the groups that scan plans, e.g. from 
`planInputPartitions()` or the file index, would now rewrite only the matching 
partitions of `DELETE FROM t WHERE x = 5` but replace all of them, which loses 
the rows of the others. On master the scan has no runtime filter at
 tributes, so the rule does nothing and the read covers every group. No in-tree 
`FileTable` supports row-level operations, and I did not find such a connector 
on GitHub, so this is a contract gap rather than a known break.
   
   Could `canInjectGroupFilters` skip a `RuntimePartitionPruningFileScan`, as 
on master, or could the scaladoc at L67-73 say that the scan does not record 
what each call returned, so it must not back such a write?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -293,11 +347,21 @@ object PushDownUtils extends Logging {
       output: Seq[AttributeReference],
       keyedPartitioning: Option[KeyedPartitioning],
       originalPartitions: => Seq[InputPartition]): Seq[Option[InputPartition]] 
= {
-    val filtered = pushRuntimeFilters(scan, runtimeFilters, table, output)
+    val (filtered, newPartitions) = scan match {
+      // A Catalyst scan is asked even when screening leaves no filter, unless 
it also implements
+      // SupportsRuntimeV2Filtering, which pushRuntimeFilters rejects, or 
declares only the
+      // deprecated `filter`, which pushRuntimeFilters calls as Spark 4.3 did.
+      case catalystScan: SupportsRuntimeCatalystFiltering
+          if runtimeFilters.nonEmpty && 
!catalystScan.isInstanceOf[SupportsRuntimeV2Filtering] &&
+            !implementsOnlyDeprecatedFilter(catalystScan) =>

Review Comment:
   **25. [Low] Follow-up to my item 10: a Scala scan recompiled against this 
trait still plans again when screening removes every runtime filter**
   
   This guard sends a scan to `pushRuntimeFilters` only when its classes do not 
declare `planInputPartitionsWithRuntimeFilters`, i.e. one built against 4.3.0 
or a Java one (L293-298). A Scala scan recompiled against this trait gets a 
mixin forwarder for it, so one that still overrides only `filter`, which the 
description says needs only `override` on it, takes this branch, as 
PushDownUtils.scala:182-185 says. When screening removes every runtime filter, 
the default then skips `filter` but plans again through 
`toBatch.planInputPartitions()` 
(SupportsRuntimeCatalystFiltering.scala:120-121) instead of reading 
`originalPartitions`. That is item 10 again for every such scan recompiled from 
now on: one more planning per scan node on a sort-merge join under the default 
DPP settings, and, for a scan instance that another node already narrowed 
through `filter`, a read of the narrowed set, the SPARK-60752 class from item 
14.
   
   The test at DataSourceV2CatalystRuntimeFilterSuite.scala:568-579 cannot see 
this. `LegacyFilterScan` is such a recompiled Scala scan, although its doc says 
"A scan written against Spark 4.3" (L965), and the test passes 
`scan.toBatch.planInputPartitions()` as `originalPartitions`, so a new plan and 
the planned partitions agree, and L576 reads as if the scan took the 4.3.0 
route.
   
   Reflection sees a forwarder as a declaration, so I do not see a check here 
that tells the two apart. Could the scaladoc of `filter` 
(SupportsRuntimeCatalystFiltering.scala:124-128) and the description say that 
such a scan should implement `planInputPartitionsWithRuntimeFilters`, since it 
otherwise plans again when screening removes every runtime filter, and could 
L576 and L965 say that this Scala fixture takes the general path?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {

Review Comment:
   **27. [Low] A subclass of a built-in scan that adds 
`SupportsRuntimeV2Filtering` now fails every query**
   
   The six built-in scans are case classes that are not final, and through this 
trait they now implement `SupportsRuntimeCatalystFiltering`. A subclass that 
adds `SupportsRuntimeFiltering` or `SupportsRuntimeV2Filtering`, e.g. `class 
MyScan(...) extends ParquetScan(...) with SupportsRuntimeV2Filtering`, then 
implements both, and even a plain `SELECT` fails with INTERNAL_ERROR, since 
DataSourceV2Strategy.scala:218 reads `runtimeFilterAttrs`, which calls 
`checkRuntimeFilteringInterfaces()` (DataSourceV2Relation.scala:225-229 and 
392-400). On master such a subclass works, and its runtime filters reach its 
own `filter`. The per-format opt-in keeps a `FileScan` subclass as on master, 
but not a subclass of a built-in scan. As with item 9, I did not find one on 
GitHub, so this is a contract gap rather than a known break. Could the 
user-facing section of the description say so, next to the finals of item 28?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {
+
+  /**
+   * The file index listing and its packing, each taken once per scan so 
planning and execution
+   * read one snapshot. A `lazy val` would compute them while holding the 
scan's monitor.
+   */
+  private val listing = new TransientBestEffortLazyVal(() =>

Review Comment:
   **30. [Low] Every built-in file scan keeps its file listing for the plan's 
lifetime**
   
   `listing` holds the `Seq[PartitionDirectory]` from `fileIndex.listFiles` for 
as long as the scan lives, on every built-in file scan, including an 
unpartitioned table, a scan that reads no partition column and a plan without 
runtime filters, none of which reads it again after `allPartitions`. 
`listFiles` wraps each file in a new `FileStatusWithMetadata` per call 
(FileIndex.scala:103-104), so this keeps an object per file on top of 
`allPartitions`, tens of MB for a table with a million files, for as long as a 
`QueryExecution` or a cached plan holds the scan. After 
`InMemoryFileIndex.refresh()`, a retained scan also keeps the previous 
listing's `FileStatus`es alive. The merge-base drops the listing after packing. 
Could `listing` be memoized only when `filterAttributes()` is not empty, and 
otherwise stay local to `allPartitions`?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {
+
+  /**
+   * The file index listing and its packing, each taken once per scan so 
planning and execution
+   * read one snapshot. A `lazy val` would compute them while holding the 
scan's monitor.
+   */
+  private val listing = new TransientBestEffortLazyVal(() =>
+    fileIndex.listFiles(partitionFilters, dataFilters))
+
+  private val allPartitions = new TransientBestEffortLazyVal(() => 
filePartitions(listing()))
+
+  // Final so that a subclass of a built-in scan cannot change one read path 
without the other.
+  final override protected def partitions: Seq[FilePartition] = allPartitions()
+
+  final override def planInputPartitions(): Array[InputPartition] = 
allPartitions().toArray
+
+  /**
+   * The read partition columns that `readSchema()` still exposes; a 
pushed-down aggregate keeps
+   * only the ones it groups by. None is declared fully pushed, so a scalar 
subquery filter over
+   * them is evaluated again above the scan.
+   */
+  override def filterAttributes(): Array[NamedReference] = {
+    val readFields = readSchema().fieldNames.map(normalizePartitionName).toSet
+    readPartitionSchema.fieldNames
+      .filter(name => readFields.contains(normalizePartitionName(name)))
+      .map(FieldReference.column)
+  }
+
+  /**
+   * Keeps the listed partition directories whose values satisfy 
`expressions`, then splits and
+   * packs their files for their own size, as V1 does for 
`dynamicallySelectedPartitions`.
+   *
+   * A filter that fails to evaluate on a directory keeps it, since the join 
or the `FilterExec`
+   * above the scan applies the filter again.
+   */
+  override def planInputPartitionsWithRuntimeFilters(
+      expressions: Array[Expression]): Array[InputPartition] = {
+    if (expressions.isEmpty) return allPartitions().toArray
+    // Directory values follow the table's partition schema, not the read one, 
and bind as V1
+    // binds them: with that schema's types, nullable.
+    val partitionSchema = fileIndex.partitionSchema
+    val fieldIndex = partitionSchema.fieldNames.zipWithIndex
+      .map { case (name, i) => normalizePartitionName(name) -> i }.toMap
+    val predicates = expressions.toSeq.map { e =>
+      Predicate.createInterpreted(e.transform {
+        case a: Attribute =>
+          // PushDownUtils passes only filters over `filterAttributes()`.
+          val i = fieldIndex.getOrElse(normalizePartitionName(a.name),
+            throw SparkException.internalError("A file scan can only apply a 
runtime filter " +
+              s"over its partition columns 
${partitionSchema.fieldNames.mkString("[", ", ", "]")}" +
+              s", got $e"))
+          BoundReference(i, partitionSchema(i).dataType, nullable = true)
+      })
+    }
+    val all = listing()
+    val kept = all.filter { dir =>
+      predicates.forall { predicate =>
+        try predicate.eval(dir.values) catch { case NonFatal(_) => true }
+      }
+    }
+    if (kept.length == all.length) allPartitions().toArray else 
filePartitions(kept).toArray

Review Comment:
   **32. [Nit] A scan that DPP prunes still packs every file at planning**
   
   When a runtime filter keeps few directories, the node packs twice: planning 
packs every listed file through `allPartitions`, and this line packs the 
survivors. Planning has to, because `FileScan` keeps the default 
`columnarSupportMode()`, `PARTITION_DEFINED`, so `supportsColumnar` 
(DataSourceV2ScanExecBase.scala:200-210), which DataSourceV2Strategy.scala:275 
asks, reads `inputPartitions`. With DPP keeping 10 of 10,000 partitions of a 
table with a million files, the driver splits, sorts and packs a million files 
at planning only to learn one boolean. V1 packs only 
`dynamicallySelectedPartitions`. The merge-base pays the same at planning, so 
this is not a regression. Could a follow-up have the built-in scans report 
`columnarSupportMode()` themselves? Their reader factories decide columnar 
reads from the schema and the conf, not from the partition.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -230,48 +238,92 @@ object PushDownUtils extends Logging {
 
         translatedFiltersPushed || partPredicatesPushed
 
-      case catalystScan: SupportsRuntimeCatalystFiltering if 
pushableFilters.nonEmpty =>
-        // A runtime filter is normally evaluated twice: the source prunes 
with it, and the
-        // FilterExec above the scan applies it again. The two have to agree, 
so this screen
-        // pushes only predicates the source can be trusted to evaluate in 
Spark's place.
-        //
-        // But pushing a non-deterministic one would let the source prune on 
its own coin flip and
-        // Spark flip again for the rows that survive, so we handle that 
specially.
-        //
-        // Not pushing a filter is safe only while its FilterExec still 
evaluates it. A fully
-        // pushed filter has none, so the source is its only evaluator. That 
is why
-        // DataSourceV2Strategy deletes a FilterExec at planning time only for 
a filter we push
-        // below, and we use the same method (isPushablePartitionFilter) to 
determine if we
-        // should push it.
-        //
-        // A DPP filter degrades to TrueLiteral once its subquery is pruned 
away, so it matches
-        // every row. The V2 path above drops these implicitly, since 
translateRuntimeFilterV2
-        // returns None; here we push Catalyst expressions directly, so we 
remove them explicitly.
-        val catalystFilters = pushableFilters
-          .flatMap(unwrapRuntimeFilterExpression)
-          .filterNot(_ == Literal.TrueLiteral)
-        if (catalystFilters.nonEmpty) {
-          catalystScan.filter(catalystFilters.toArray)
-          true
-        } else {
-          false
+      case catalystScan: SupportsRuntimeCatalystFiltering
+          if runtimeFilters.nonEmpty && 
implementsOnlyDeprecatedFilter(catalystScan) =>
+        filterWithDeprecatedMethod(catalystScan, runtimeFilters, output)
+
+      case catalystScan: SupportsRuntimeCatalystFiltering =>
+        // Resolved only when a filter could be fully pushed, so a node with 
no such filter does
+        // not ask the scan.
+        lazy val fullyPushedAttrs = 
V2ExpressionUtils.resolveDataSourceRuntimeFilterRefs(
+          catalystScan.fullyPushedFilterAttributes(),
+          output,
+          "fullyPushedFilterAttributes()",
+          catalystScan.getClass.getName)
+        val evaluatedOnlyByScan =
+          runtimeFilters.filter(isFullyPushedRuntimeFilter(_, 
fullyPushedAttrs))
+        if (evaluatedOnlyByScan.nonEmpty) {
+          throw SparkException.internalError(
+            s"${scan.getClass.getName} fully evaluates the runtime filters " +
+            s"${evaluatedOnlyByScan.mkString(", ")}, so they must go through " 
+
+            "PushDownUtils.replanWithRuntimeFilters, not pushRuntimeFilters.")
         }
+        false
 
       case _ =>
         false
     }
   }
 
+  /**
+   * Screens runtime filters for a [[SupportsRuntimeCatalystFiltering]] scan 
and unwraps them to the
+   * Catalyst predicates the scan plans with, untranslated.
+   *
+   * A filter is kept only if it references attributes the scan declared 
filterable and passes
+   * `isPushablePartitionFilter`, the check `DataSourceV2Strategy` uses to 
drop a pushed filter's
+   * `FilterExec`. A DPP filter degraded to true is removed, as the V2 path 
does by not translating
+   * it.
+   */
+  private def catalystRuntimeFilters(
+      scan: SupportsRuntimeCatalystFiltering,
+      runtimeFilters: Seq[Expression],
+      output: Seq[AttributeReference]): Seq[Expression] = {
+    val filterAttrs = V2ExpressionUtils.resolveDataSourceRuntimeFilterRefs(
+      scan.filterAttributes(),
+      output,
+      "filterAttributes()",
+      scan.getClass.getName)
+    runtimeFilters
+      .filter(f => f.references.subsetOf(filterAttrs))

Review Comment:
   **36. [Nit] The `filterAttributes()` screen at execution no longer drops 
anything Spark planned**
   
   Since 2acbef9 the strategy keeps only the DPP filters over 
`relation.runtimeFilterAttrs` for a Catalyst scan 
(DataSourceV2Strategy.scala:239-243) and routes only scalar subquery filters 
over them (L218-224). `BatchScanExec` is built only at 
DataSourceV2Strategy.scala:269, and the copies that DPP planning makes keep the 
same columns, so this `subsetOf` cannot drop a filter of a node Spark planned. 
It only guards a node built elsewhere, at the cost of resolving 
`filterAttributes()` again on each execution. The `true` removal at L290 is 
still needed. Could the scaladoc at L268-276 say that the strategy already 
drops these? Alternatively, `CleanupDynamicPruningFilters` could drop a DPP 
filter whose `pruningKey.references` are not in `r.runtimeFilterAttrs` for 
every `DataSourceV2ScanRelation`, which would also keep it from the `filter` of 
a `SupportsRuntimeV2Filtering` branch, which gets it on master.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/CleanupDynamicPruningFilters.scala:
##########
@@ -79,9 +90,14 @@ object CleanupDynamicPruningFilters extends 
Rule[LogicalPlan] with PredicateHelp
       case p @ NodeWithOnlyDeterministicProjectAndFilter(
           HiveTableRelation(_, _, _, _, _)) =>
         removeUnnecessaryDynamicPruningSubquery(p)
-      case p @ NodeWithOnlyDeterministicProjectAndFilter(
-          _: DataSourceV2ScanRelation) =>
-        removeUnnecessaryDynamicPruningSubquery(p)
+      // Only a built-in file scan is known to prune what it plans with a 
partition filter it fully
+      // took. Another DSv2 source may apply such a filter row by row and 
leave the pruning to DPP.

Review Comment:
   **35. [Nit] The reason at L93-94 does not hold for the `Filter` path of the 
same rule**
   
   L93-94 keep the pushed path to built-in file scans because another DSv2 
source "may apply such a filter row by row and leave the pruning to DPP". But 
the `Filter` path of this rule (L62-65), which master already applies to every 
`DataSourceV2ScanRelation`, drops the DPP filter for an equality left in a 
`Filter` above the scan, which the source did not take and so cannot prune by. 
The rule thus trusts a DSv2 source less when it took the equality than when it 
did not. Keeping other sources as on master after #60053 is a fine reason by 
itself; could the comment say that instead?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PartitionPruning.scala:
##########
@@ -233,6 +233,10 @@ object PartitionPruning extends Rule[LogicalPlan] with 
PredicateHelper with Join
 
   private def hasSelectivePredicate(plan: LogicalPlan): Boolean = plan.exists {
     case f: Filter => isLikelySelective(f.condition)
+    // A built-in V2 file scan takes its partition filters in without leaving 
a `Filter` above it,
+    // where V1 keeps one.
+    case r: DataSourceV2ScanRelation if 
r.scan.isInstanceOf[RuntimePartitionPruningFileScan] =>

Review Comment:
   **34. [Nit] Follow-up to my item 12: the filtering side need not be a 
built-in file scan**
   
   The `isInstanceOf[RuntimePartitionPruningFileScan]` here came from my 
suggestion in item 12, but the filtering side does not need it. This only asks 
whether a likely selective predicate restricts the dim, and a predicate that 
any DSv2 source took in fully, leaving no `Filter`, does so just as one in a 
`Filter` does. The scope in `CleanupDynamicPruningFilters` is about what the 
pruned scan guarantees, which does not apply here. So a dim on a connector scan 
with a fully pushed `region = 'EU'` still gets no DPP, as on master. Could this 
be `case r: DataSourceV2ScanRelation => 
r.pushedFilters.exists(isLikelySelective)`? It is a benefit heuristic, so the 
results stay the same, though with `reuseBroadcastOnly=false` it can add DPP 
subqueries for such dims.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -226,22 +227,29 @@ class DataSourceV2Strategy(session: SparkSession) extends 
Strategy with Predicat
       }
       // Screen with the same test pushdown applies, or a filter dropped here 
and rejected there
       // would be evaluated nowhere.
-      val fullyPushedRuntimeFilters = scalarSubqueryFilters.filter { f =>
-        f.references.subsetOf(relation.fullyPushedRuntimeFilterAttrs) &&
-          PushDownUtils.isPushablePartitionFilter(f, includeSubquery = true)
-      }
+      val fullyPushedRuntimeFilters = scalarSubqueryFilters.filter(
+        PushDownUtils.isFullyPushedRuntimeFilter(_, 
relation.fullyPushedRuntimeFilterAttrs))
       // dynamicFilters need no such check: a DynamicPruningSubquery over a 
non-deterministic
       // filtering plan is itself non-deterministic, so 
CleanupDynamicPruningFilters has already
       // rewritten it to TrueLiteral by the time we get here.
+      // A Catalyst runtime-filtering scan only applies a filter over 
attributes it declared, so a
+      // DPP filter over any other column is dropped here rather than planned 
for nothing. One
+      // PartitionPruning inserted over a union lands in every branch, 
including one that cannot
+      // use it; the join it was derived from still applies it.
+      val usableDynamicFilters = relation.scan match {
+        case _: SupportsRuntimeCatalystFiltering =>
+          
dynamicFilters.filter(_.references.subsetOf(relation.runtimeFilterAttrs))
+        case _ => dynamicFilters
+      }
       // A group-based read keeps only the group filter, for the reason above. 
PartitionPruning
       // plans a DynamicPruningSubquery from the join of one read, so it is a 
filter of one read
       // too. With an IN subquery it lands on the read of the updated rows of 
an UPDATE. With a
       // NOT EXISTS subquery the negated condition becomes a semi join, so it 
lands on the read
       // of the rows to copy over.
       val runtimeFilters = if (groupBasedRead) {
-        dynamicFilters.filterNot(_.isInstanceOf[DynamicPruningSubquery])
+        usableDynamicFilters.filterNot(_.isInstanceOf[DynamicPruningSubquery])
       } else {
-        dynamicFilters ++ scalarSubqueryFilters
+        usableDynamicFilters ++ scalarSubqueryFilters

Review Comment:
   **33. [Nit] A scalar subquery filter over a partition column is planned 
twice**
   
   Since e721ae7 the file scans declare no partition column fully pushed (item 
2), so `part = (SELECT ...)` goes into `runtimeFilters` here and also stays in 
the `Filter` above (L265), as for a `SupportsRuntimeV2Filtering` scan on 
master. Under the defaults, subquery reuse runs it once, but with 
`spark.sql.execution.reuseSubquery=false` it runs twice, and if its source 
changes in between, the scan prunes with one value while the `Filter` keeps 
another. V1 and the merge-base evaluate it once. That is a fair price for item 
2, but could the user-facing section say that such a filter now shows up both 
in `RuntimeFilters` and in the `Filter` above the scan?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {
+
+  /**
+   * The file index listing and its packing, each taken once per scan so 
planning and execution
+   * read one snapshot. A `lazy val` would compute them while holding the 
scan's monitor.
+   */
+  private val listing = new TransientBestEffortLazyVal(() =>
+    fileIndex.listFiles(partitionFilters, dataFilters))
+
+  private val allPartitions = new TransientBestEffortLazyVal(() => 
filePartitions(listing()))
+
+  // Final so that a subclass of a built-in scan cannot change one read path 
without the other.
+  final override protected def partitions: Seq[FilePartition] = allPartitions()
+
+  final override def planInputPartitions(): Array[InputPartition] = 
allPartitions().toArray
+
+  /**
+   * The read partition columns that `readSchema()` still exposes; a 
pushed-down aggregate keeps
+   * only the ones it groups by. None is declared fully pushed, so a scalar 
subquery filter over
+   * them is evaluated again above the scan.
+   */
+  override def filterAttributes(): Array[NamedReference] = {
+    val readFields = readSchema().fieldNames.map(normalizePartitionName).toSet
+    readPartitionSchema.fieldNames
+      .filter(name => readFields.contains(normalizePartitionName(name)))
+      .map(FieldReference.column)
+  }
+
+  /**
+   * Keeps the listed partition directories whose values satisfy 
`expressions`, then splits and
+   * packs their files for their own size, as V1 does for 
`dynamicallySelectedPartitions`.
+   *
+   * A filter that fails to evaluate on a directory keeps it, since the join 
or the `FilterExec`
+   * above the scan applies the filter again.
+   */
+  override def planInputPartitionsWithRuntimeFilters(
+      expressions: Array[Expression]): Array[InputPartition] = {
+    if (expressions.isEmpty) return allPartitions().toArray
+    // Directory values follow the table's partition schema, not the read one, 
and bind as V1
+    // binds them: with that schema's types, nullable.
+    val partitionSchema = fileIndex.partitionSchema
+    val fieldIndex = partitionSchema.fieldNames.zipWithIndex
+      .map { case (name, i) => normalizePartitionName(name) -> i }.toMap
+    val predicates = expressions.toSeq.map { e =>
+      Predicate.createInterpreted(e.transform {
+        case a: Attribute =>
+          // PushDownUtils passes only filters over `filterAttributes()`.
+          val i = fieldIndex.getOrElse(normalizePartitionName(a.name),
+            throw SparkException.internalError("A file scan can only apply a 
runtime filter " +
+              s"over its partition columns 
${partitionSchema.fieldNames.mkString("[", ", ", "]")}" +
+              s", got $e"))
+          BoundReference(i, partitionSchema(i).dataType, nullable = true)
+      })
+    }
+    val all = listing()
+    val kept = all.filter { dir =>
+      predicates.forall { predicate =>
+        try predicate.eval(dir.values) catch { case NonFatal(_) => true }

Review Comment:
   **38. [Nit] A runtime filter that fails on a directory leaves no log**
   
   Keeping the directory is right, but nothing records that the filter failed, 
so a user who sees no pruning has nothing to go on. 
`PartitionPredicateImpl.eval` keeps a partition for the same reason and logs a 
warning with the expression and the exception 
(PartitionPredicateImpl.scala:86-95). Could this log one too, e.g. once per 
call rather than per directory? This is also another binder of directory values 
next to `PartitioningAwareFileIndex.prunePartitions`, 
`FileSourceScanExec.dynamicallySelectedPartitions` and 
`PartitionPredicateImpl`. None of them fits as is, so a shared one could wait 
for a follow-up.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RuntimePartitionPruningFileScan.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.v2
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, BoundReference, 
Expression, Predicate}
+import org.apache.spark.sql.connector.expressions.{FieldReference, 
NamedReference}
+import org.apache.spark.sql.connector.read.InputPartition
+import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.internal.connector.SupportsRuntimeCatalystFiltering
+import org.apache.spark.util.TransientBestEffortLazyVal
+
+/**
+ * A [[FileScan]] that prunes its partition directories with the runtime 
filters Spark derives over
+ * its partition columns (SPARK-30628). The built-in file formats mix this in.
+ *
+ * Both read paths start from one listing of the file index and split and pack 
the files they
+ * keep through `filePartitions`, so `partitions` and `planInputPartitions()` 
are final here. A
+ * scan that reports a partitioning or an ordering should not mix this in.
+ */
+trait RuntimePartitionPruningFileScan extends FileScan with 
SupportsRuntimeCatalystFiltering {
+
+  /**
+   * The file index listing and its packing, each taken once per scan so 
planning and execution
+   * read one snapshot. A `lazy val` would compute them while holding the 
scan's monitor.
+   */
+  private val listing = new TransientBestEffortLazyVal(() =>
+    fileIndex.listFiles(partitionFilters, dataFilters))
+
+  private val allPartitions = new TransientBestEffortLazyVal(() => 
filePartitions(listing()))
+
+  // Final so that a subclass of a built-in scan cannot change one read path 
without the other.
+  final override protected def partitions: Seq[FilePartition] = allPartitions()
+
+  final override def planInputPartitions(): Array[InputPartition] = 
allPartitions().toArray
+
+  /**
+   * The read partition columns that `readSchema()` still exposes; a 
pushed-down aggregate keeps
+   * only the ones it groups by. None is declared fully pushed, so a scalar 
subquery filter over
+   * them is evaluated again above the scan.
+   */
+  override def filterAttributes(): Array[NamedReference] = {
+    val readFields = readSchema().fieldNames.map(normalizePartitionName).toSet
+    readPartitionSchema.fieldNames
+      .filter(name => readFields.contains(normalizePartitionName(name)))
+      .map(FieldReference.column)
+  }
+
+  /**
+   * Keeps the listed partition directories whose values satisfy 
`expressions`, then splits and
+   * packs their files for their own size, as V1 does for 
`dynamicallySelectedPartitions`.
+   *
+   * A filter that fails to evaluate on a directory keeps it, since the join 
or the `FilterExec`
+   * above the scan applies the filter again.
+   */
+  override def planInputPartitionsWithRuntimeFilters(
+      expressions: Array[Expression]): Array[InputPartition] = {
+    if (expressions.isEmpty) return allPartitions().toArray
+    // Directory values follow the table's partition schema, not the read one, 
and bind as V1
+    // binds them: with that schema's types, nullable.
+    val partitionSchema = fileIndex.partitionSchema
+    val fieldIndex = partitionSchema.fieldNames.zipWithIndex
+      .map { case (name, i) => normalizePartitionName(name) -> i }.toMap
+    val predicates = expressions.toSeq.map { e =>
+      Predicate.createInterpreted(e.transform {
+        case a: Attribute =>
+          // PushDownUtils passes only filters over `filterAttributes()`.
+          val i = fieldIndex.getOrElse(normalizePartitionName(a.name),
+            throw SparkException.internalError("A file scan can only apply a 
runtime filter " +
+              s"over its partition columns 
${partitionSchema.fieldNames.mkString("[", ", ", "]")}" +
+              s", got $e"))
+          BoundReference(i, partitionSchema(i).dataType, nullable = true)
+      })
+    }
+    val all = listing()
+    val kept = all.filter { dir =>
+      predicates.forall { predicate =>
+        try predicate.eval(dir.values) catch { case NonFatal(_) => true }
+      }
+    }
+    if (kept.length == all.length) allPartitions().toArray else 
filePartitions(kept).toArray
+  }
+
+  // Read when the scan is built, as `FileScan` does.
+  private val isCaseSensitive = conf.caseSensitiveAnalysis
+
+  private def normalizePartitionName(name: String): String = {

Review Comment:
   **37. [Nit] Following up on item 6: a helper under a new name avoids the 
clash with Kyuubi**
   
   Your reply in item 6 is right that a `protected` or package-private 
`normalizeName` would clash with Kyuubi's `private def normalizeName`, but that 
comes from reusing the name. A helper under a new name has no such clash, e.g. 
in `FileScan`:
   
   ```scala
   private[v2] def normalizePartitionColumnName(name: String): String = 
normalizeName(name)
   ```
   
   A subclass outside the package neither sees nor overrides it, and as a `def` 
it needs no field in a subclass built against 4.3.0. `filePartitions` and this 
trait would then share one rule again, instead of two copies of the build-time 
`conf.caseSensitiveAnalysis` (L102-107 and FileScan.scala:217-225).



-- 
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]

Reply via email to