dongjoon-hyun opened a new pull request, #60117:
URL: https://github.com/apache/spark/pull/60117

   ### What changes were proposed in this pull request?
   
   This PR fixes restarts of streaming queries from checkpoints written by 
Spark 4.3.x or before, when a filter uses the result of a Python UDF.
   
   SPARK-59693 made every Python UDF (`PythonFuncExpression`) expensive. With 
`spark.sql.optimizer.avoidDoubleFilterEval=true` (the default), 
`PushPredicateThroughNonJoin` now keeps a filter on a Python UDF alias above 
its `Project`. Before, the filter was pushed below the `Project`. From there it 
could move further below a stateful operator, such as into the input of a 
stream-stream join, or below `EventTimeWatermark`. The checkpoint does not 
record which classification a query ran with, so a query restarted from a 
checkpoint of a released Spark version gets a different plan.
   
   This PR:
   - adds an internal config, 
`spark.sql.optimizer.avoidDoubleFilterEvalForPythonUDF` (default `true`). With 
`false`, a Python UDF is only as expensive as its arguments, as in Spark 4.3 
and before.
   - adds it to `OffsetSeqMetadata.relevantSQLConfs`, with `false` in 
`relevantSQLConfDefaultValues` for checkpoints without the key. Every released 
version treats Python UDFs as cheap, so a missing key has one meaning and a 
static default is enough.
   - checks it in `PushPredicateThroughNonJoin.isExpensive` rather than in 
`PythonFuncExpression.expensive`, because a parent expression caches 
`hasExpensiveChild`. SPARK-60632 does the same for 
`spark.sql.optimizer.regexPatternCostEstimateVersion`. With `false`, it 
classifies a copy of the expression in which each Python UDF is replaced by 
`PrettyPythonUDF`. That expression is not foldable either, and it is expensive 
only through its arguments.
   
   | Checkpoint written by | Value used |
   |---|---|
   | Spark 4.3.x or before | `false`, so the filter stays where those versions 
put it |
   | Spark with this change | the recorded value, `true` by default |
   
   This is a separate config rather than a new version of 
`spark.sql.optimizer.regexPatternCostEstimateVersion`. That config is about 
LIKE and RLIKE patterns, and its versions are shared with `branch-4.2` and 
`branch-4.3`, which do not have SPARK-59693.
   
   ### Why are the changes needed?
   
   Without this change, a query that ran on Spark 4.3.0 fails or returns 
different rows after an upgrade to the unreleased `master` or `branch-4.x`. No 
config is changed in either case.
   
   Issue 1: When only the filter uses the input column of the UDF, the state 
schema of the stream-stream join differs between the two plans, so the restart 
fails.
   
   ```python
   from pyspark.sql.functions import col, udf
   
   @udf("int")
   def identity(v):
       return v
   
   left = spark.readStream.schema("k INT, lv INT").json(left_path)
   right = spark.readStream.schema("k INT, rv INT").json(right_path)
   df = (left.join(right, "k")
         .select(identity(col("lv")).alias("u"), "rv")
         .where(col("u") > 0)
         .select("rv"))
   
   # Run 1 on Spark 4.3.0 with the left rows (1, -1) and (2, 10).
   # Run 2 on master from the same checkpoint with the right rows (1, 100) and 
(2, 200).
   # Issue 1: It should return [200], but it fails with 
STATE_STORE_VALUE_SCHEMA_NOT_COMPATIBLE.
   ```
   
   Issue 2: A filter over `withWatermark` stays above the watermark after the 
restart. Rows that fail the filter then advance the watermark, so later rows 
can be dropped as late.
   
   ```python
   from pyspark.sql.functions import window
   
   events = spark.readStream.schema("t TIMESTAMP, v INT").json(events_path)
   df = (events.withWatermark("t", "10 seconds")
         .select("t", identity(col("v")).alias("u"))
         .where(col("u") > 0)
         .groupBy(window("t", "10 seconds"))
         .count())
   
   # Run 1 on Spark 4.3.0 with (00:01:40, 1).
   # Run 2 on master with (00:16:40, -1), then run 3 with (00:01:45, 1) and 
(00:33:20, 1).
   # Issue 2: It should return the window [00:01:40, 00:01:50) with count 2, 
but it returns count 1.
   ```
   
   Setting `spark.sql.optimizer.avoidDoubleFilterEval=false` works around both 
issues. However, it also moves the filters on other expensive expressions, such 
as Scala UDFs. In addition, since SPARK-60885 a failed first restart records 
`true`, so setting `false` afterwards no longer recovers the query.
   
   ### Does this PR introduce _any_ user-facing change?
   
   No, compared to the released versions. A query restarted from a checkpoint 
of Spark [4.3.0](https://github.com/apache/spark/releases/tag/v4.3.0) 
(2026-10-27) or before keeps the plan of that version.
   
   Within the unreleased `master` and `branch-4.x`, yes:
   - A query restarted from a checkpoint of Spark 4.3.x or before no longer 
treats Python UDFs as expensive.
   - A new query still treats them as expensive.
   - A checkpoint written by a snapshot build after SPARK-59693 has no key, so 
it now reads `false`.
   
   ### How was this patch tested?
   
   New tests:
   - `FilterPushdownSuite`: with `false`, a filter on a `pythonUDF(a) + 1` 
alias is pushed below the `Project`, and a filter on a Python UDF over an 
expensive RLIKE stays above it. The plan is optimized with the default first, 
so the test also covers the cached `hasExpensiveChild`.
   - `OffsetSeqLogSuite`: a checkpoint without the key reads `false`, and a new 
checkpoint records `true`.
   
   Existing tests: `catalyst/testOnly *FilterPushdownSuite` and `sql/testOnly 
*OffsetSeqLogSuite`.
   
   The test suite has no checkpoints written by released Spark versions, so the 
issues were also checked manually with PySpark (file source, 
`Trigger.AvailableNow`). Run 1 used the released Spark 4.3.0. Later runs used a 
build of `master` without this PR and a build with it, from the same 
checkpoint. `S1` is Issue 1 with the UDF result in the output, which should not 
change.
   
   | Query | 4.3.0 -> 4.3.0 | 4.3.0 -> master | 4.3.0 -> this PR | this PR -> 
this PR |
   |---|---|---|---|---|
   | S1 | `[2,10,200]` | `[2,10,200]` | `[2,10,200]` | not run |
   | Issue 1 | `[200]` | `STATE_STORE_VALUE_SCHEMA_NOT_COMPATIBLE` | `[200]` | 
`[200]` |
   | Issue 2 | count 2 | count 1 | count 2 | count 1 |
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Opus 5.5


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