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

sunchao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 6f1c904686cb [SPARK-58310][SQL] Avoid runtime Bloom-filter subqueries 
containing Python UDFs
6f1c904686cb is described below

commit 6f1c904686cb54e52c2282882fab898a125a7eac
Author: Chao Sun <[email protected]>
AuthorDate: Fri Jul 24 19:57:57 2026 -0700

    [SPARK-58310][SQL] Avoid runtime Bloom-filter subqueries containing Python 
UDFs
    
    ### Why are the changes needed?
    
    With adaptive execution and runtime Bloom filters enabled, a nested 
broadcast join can cause `InjectRuntimeFilter` to copy a creation-side plan 
containing a Python UDF into a newly constructed Bloom-filter scalar subquery. 
Runtime filters are introduced after subquery optimization, so the copied 
Python UDF never passes through Python-UDF extraction. The physical plan 
subsequently attempts to generate JVM code for the unevaluable Python 
expression while executing `BloomFilterAggregate` [...]
    
    The problem affects ordinary Apache Spark and does not depend on an 
external table provider. A self-contained PySpark setup is:
    
    ```python
    from pyspark.sql.functions import pandas_udf, udf
    
    udf("string")
    def normalize_runtime_bloom_url(value):
        return value
    
    pandas_udf("string")
    def normalize_runtime_bloom_pandas_url(values):
        return values
    
    spark.udf.register("normalize_runtime_bloom_url", 
normalize_runtime_bloom_url)
    spark.udf.register(
        "normalize_runtime_bloom_pandas_url", normalize_runtime_bloom_pandas_url
    )
    
    spark.range(20000).selectExpr(
        "cast(id as int) as id",
        "concat('url-', cast(id % 20 as string)) as url",
    ).write.mode("overwrite").parquet("/tmp/spark-58310-fact")
    
    spark.range(20).selectExpr(
        "concat('url-', cast(id as string)) as url",
        "case when id < 10 then 'NL' else 'US' end as country",
    ).write.mode("overwrite").parquet("/tmp/spark-58310-dimension")
    
    spark.read.parquet("/tmp/spark-58310-fact").createOrReplaceTempView(
        "runtime_bloom_fact"
    )
    spark.read.parquet("/tmp/spark-58310-dimension").createOrReplaceTempView(
        "runtime_bloom_dimension"
    )
    
    spark.conf.set("spark.sql.adaptive.enabled", "true")
    spark.conf.set("spark.sql.optimizer.dynamicPartitionPruning.enabled", 
"false")
    spark.conf.set("spark.sql.optimizer.runtime.bloomFilter.enabled", "true")
    spark.conf.set(
        
"spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold", "0"
    )
    
spark.conf.set("spark.sql.optimizer.runtime.bloomFilter.creationSideThreshold", 
"100MB")
    spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "-1")
    ```
    
    The following query reproduces the failure; substituting 
`normalize_runtime_bloom_pandas_url` reproduces the Arrow/Pandas variant:
    
    ```sql
    WITH coverage AS (
      SELECT url, normalize_runtime_bloom_url(url) AS normalized_url
      FROM runtime_bloom_dimension
      WHERE country = 'NL'
    ),
    normalized_keys AS (
      SELECT DISTINCT normalized_url
      FROM coverage
      WHERE normalized_url IS NOT NULL
    ),
    matched_urls AS (
      SELECT /*+ BROADCAST(k) */ fact.url
      FROM runtime_bloom_fact fact
      JOIN normalized_keys k ON fact.url = k.normalized_url
    ),
    snapshot AS (
      SELECT max(url) AS snapshot_url
      FROM runtime_bloom_fact
    )
    SELECT /*+ BROADCAST(m), BROADCAST(snapshot) */
      coverage.url,
      coverage.normalized_url,
      matched_urls.url AS matched_url,
      snapshot.snapshot_url
    FROM coverage
    LEFT JOIN matched_urls ON coverage.normalized_url = matched_urls.url
    CROSS JOIN snapshot
    ```
    
    ### What changes were proposed in this PR?
    
    After constructing and column-pruning the runtime Bloom-filter aggregate, 
return the unchanged application-side plan when the pruned aggregate still 
contains the `PYTHON_UDF` tree pattern. Checking after column pruning preserves 
safe Bloom filters when a Python UDF is needed only by the outer query output 
and is absent from the actual Bloom-filter scalar subquery. This guard is 
deliberately limited to unsafe Bloom-filter creation. It does not disable 
runtime filtering, does not reject [...]
    
    Add `SPARK-58310` coverage to `InjectRuntimeFilterSuite` that:
    
    - Runs both standard scalar Python and Arrow/Pandas scalar UDFs when 
