peter-toth commented on code in PR #58851:
URL: https://github.com/apache/spark/pull/58851#discussion_r4034497348


##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2EnhancedPartitionFilterSuite.scala:
##########
@@ -403,6 +404,26 @@ class DataSourceV2EnhancedPartitionFilterSuite
     }
   }
 
+  test("SPARK-59572: a failed second-pass PartitionPredicate must not keep the 
partition") {
+    withTable(partFilterTableName) {
+      sql(s"CREATE TABLE $partFilterTableName (part_col string, data string) 
USING $v2Source " +
+        "PARTITIONED BY (part_col)")
+      sql(s"INSERT INTO $partFilterTableName VALUES ('hr', 'x'), ('1', 'y')")
+
+      spark.udf.register("to_int", (s: String) => s.toInt)
+
+      // `to_int('hr')` throws, so the filter is never true for that 
partition. The filter is
+      // untranslatable, so it is pushed as a PartitionPredicate and accepted, 
which removes it
+      // from the post-scan filters. The source is then its only evaluator, so 
reporting a failed
+      // evaluation as a match returns the 'hr' row. The error surfaces while 
the scan is built.
+      val e = intercept[SparkException] {

Review Comment:
   **Finding 1.** This test passes whether or not the `PartitionPredicate` is 
the evaluator, so it does not pin the path its comment describes.
   
   Any path that leaves `to_int(part_col) = 1` in the post-scan filters raises 
the same error. The `FilterExec` above the scan evaluates it on the `'hr'` row 
and gets `FAILED_EXECUTE_UDF` with a `NumberFormatException` cause, so both 
assertions hold. Setting `accept-partition-predicates` to `false` on the 
fixture is the cheapest way to see it: `pushPredicates` returns the predicate, 
`pushPartitionPredicates` restores the original filter, and the test still goes 
green. It does fail on master today, so the fix is proven. What is missing is 
that a later change which stops pushing this filter leaves the test green and 
silent.
   
   The comment already names the discriminator: on the pushed path the error 
comes out of planning, on the post-scan path only out of execution. Asserting 
that is a one-line change:
   
   ```scala
   val e = intercept[SparkException] {
     sql(s"SELECT * FROM $partFilterTableName WHERE to_int(part_col) = 1")
       .queryExecution.optimizedPlan
   }
   ```
   
   The DELETE test at 
`sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2EnhancedDeleteFilterSuite.scala:255`
 has the same gap and needs a different fix. If the metadata-only rewrite does 
not fire, the row-level DELETE evaluates the same UDF, throws the same error 
and writes nothing, so `checkAnswer` passes too. `executeAndKeepPlan` cannot 
capture the plan there because its `onFailure` is a no-op; recording 
`qe.executedPlan` in `onFailure` as well lets the test assert 
`DeleteFromTableExec` around the `intercept`.
   
   Both of these are derived from reading, not from running them.
   



##########
docs/sql-migration-guide.md:
##########
@@ -55,6 +55,11 @@ license: |
 - Since Spark 4.3, the Spark Connect session errors 
`INVALID_HANDLE.SESSION_CHANGED`/`SESSION_CLOSED`/`SESSION_NOT_FOUND` carry 
SQLSTATE `08003` instead of `HY000`; the condition names are unchanged. Code 
matching these errors on SQLSTATE should match `08003` or class `08`.
 - Since Spark 4.3, [Declarative 
Pipelines](declarative-pipelines-programming-guide.html) honors 
`spark.sql.caseSensitive` when inferring and evolving pipeline table schemas. 
Under case-insensitive resolution (the default), column names that differ only 
in case now identify the same column: flows writing to one table contribute a 
single column rather than one per spelling, and a column that differs only in 
case from one already persisted in the target is written to that column instead 
of being added alongside it. Previously such names were always treated as 
distinct, producing a table schema that Spark's own resolver could not 
disambiguate and that could fail later with errors such as 
`COLUMN_ALREADY_EXISTS` or `AMBIGUOUS_REFERENCE`. When two flows' columns fold 
together but their types are incompatible, the update now fails at validation 
with `UNABLE_TO_INFER_PIPELINE_TABLE_SCHEMA`. Where the spellings differ, the 
surviving one comes from the flow with the lowest identifier. An expl
 icitly declared table schema keeps its spelling over the inferred one. 
Incremental streaming tables keep the persisted spelling of an existing column; 
materialized views re-infer the schema on every update. Set 
`spark.sql.caseSensitive` to `true` to keep names differing only in case 
distinct, as before.
 - Since Spark 4.3, all flows writing to the same pipeline table must agree on 
the effective `spark.sql.caseSensitive`, which each flow takes from its own SQL 
configuration (a `SET` in pipeline source, which never reaches the session) and 
otherwise from the session. A disagreement fails the update with 
`CONFLICTING_PIPELINE_FLOW_CASE_SENSITIVITY`, because that value decides 
whether names differing only in case identify the same column and would 
otherwise make the table's schema depend on the order the flows are evaluated 
in. Set `spark.sql.caseSensitive` to the same value for every flow writing to 
the table, remembering that a flow which does not set it inherits the session's 
value.
+- Since Spark 4.3, when a data source cannot evaluate a partition predicate 
that Spark pushed down, for example because an ANSI cast of a partition value 
fails, the error is raised instead of the partition being reported as a match. 
Previously the failure was ignored and the partition was kept, so a scan could 
return rows the filter does not accept and a metadata-only `DELETE` could 
remove a partition its condition never matched. Queries that appeared to 
succeed may now fail with the same error the query raises without pushdown, and 
whether a given query fails depends on the order in which the data source 
evaluates the predicates it accepted, which Spark does not define. The error 
can also surface earlier than before, while the scan is built rather than while 
rows are read, so `EXPLAIN` may report it in place of a plan and a partition 
holding no rows can raise it. Runtime filters are unaffected: a partition they 
cannot evaluate is still kept, because their rows are filtered after th
 e scan or by the join the filter came from.

Review Comment:
   **Finding 3.** The description's user-facing section lists two changes and 
says both are recorded here, but only the first is.
   
   "A partition key whose width does not match `Table.partitioning()` now 
raises an internal error" appears in neither this entry nor the 4.2.1 one. For 
a connector that passes a subset key it turns a warning plus a kept partition 
into a failed query, so a user of such a connector sees a query that used to 
work start failing.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:
##########
@@ -481,7 +485,10 @@ object PushDownUtils extends Logging {
       partitionFields: Seq[PartitionPredicateField]): 
Seq[PartitionPredicateImpl] = {
     val catalystExprs = runtimeFilters.flatMap(unwrapRuntimeFilterExpression)
     val flattened = flattenNestedPartitionFilters(catalystExprs, 
partitionFields).keys
-    createPartitionPredicates(flattened.toSeq, partitionFields)._1
+    // A runtime filter only prunes: its rows are filtered anyway, by the 
post-scan `FilterExec`
+    // for a scalar subquery filter and by the join it was derived from for a 
DPP filter. So a

Review Comment:
   **Finding 2.** A third source of runtime `PartitionPredicate`s is missing 
from this list, and neither reason given covers it.
   
   `RowLevelOperationRuntimeGroupFiltering.buildDynamicPruningCond` injects 
`DynamicPruningExpression(InSubquery(pruningKeys, ...))`, where `pruningKeys` 
resolve from the scan's own `filterAttributes()` 
(`sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelOperationRuntimeGroupFiltering.scala:146`).
 A partition column there reaches `createRuntimePartitionPredicates` like any 
other DPP filter. `DataSourceV2Strategy` partitions it out as a 
`DynamicPruning`, so it has no post-scan `FilterExec`, and it was not derived 
from a join: it comes from the row-level operation's own condition.
   
   Keeping the partition is still safe there, for a third reason. A 
`ReplaceData` or `WriteDelta` rewrite re-applies that condition to the rows it 
reads, so an extra group is rewritten unchanged rather than mis-written. Worth 
spelling out, because this same two-case list is the whole safety argument in 
the `keepOnEvalFailure` scaladoc and in the `InMemoryBaseTable` comment, and 
the next reader will check it against this path.
   



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