This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new f633033db3d7 [SPARK-58207][SQL] Skip pushdown for nondeterministic V2 
filters
f633033db3d7 is described below

commit f633033db3d72bd7c1d4ab547eefb3172731f8f5
Author: Peter Toth <[email protected]>
AuthorDate: Thu Jul 23 23:00:42 2026 +0800

    [SPARK-58207][SQL] Skip pushdown for nondeterministic V2 filters
    
    ### What changes were proposed in this pull request?
    
    The `SupportsPushDownV2Filters` branch of `PushDownUtils.pushFilters` now 
pushes only deterministic filters to the data source; nondeterministic filters 
are kept as post-scan filters and evaluated by Spark after the scan. This 
mirrors the fix made for `SupportsPushDownCatalystFilters` in #57235 
(SPARK-58112).
    
    ### Why are the changes needed?
    
    Before this change the `SupportsPushDownV2Filters` branch translated and 
pushed every conjunct, and `V2ExpressionBuilder` translates `Rand`, so a 
predicate such as `rand() > 0.5` was pushed to a V2 source.
    
    Pushing a nondeterministic predicate is unsound in general: the data source 
may evaluate it a different number of times or at a different point than Spark, 
and a *partial* push — a predicate used for pruning yet also returned for 
post-scan re-evaluation (e.g. a parquet row group filter, an explicitly 
documented mode of `SupportsPushDownV2Filters#pushedPredicates`) — evaluates 
the predicate twice with different results, which can change query results.
    
    This is the same class of problem that #57235 (SPARK-58112) fixed for 
`SupportsPushDownCatalystFilters`. The other pushdown paths already avoid it:
    - `SupportsPushDownFilters` (V1 `sources.Filter`): nondeterministic 
predicates are untranslatable to `sources.Filter`, so they fall through to 
post-scan.
    - the iterative `PartitionPredicate` second pass: guarded by 
`PushDownUtils.isPushablePartitionFilter` (`f.deterministic`).
    
    Only the `SupportsPushDownV2Filters` first-pass push was left unguarded; 