available.
    - Reproduces nested CTEs, explicit broadcast joins, scalar aggregates, and 
adaptive execution with dynamic partition pruning disabled.
    - Compares the complete result with the Bloom-disabled baseline and 
verifies a real Parquet write/read round trip.
    - Proves that safe application-side and pruned creation-side runtime Bloom 
filters are still injected and produce the same results.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Queries that previously failed because a runtime Bloom-filter subquery 
attempted to code-generate a Python UDF now complete successfully. Safe runtime 
Bloom-filter optimizations remain enabled.
    
    ### How was this PR tested?
    
    First, the new `SPARK-58310` regression was run against unmodified Apache 
Spark `master` and failed with the expected Python-UDF 
scalar-subquery/`ObjectHashAggregateExec` error. The same regression then 
passed after the creation-side guard was applied, with both `pandas` and 
`pyarrow` installed.
    
    The following focused upstream regression suites and Scala-style checks 
were also run on the public Apache Spark branch:
    
    ```bash
    build/sbt \
      'sql/testOnly org.apache.spark.sql.InjectRuntimeFilterSuite 
org.apache.spark.sql.DynamicPartitionPruningV1SuiteAEOn 
org.apache.spark.sql.SubquerySuite 
org.apache.spark.sql.execution.adaptive.AdaptiveQueryExecSuite 
org.apache.spark.sql.execution.python.ExtractPythonUDFsSuite' \
      'catalyst/scalastyle' \
      'sql/scalastyle' \
      'sql/Test/scalastyle'
    ```
    
    ### How was this patch tested?
    
    The upstream before/after reproduction, Python and Arrow regression, 
adjacent SQL suites, and Scala-style checks are described above.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: OpenAI Codex (GPT-5).
    
    Closes #57485 from sunchao/dev/chao/codex/python-udf-runtime-bloom-filter.
    
    Authored-by: Chao Sun <[email protected]>
    Signed-off-by: Chao Sun <[email protected]>
---
 .../catalyst/optimizer/InjectRuntimeFilter.scala   |   5 +
 .../spark/sql/InjectRuntimeFilterSuite.scala       | 125 +++++++++++++++++++++
 2 files changed, 130 insertions(+)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala
index 176e927b2d21..a8f5b3ca67ed 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InjectRuntimeFilter.scala
@@ -69,6 +69,11 @@ object InjectRuntimeFilter extends Rule[LogicalPlan] with 
PredicateHelper with J
     val alias = Alias(bloomFilterAgg.toAggregateExpression(), "bloomFilter")()
     val aggregate =
       ConstantFolding(ColumnPruning(Aggregate(Nil, Seq(alias), 
filterCreationSidePlan)))
+    // Runtime filters are introduced after subquery optimization, so Python 
UDFs in a new
+    // creation-side subquery cannot be extracted into a Python evaluation 
operator.
+    if (aggregate.containsPattern(PYTHON_UDF)) {
+      return filterApplicationSidePlan
+    }
     val bloomFilterSubquery = ScalarSubquery(aggregate, Nil)
     val filter = BloomFilterMightContain(bloomFilterSubquery,
       new XxHash64(Seq(filterApplicationSideKey)))
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/InjectRuntimeFilterSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/InjectRuntimeFilterSuite.scala
index 53b0132b29ce..f045a92b6dc9 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/InjectRuntimeFilterSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/InjectRuntimeFilterSuite.scala
@@ -17,6 +17,8 @@
 
 package org.apache.spark.sql
 
+import java.io.File
+
 import org.apache.spark.sql.catalyst.expressions.{Alias, 
BloomFilterMightContain, Literal}
 import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
BloomFilterAggregate}
 import org.apache.spark.sql.catalyst.optimizer.MergeSubplans
@@ -517,6 +519,129 @@ class InjectRuntimeFilterSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-58310: skip runtime bloom filter when pruned creation side 
contains Python UDF") {
+    import IntegratedUDFTestUtils._
+
+    assume(shouldTestPythonUDFs)
+    registerTestUDF(TestPythonUDF("normalize_runtime_bloom_url"), spark)
+    val pythonUdfNames = if (shouldTestPandasUDFs) {
+      
registerTestUDF(TestScalarPandasUDF("normalize_runtime_bloom_pandas_url"), 
spark)
+      Seq("normalize_runtime_bloom_url", "normalize_runtime_bloom_pandas_url")
+    } else {
+      Seq("normalize_runtime_bloom_url")
+    }
+
+    withTempPath { dir =>
+      val factPath = new File(dir, "fact").getCanonicalPath
+      val dimensionPath = new File(dir, "dimension").getCanonicalPath
+
+      spark.range(20000)
+        .selectExpr("cast(id as int) as id", "concat('url-', cast(id % 20 as 
string)) as url")
+        .write.parquet(factPath)
+      spark.range(20)
+        .selectExpr(
+          "concat('url-', cast(id as string)) as url",
+          "case when id < 10 then 'NL' else 'US' end as country")
+        .write.parquet(dimensionPath)
+
+      withTempView("runtime_bloom_fact", "runtime_bloom_dimension") {
+        
spark.read.parquet(factPath).createOrReplaceTempView("runtime_bloom_fact")
+        
spark.read.parquet(dimensionPath).createOrReplaceTempView("runtime_bloom_dimension")
+
+        withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+          SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "false",
+          
SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key -> "0",
+          SQLConf.RUNTIME_BLOOM_FILTER_CREATION_SIDE_THRESHOLD.key -> "100MB",
+          SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+          pythonUdfNames.foreach { pythonUdfName =>
+            withClue(s"$pythonUdfName: ") {
+              val query =
+                s"""
+                  |WITH coverage AS (
+                  |  SELECT url, $pythonUdfName(url) AS normalized_url
+                  |  FROM runtime_bloom_dimension
+                  |  WHERE country = 'NL'
+                  |),
+                  |grader_keys AS (
+                  |  SELECT DISTINCT normalized_url
+                  |  FROM coverage
+                  |  WHERE normalized_url IS NOT NULL
+                  |),
+                  |matched_url_status AS (
+                  |  SELECT /*+ BROADCAST(k) */ s.url
+                  |  FROM runtime_bloom_fact s
+                  |  JOIN grader_keys k ON s.url = k.normalized_url
+                  |),
+                  |url_status_snapshot AS (
+                  |  SELECT max(url) AS snapshot_url FROM runtime_bloom_fact
+                  |)
+                  |SELECT /*+ BROADCAST(m), BROADCAST(snapshot) */
+                  |  c.url, c.normalized_url, m.url AS matched_url, 
snapshot.snapshot_url
+                  |FROM coverage c
+                  |LEFT JOIN matched_url_status m ON c.normalized_url = m.url
+                  |CROSS JOIN url_status_snapshot snapshot
+                """.stripMargin
+
+              val applicationSideQuery =
+                s"""
+                  |SELECT /*+ MERGE(f, s) */ f.id, f.url
+                  |FROM (
+                  |  SELECT id, url, $pythonUdfName(url) AS normalized_url
+                  |  FROM runtime_bloom_fact
+                  |) f
+                  |JOIN (
+                  |  SELECT url FROM runtime_bloom_dimension WHERE country = 
'NL'
+                  |) s ON f.normalized_url = s.url
+                """.stripMargin
+
+              val creationSideQuery =
+                s"""
+                  |SELECT /*+ MERGE(f, s) */ f.id, s.url, s.normalized_url
+                  |FROM runtime_bloom_fact f
+                  |JOIN (
+                  |  SELECT url, $pythonUdfName(url) AS normalized_url
+                  |  FROM runtime_bloom_dimension
+                  |  WHERE country = 'NL'
+                  |) s ON f.url = s.url
+                """.stripMargin
+
+              var expected: Array[Row] = null
+              var applicationSideExpected: Array[Row] = null
+              var creationSideExpected: Array[Row] = null
+              withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_ENABLED.key -> "false") 
{
+                expected = sql(query).collect()
+                applicationSideExpected = sql(applicationSideQuery).collect()
+                creationSideExpected = sql(creationSideQuery).collect()
+              }
+
+              assert(expected.length == 10000)
+              assert(applicationSideExpected.length == 10000)
+              assert(creationSideExpected.length == 10000)
+
+              withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_ENABLED.key -> "true") {
+                val result = sql(query)
+                checkAnswer(result, expected.toSeq)
+
+                val outputPath = new java.io.File(dir, 
pythonUdfName).getCanonicalPath
+                result.write.parquet(outputPath)
+                checkAnswer(spark.read.parquet(outputPath), expected.toSeq)
+
+                val applicationSideResult = sql(applicationSideQuery)
+                
assert(getNumBloomFilters(applicationSideResult.queryExecution.optimizedPlan) > 
0)
+                checkAnswer(applicationSideResult, 
applicationSideExpected.toSeq)
+
+                val creationSideResult = sql(creationSideQuery)
+                
assert(getNumBloomFilters(creationSideResult.queryExecution.optimizedPlan) > 0)
+                checkAnswer(creationSideResult, creationSideExpected.toSeq)
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
   test("Support Left Semi join in row level runtime filters") {
     
withSQLConf(SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key
 -> "3000",
       SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "32") {


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

Reply via email to