this closes that gap so all pushdown paths handle nondeterministic filters 
consistently.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Data sources implementing `SupportsPushDownV2Filters` no longer 
receive nondeterministic filters through `pushPredicates`; those filters remain 
in Spark as post-scan filters. For a source that fully enforced such a 
predicate (e.g. JDBC), the predicate is now evaluated by Spark instead of the 
source. Documented in the SQL migration guide.
    
    ### How was this patch tested?
    
    - Added a regression test in `DataSourceV2Suite` ("SPARK-58207: V2 filter 
pushdown skips non-deterministic filters") using 
`AdvancedDataSourceV2WithV2Filter`: asserts that `rand() > 0.5` is not pushed 
to the source and is retained as a post-scan filter (fails on master, passes 
with this change).
    - Updated the `JDBCV2Suite` `RAND(1) < bonus` pushdown test to assert 
`RAND(1) < BONUS` is no longer pushed to H2 (only `BONUS IS NOT NULL` and 
`GREATEST(BONUS, 1100.0) > 1200.0` are pushed) and is retained as a post-scan 
filter.
    - Ran `DataSourceV2Suite` and `DataSourceV2EnhancedPartitionFilterSuite` 
(the latter exercises the iterative `PartitionPredicate` second pass) locally, 
both green.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #57357 from 
peter-toth/SPARK-58207-skip-pushdown-nondeterministic-v2-filters.
    
    Authored-by: Peter Toth <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
    (cherry picked from commit 018ad68ccb8994425e2e79f9aa9cefca1f1b4c87)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 docs/sql-migration-guide.md                        |  1 +
 .../execution/datasources/v2/PushDownUtils.scala   | 11 +++++++++--
 .../spark/sql/connector/DataSourceV2Suite.scala    | 23 ++++++++++++++++++++++
 .../org/apache/spark/sql/jdbc/JDBCV2Suite.scala    |  6 ++++--
 4 files changed, 37 insertions(+), 4 deletions(-)

diff --git a/docs/sql-migration-guide.md b/docs/sql-migration-guide.md
index 9cd5b664e8eb..0db1259e1f97 100644
--- a/docs/sql-migration-guide.md
+++ b/docs/sql-migration-guide.md
@@ -30,6 +30,7 @@ license: |
 - Since Spark 4.3, the Spark Thrift Server rejects setting JVM system 
properties through the `set:system:` session configuration overlay (for 
example, in a JDBC connection string). To restore the previous behavior, set 
`spark.sql.legacy.hive.thriftServer.allowSettingSystemProperties` to `true`.
 - Since Spark 4.3, the adaptive execution rule 
`org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` has been renamed 
to `DemoteBroadcastHashJoin`, which now only demotes broadcast hash joins 
(emitting `NO_BROADCAST_HASH`). Its selection of shuffled hash join over sort 
merge join has moved to a new physical rule gated by 
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` (default 
`true`). If you previously disabled the shuffled-hash-join preference by 
listing `o [...]
 - Since Spark 4.3, the exact `percentile`, `percentile_cont`, and `median` 
aggregate functions (including their `WITHIN GROUP (ORDER BY ...)` forms) 
compute the linear interpolation between two neighboring values as `lower + 
fraction * (higher - lower)` instead of `(1 - fraction) * lower + fraction * 
higher`. The two are equal in exact arithmetic, but the new form is 
monotonically non-decreasing in the requested percentage and avoids a rounding 
error the old form could introduce. As a re [...]
+- Since Spark 4.3, non-deterministic filters (for example predicates involving 
`rand()`) are no longer pushed down to DataSource V2 sources that implement 
`SupportsPushDownV2Filters`; they are evaluated by Spark after the scan 
instead. This prevents a source from evaluating such a predicate a different 
number of times than Spark, or using it for pruning while also returning it for 
post-scan re-evaluation.
 - Since Spark 4.3, the new `COMMENT ON COLUMN ... IS NULL` syntax removes a 
column comment by passing a `null` comment to 
`TableChange.updateColumnComment(String[], String)`, so a `UpdateColumnComment` 
table change may now carry a `null` `newComment()`. Previously `newComment()` 
was always non-null. DataSource V2 catalogs that handle `UpdateColumnComment` 
should null-check `newComment()` and treat `null` as "remove the column 
comment" (mirroring how `UpdateColumnDefaultValue` already car [...]
 - Since Spark 4.3, `hash()` and `xxhash64()` include the `days` field of 
`CalendarInterval` when computing the hash, so their output for interval values 
differs from earlier releases. Previously the codegen path dropped `days`, 
disagreeing with interpreted evaluation.
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
index 6685b28c7dc8..b74a785aafc3 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
@@ -93,6 +93,13 @@ object PushDownUtils extends Logging {
           (postScanFilters ++ untranslatableExprs).toImmutableArraySeq)
 
       case r: SupportsPushDownV2Filters =>
+        // Non-deterministic filters should not be pushed down: a data source 
may evaluate a pushed
+        // predicate a different number of times or at a different point than 
Spark, and a partial
+        // push (a predicate used for pruning yet also returned for post-scan 
re-evaluation, e.g. a
+        // parquet row group filter) would evaluate a non-deterministic 
predicate twice with
+        // different results. Keep them as post-scan filters, matching the
+        // SupportsPushDownCatalystFilters branch below.
+        val (deterministicFilters, nonDeterministicFilters) = 
filters.partition(_.deterministic)
         // Divide the filters into those translatable and untranslatable to 
data source filters.
         // For the translated filters, we will try to push them down to the 
data source,
         // and the data source will return the filters that it cannot 
guarantee to be true
@@ -101,7 +108,7 @@ object PushDownUtils extends Logging {
         val translatedFilters = mutable.ArrayBuffer.empty[Predicate]
         val untranslatableExprs = mutable.ArrayBuffer.empty[Expression]
 
-        for (filterExpr <- filters) {
+        for (filterExpr <- deterministicFilters) {
           val translated =
             DataSourceV2Strategy.translateFilterV2WithMapping(
               filterExpr, Some(translatedFilterToExpr))
@@ -131,7 +138,7 @@ object PushDownUtils extends Logging {
           }
 
         val orderedPostScanFilters = prioritizeFilters(finalPostScanFilters,
-          ExpressionSet(untranslatableExprs))
+          ExpressionSet(untranslatableExprs)) ++ nonDeterministicFilters
         (Right(r.pushedPredicates.toImmutableArraySeq), orderedPostScanFilters)
       case r: SupportsPushDownCatalystFilters =>
         val (deterministicFilters, nonDeterministicFilters) = 
filters.partition(_.deterministic)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
index c6f3c1c3886c..cc784e6f73a0 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
@@ -1376,6 +1376,29 @@ class DataSourceV2Suite extends SharedSparkSession with 
AdaptiveSparkPlanHelper
       "non-deterministic filter should be retained as a post-scan Filter")
   }
 
+  test("SPARK-58207: V2 filter pushdown skips non-deterministic filters") {
+    val df = 
spark.read.format(classOf[AdvancedDataSourceV2WithV2Filter].getName).load()
+    // i > 3 is deterministic and pushable; rand() > 0.5 is non-deterministic. 
rand() translates
+    // to a V2 Predicate (V2ExpressionBuilder handles Rand), so without a 
guard it would be pushed
+    // to the source, which may evaluate it a different number of times than 
Spark (or use it for
+    // pruning while also returning it for post-scan re-check).
+    val q = df.filter($"i" > 3 && rand() > 0.5)
+
+    // Only the deterministic predicate should reach the source's 
pushPredicates.
+    val pushed = getBatchWithV2Filter(q).predicates.map(_.describe())
+    assert(pushed.exists(_.contains("i")),
+      s"the deterministic predicate on i should be pushed; got: 
${pushed.mkString(", ")}")
+    assert(!pushed.exists(_.contains("RAND")),
+      s"a non-deterministic predicate must not be pushed; got: 
${pushed.mkString(", ")}")
+
+    // The non-deterministic filter must be retained as a post-scan Filter.
+    val postScanConditions = q.queryExecution.optimizedPlan.collect {
+      case f: LogicalFilter => f.condition
+    }
+    assert(postScanConditions.exists(cond => cond.exists(!_.deterministic)),
+      "non-deterministic filter should be retained as a post-scan Filter")
+  }
+
   test("pushedFilters drops filters referencing pruned columns") {
     // Disable constraint propagation so IsNotNull(i) is not added (it would 
keep
     // column i in the scan output). This simulates a connector that pushes 
IsNotNull.
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/jdbc/JDBCV2Suite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/jdbc/JDBCV2Suite.scala
index dd3d90d2df05..d7e5786ecb9c 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/jdbc/JDBCV2Suite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/jdbc/JDBCV2Suite.scala
@@ -1243,14 +1243,16 @@ class JDBCV2Suite extends SharedSparkSession with 
ExplainSuiteHelper {
     checkPushedInfo(df10, "PushedFilters: [ID IS NOT NULL, ID > 1]")
     checkAnswer(df10, Row("mary", 2))
 
+    // RAND(1) is non-deterministic, so (SPARK-58207) it is not pushed down 
and stays as a
+    // post-scan filter. RAND(1) < bonus is trivially true for the matching 
row (bonus = 1300 >= 1).
     val df11 = sql(
       """
         |SELECT * FROM h2.test.employee
         |WHERE GREATEST(bonus, 1100) > 1200 AND RAND(1) < bonus
         |""".stripMargin)
-    checkFiltersRemoved(df11)
+    checkFiltersRemoved(df11, removed = false)
     checkPushedInfo(df11, "PushedFilters: " +
-      "[BONUS IS NOT NULL, (GREATEST(BONUS, 1100.0)) > 1200.0, RAND(1) < 
BONUS]")
+      "[BONUS IS NOT NULL, (GREATEST(BONUS, 1100.0)) > 1200.0]")
     checkAnswer(df11, Row(2, "david", 10000, 1300, true))
 
     val df12 = sql(


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to