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

zhztheplayer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 34e70f5ee7 [GLUTEN-12013][VL] Register bloom-filter rewrite at 
injectPre so it survives whole-stage fallback (#12151)
34e70f5ee7 is described below

commit 34e70f5ee7af1cb295482653c5c81fe18be33ecb
Author: BRIJ RAJ KISHORE <[email protected]>
AuthorDate: Thu Aug 6 15:28:23 2026 +0530

    [GLUTEN-12013][VL] Register bloom-filter rewrite at injectPre so it 
survives whole-stage fallback (#12151)
    
    `BloomFilterMightContainJointRewriteRule` rewrites `bloom_filter_agg` ->
    `velox_bloom_filter_agg` and `might_contain` -> `velox_might_contain`
    unconditionally, so producer and consumer always agree on the serialized 
byte
    format. It was registered at `injectPreTransform`, which runs after
    `HeuristicApplier` captures the plan that `ExpandFallbackPolicy` reverts to.
    When a stage fallback is promoted to a whole-stage fallback, that reversion
    strips the rewrite from one stage while another stage keeps it, leaving a
    vanilla `bloom_filter_agg` producing Spark-format bytes (4-byte big-endian
    version) that a `velox_might_contain` consumer reads as a Velox-format
    single-byte version, failing with "Unsupported BloomFilter version: 0".
    
    Registering the same rule at `injectPre` instead fixes this: `injectPre` 
runs
    before the revert-target plan is captured, so the rewrite is already baked 
into
    it and a whole-stage revert can no longer remove it.
    
    Adds `GlutenBloomFilterFallbackSuite` covering whole-stage fallback of one 
and
    both stages, runtime bloom filters injected by `InjectRuntimeFilter` (native
    offload plus single-stage reversion), `DataFrame.stat.bloomFilter()` keeping
    Spark-native bytes, the disabled-config path, and the SPARK-54336 
literal-value
    case.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../gluten/backendsapi/velox/VeloxRuleApi.scala    |  17 +-
 .../sql/GlutenBloomFilterFallbackSuite.scala       | 474 +++++++++++++++++++++
 2 files changed, 486 insertions(+), 5 deletions(-)

diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
index 9888eaa179..6f280757cc 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
@@ -75,6 +75,18 @@ object VeloxRuleApi {
    * columnar query planning.
    */
   private def injectLegacy(injector: LegacyInjector): Unit = {
+    // Registered at injectPre rather than injectPreTransform so the rewrite 
is baked into the
+    // plan that ExpandFallbackPolicy reverts to when it promotes a stage 
fallback to a
+    // whole-stage fallback (GLUTEN-12013). A rewrite applied at 
injectPreTransform is stripped
+    // by that reversion, which can leave a vanilla-reverted stage producing 
Spark-format bloom
+    // filter bytes while another stage still consumes them as Velox-format, 
crashing with
+    // "Unsupported BloomFilter version".
+    injector.injectPre(
+      c =>
+        BloomFilterMightContainJointRewriteRule.apply(
+          c.session,
+          c.caller.isBloomFilterStatFunction()))
+
     // Legacy: Pre-transform rules.
     injector.injectPreTransform(_ => RemoveTransitions)
     injector.injectPreTransform(_ => PushDownInputFileExpression.PreOffload)
@@ -82,11 +94,6 @@ object VeloxRuleApi {
     injector.injectPreTransform(c => FallbackMultiCodegens.apply(c.session))
     injector.injectPreTransform(c => 
MergeTwoPhasesHashBaseAggregate(c.session))
     injector.injectPreTransform(_ => RewriteSubqueryBroadcast())
-    injector.injectPreTransform(
-      c =>
-        BloomFilterMightContainJointRewriteRule.apply(
-          c.session,
-          c.caller.isBloomFilterStatFunction()))
     injector.injectPreTransform(_ => EliminateRedundantGetTimestamp)
 
     // Legacy: The legacy transform rule.
diff --git 
a/backends-velox/src/test/scala/org/apache/gluten/sql/GlutenBloomFilterFallbackSuite.scala
 
b/backends-velox/src/test/scala/org/apache/gluten/sql/GlutenBloomFilterFallbackSuite.scala
new file mode 100644
index 0000000000..c9c9fa31b0
--- /dev/null
+++ 
b/backends-velox/src/test/scala/org/apache/gluten/sql/GlutenBloomFilterFallbackSuite.scala
@@ -0,0 +1,474 @@
+/*
+ * 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.gluten.sql
+
+import org.apache.gluten.backendsapi.BackendsApiManager
+import org.apache.gluten.config.GlutenConfig
+import org.apache.gluten.execution.{FilterExecTransformerBase, 
WholeStageTransformerSuite}
+import org.apache.gluten.expression.VeloxBloomFilterMightContain
+import org.apache.gluten.expression.aggregate.VeloxBloomFilterAggregate
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.FunctionIdentifier
+import org.apache.spark.sql.catalyst.expressions.BloomFilterMightContain
+import org.apache.spark.sql.catalyst.expressions.ExpressionInfo
+import org.apache.spark.sql.catalyst.expressions.aggregate.BloomFilterAggregate
+import org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, 
ObjectHashAggregateExec}
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Regression tests for https://github.com/apache/gluten/issues/12013.
+ *
+ * Verifies that the bloom-filter producer (`bloom_filter_agg`) and consumer 
(`might_contain`)
+ * always stay on a consistent serialized byte format.
+ *
+ * `BloomFilterMightContainJointRewriteRule` rewrites both sides 
unconditionally, so they agree by
+ * construction. The fix for GLUTEN-12013 is that the rule is registered at 
`injectPre` instead of
+ * `injectPreTransform`: `injectPre` runs before `HeuristicApplier` captures 
the plan that
+ * `ExpandFallbackPolicy` reverts to, so a whole-stage fallback can no longer 
strip the rewrite from
+ * one stage and leave it mismatched against another. Covered here:
+ *   - user-facing pairs across whole-stage fallback of one or both stages;
+ *   - literal-valued pairs (SPARK-54336);
+ *   - runtime-filter pairs injected by `InjectRuntimeFilter`, which must 
offload natively and
+ *     survive whole-stage reversion of a single stage;
+ *   - `DataFrame.stat.bloomFilter()`, which must keep Spark-native bytes (the 
rule is skipped for
+ *     it via `CallerInfo.isBloomFilterStatFunction`).
+ */
+class GlutenBloomFilterFallbackSuite extends WholeStageTransformerSuite {
+  protected val resourcePath: String = null
+  protected val fileFormat: String = null
+
+  import testImplicits._
+
+  private val funcIdBloomFilterAgg = FunctionIdentifier("bloom_filter_agg")
+  private val funcIdMightContain = FunctionIdentifier("might_contain")
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    spark.sessionState.functionRegistry.registerFunction(
+      funcIdBloomFilterAgg,
+      new ExpressionInfo(classOf[BloomFilterAggregate].getName, 
"bloom_filter_agg"),
+      args =>
+        args.size match {
+          case 1 => new BloomFilterAggregate(args(0))
+          case 2 => new BloomFilterAggregate(args(0), args(1))
+          case 3 => new BloomFilterAggregate(args(0), args(1), args(2))
+          case _ => throw new IllegalArgumentException("bloom_filter_agg 
requires 1-3 arguments")
+        }
+    )
+    spark.sessionState.functionRegistry.registerFunction(
+      funcIdMightContain,
+      new ExpressionInfo(classOf[BloomFilterMightContain].getName, 
"might_contain"),
+      args => BloomFilterMightContain(args(0), args(1)))
+  }
+
+  override def afterAll(): Unit = {
+    spark.sessionState.functionRegistry.dropFunction(funcIdBloomFilterAgg)
+    spark.sessionState.functionRegistry.dropFunction(funcIdMightContain)
+    super.afterAll()
+  }
+
+  private val veloxBloomFilterMaxNumBits = 4194304L
+
+  // GLUTEN-12013: only filter stage falls back (threshold=2).
+  // bloom_filter_agg subquery runs natively and produces Velox-format bytes; 
the filter stage
+  // falls back via ExpandFallbackPolicy.  Because the rewrite is applied at 
injectPre it is part
+  // of the plan the reversion falls back to, so the JVM filter still reads 
Velox-format bytes.
+  test("GLUTEN-12013: bloom_filter_agg whole-stage fallback does not corrupt 
bloom filter bytes") {
+    if 
(BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback())
 {
+      val table = "bloom_filter_test"
+      val numEstimatedItems = 5000000L
+      val sqlString =
+        s"""
+           |SELECT col positive_membership_test
+           |FROM $table
+           |WHERE might_contain(
+           |            (SELECT bloom_filter_agg(col,
+           |              cast($numEstimatedItems as long),
+           |              cast($veloxBloomFilterMaxNumBits as long))
+           |             FROM $table), col)
+           |""".stripMargin
+      withTempView(table) {
+        (Seq(Long.MinValue, 0, Long.MaxValue) ++ (1L to 200000L))
+          .toDF("col")
+          .createOrReplaceTempView(table)
+        // Threshold=2: FilterExec fallback cost=2 triggers whole-stage 
fallback; agg cost=1
+        // does not, so Stage 0 runs natively.  ANSI off keeps agg cost at 1 
on Spark 4.0+.
+        withSQLConf(
+          GlutenConfig.COLUMNAR_FILTER_ENABLED.key -> "false",
+          GlutenConfig.COLUMNAR_WHOLESTAGE_FALLBACK_THRESHOLD.key -> "2",
+          SQLConf.ANSI_ENABLED.key -> "false"
+        ) {
+          val df = spark.sql(sqlString)
+          // Must not throw: java.io.IOException: Unexpected Bloom filter 
version number.
+          assert(df.collect().length == 200003)
+          // Verify the rewrite survived the whole-stage fallback: 
VeloxBloomFilterMightContain
+          // must still be present even though Stage 1 executes inside a 
FallbackNode.
+          assert(
+            
df.queryExecution.executedPlan.toString.contains("velox_might_contain"),
+            s"Expected velox_might_contain to survive whole-stage fallback. 
Plan:\n" +
+              s"${df.queryExecution.executedPlan}"
+          )
+        }
+      }
+    }
+  }
+
+  // GLUTEN-12013: both stages fall back (threshold=1).
+  // Stage 0's inherent transition cost of 1 meets the threshold so 
ExpandFallbackPolicy
+  // promotes it to a whole-stage fallback too.  The injectPre rule has 
already rewritten both
+  // sides to Velox variants before ExpandFallbackPolicy captures its 
snapshot.  Even in JVM
+  // row-mode, VeloxBloomFilterAggregate produces Velox-format bytes (via JNI) 
and
+  // VeloxBloomFilterMightContain consumes them -- both sides are consistent.
+  test("GLUTEN-12013: bloom_filter_agg whole-stage fallback when both stages 
fall back") {
+    if 
(BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback())
 {
+      val table = "bloom_filter_test"
+      val numEstimatedItems = 5000000L
+      val sqlString =
+        s"""
+           |SELECT col positive_membership_test
+           |FROM $table
+           |WHERE might_contain(
+           |            (SELECT bloom_filter_agg(col,
+           |              cast($numEstimatedItems as long),
+           |              cast($veloxBloomFilterMaxNumBits as long))
+           |             FROM $table), col)
+           |""".stripMargin
+      withTempView(table) {
+        (Seq(Long.MinValue, 0, Long.MaxValue) ++ (1L to 200000L))
+          .toDF("col")
+          .createOrReplaceTempView(table)
+        // Threshold=1: both stages fall back; both use Velox variants via JNI.
+        withSQLConf(
+          GlutenConfig.COLUMNAR_FILTER_ENABLED.key -> "false",
+          GlutenConfig.COLUMNAR_WHOLESTAGE_FALLBACK_THRESHOLD.key -> "1",
+          SQLConf.ANSI_ENABLED.key -> "false"
+        ) {
+          val df = spark.sql(sqlString)
+          // Must not throw: java.io.IOException: Unexpected Bloom filter 
version number.
+          assert(df.collect().length == 200003)
+          // Verify the rewrite survived on both sides.
+          assert(
+            
df.queryExecution.executedPlan.toString.contains("velox_might_contain"),
+            s"Expected velox_might_contain to survive whole-stage fallback. 
Plan:\n" +
+              s"${df.queryExecution.executedPlan}"
+          )
+        }
+      }
+    }
+  }
+
+  // GLUTEN-12013: DataFrame.stat.bloomFilter() must not be affected by the 
rewrite rule.
+  // The rule must only rewrite BloomFilterAggregate inside a 
BloomFilterMightContain subquery.
+  // A standalone BloomFilterAggregate (as used here) must remain vanilla so 
that the collected
+  // bytes are in Spark-native format and BloomFilter.readFrom() succeeds.
+  test("GLUTEN-12013: DataFrame.stat.bloomFilter() produces Spark-readable 
bytes") {
+    if 
(BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback())
 {
+      val table = "bloom_filter_stat_test"
+      withTempView(table) {
+        (1L to 1000L).toDF("col").createOrReplaceTempView(table)
+        // Must not throw: java.io.IOException: Unexpected Bloom filter 
version number
+        val bf = spark.table(table).stat.bloomFilter("col", 1000L, 0.01)
+        // Bloom filters have no false negatives: every inserted value must be 
present.
+        assert(bf.mightContainLong(500L), "Expected 500 to be in bloom filter")
+      }
+    }
+  }
+
+  // GLUTEN-12013: native bloom filter disabled -- early-exit path of the 
rewrite rule.
+  // When spark.gluten.sql.native.bloomFilter=false the rule returns the plan 
unchanged.
+  // BloomFilterAggregate / BloomFilterMightContain remain as vanilla Spark 
expressions and
+  // produce/consume consistent Spark-format bytes.
+  test(
+    "GLUTEN-12013: native bloom filter disabled skips rewrite and produces 
correct results") {
+    if 
(BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback())
 {
+      val table = "bloom_filter_test"
+      val numEstimatedItems = 5000000L
+      val sqlString =
+        s"""
+           |SELECT col positive_membership_test
+           |FROM $table
+           |WHERE might_contain(
+           |            (SELECT bloom_filter_agg(col,
+           |              cast($numEstimatedItems as long),
+           |              cast($veloxBloomFilterMaxNumBits as long))
+           |             FROM $table), col)
+           |""".stripMargin
+      withTempView(table) {
+        (Seq(Long.MinValue, 0, Long.MaxValue) ++ (1L to 200000L))
+          .toDF("col")
+          .createOrReplaceTempView(table)
+        withSQLConf(
+          GlutenConfig.COLUMNAR_NATIVE_BLOOMFILTER_ENABLED.key -> "false",
+          SQLConf.ANSI_ENABLED.key -> "false"
+        ) {
+          val df = spark.sql(sqlString)
+          assert(df.collect().length == 200003)
+          // Verify the rule early-exited: plan must NOT contain Velox 
variants.
+          assert(
+            
!df.queryExecution.executedPlan.toString.contains("velox_might_contain"),
+            "Expected vanilla BloomFilterMightContain when native bloom filter 
is disabled"
+          )
+        }
+      }
+    }
+  }
+
+  // GLUTEN-12013: verify that the bloom-filter subquery uses 
VeloxBloomFilterAggregate even when
+  // the aggregate node executes in JVM mode (ObjectHashAggregateExec).
+  //
+  // When hash-aggregate offloading is disabled, the subquery runs as
+  // ObjectHashAggregateExec(VeloxBloomFilterAggregate).  
VeloxBloomFilterAggregate.eval() calls
+  // serialize(buffer) directly without a cardinality guard, so it always 
produces Velox-format
+  // (version=1) bytes.  VeloxBloomFilterMightContain on the outer side reads 
those bytes correctly.
+  test("GLUTEN-12013: VeloxBloomFilterAggregate in JVM subquery produces 
correct Velox bytes") {
+    if 
(BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback())
 {
+      val table = "bloom_filter_test"
+      val numEstimatedItems = 5000000L
+      val sqlString =
+        s"""
+           |SELECT col positive_membership_test
+           |FROM $table
+           |WHERE might_contain(
+           |            (SELECT bloom_filter_agg(col,
+           |              cast($numEstimatedItems as long),
+           |              cast($veloxBloomFilterMaxNumBits as long))
+           |             FROM $table), col)
+           |""".stripMargin
+      withTempView(table) {
+        (Seq(Long.MinValue, 0, Long.MaxValue) ++ (1L to 200000L))
+          .toDF("col")
+          .createOrReplaceTempView(table)
+        // Disable hash-aggregate offloading: the bloom_filter_agg subquery 
executes in JVM mode as
+        // ObjectHashAggregateExec(VeloxBloomFilterAggregate).  This mirrors 
the q59 golden shape.
+        withSQLConf(
+          GlutenConfig.COLUMNAR_HASHAGG_ENABLED.key -> "false",
+          SQLConf.ANSI_ENABLED.key -> "false"
+        ) {
+          val df = spark.sql(sqlString)
+          // Must not throw: java.io.IOException: Unexpected Bloom filter 
version number.
+          // VeloxBloomFilterAggregate.eval() produces Velox-format bytes even 
in JVM mode.
+          val result = df.collect()
+          assert(result.length == 200003, s"Expected 200003 rows, got 
${result.length}")
+
+          // Directly verify the subquery's aggregate function class at 
runtime.
+          // ObjectHashAggregateExec(VeloxBloomFilterAggregate) must be 
present -- NOT vanilla
+          // BloomFilterAggregate -- so we know the physical rewrite actually 
happened.
+          val subqueryVeloxAggs = 
collectWithSubqueries(df.queryExecution.executedPlan) {
+            case agg: ObjectHashAggregateExec
+                if agg.aggregateExpressions.exists(
+                  _.aggregateFunction.isInstanceOf[VeloxBloomFilterAggregate]) 
=>
+              agg
+          }
+          assert(
+            subqueryVeloxAggs.nonEmpty,
+            "Expected ObjectHashAggregateExec(VeloxBloomFilterAggregate) in 
the bloom-filter " +
+              "subquery. Actual subquery aggs: " +
+              collectWithSubqueries(df.queryExecution.executedPlan) {
+                case agg: ObjectHashAggregateExec => agg
+              }.map(
+                a =>
+                  a.aggregateExpressions
+                    .map(_.aggregateFunction.getClass.getSimpleName)
+                    .mkString(","))
+                .mkString("; ")
+          )
+        }
+      }
+    }
+  }
+
+  // GLUTEN-12013 follow-up: runtime bloom filters injected by Spark's 
InjectRuntimeFilter must
+  // stay native.  These expressions only appear after Spark's optimizer has 
run, so they are only
+  // visible to a physical rule; BloomFilterMightContainJointRewriteRule 
rewrites them along with
+  // every other bloom filter.  Without that rewrite, vanilla 
might_contain/bloom_filter_agg have no
+  // Substrait mapping and the consuming FilterExec plus the producing 
aggregate fall back to the
+  // JVM with R2C/C2R transitions (the regression originally visible in the 
TPC-DS q59 golden).
+  test("GLUTEN-12013: runtime bloom filter keeps FilterExecTransformer 
native") {
+    withTable("bf_fact", "bf_dim") {
+      spark
+        .range(0, 10000)
+        .selectExpr("id as key", "id % 100 as payload")
+        .write
+        .saveAsTable("bf_fact")
+      spark
+        .range(0, 100)
+        .selectExpr("id as key", "id % 10 as f")
+        .write
+        .saveAsTable("bf_dim")
+      withSQLConf(
+        SQLConf.RUNTIME_BLOOM_FILTER_ENABLED.key -> "true",
+        SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key 
-> "3000",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ANSI_ENABLED.key -> "false"
+      ) {
+        val df = spark.sql(
+          "SELECT * FROM bf_fact JOIN bf_dim ON bf_fact.key = bf_dim.key WHERE 
bf_dim.f = 5")
+        assert(
+          df.queryExecution.optimizedPlan.toString.contains("might_contain"),
+          "Precondition failed: InjectRuntimeFilter did not inject a bloom 
filter"
+        )
+        assert(df.collect().length == 10)
+        // The consumer side must be a native FilterExecTransformer evaluating
+        // velox_might_contain -- not a fallen-back JVM FilterExec.
+        val nativeBloomFilters = 
collectWithSubqueries(df.queryExecution.executedPlan) {
+          case f: FilterExecTransformerBase
+              if f.cond.exists(_.isInstanceOf[VeloxBloomFilterMightContain]) =>
+            f
+        }
+        assert(
+          nativeBloomFilters.nonEmpty,
+          "Expected a native FilterExecTransformer with velox_might_contain; 
the runtime " +
+            s"bloom filter fell back to JVM. 
Plan:\n${df.queryExecution.executedPlan}"
+        )
+        // The producer side must use VeloxBloomFilterAggregate so the bytes 
are version=1.
+        val veloxAggs = collectWithSubqueries(df.queryExecution.executedPlan) {
+          case agg: BaseAggregateExec
+              if agg.aggregateExpressions.exists(
+                _.aggregateFunction.isInstanceOf[VeloxBloomFilterAggregate]) =>
+            agg
+        }
+        assert(
+          veloxAggs.nonEmpty,
+          "Expected VeloxBloomFilterAggregate in the runtime-filter subquery. 
Plan:\n" +
+            s"${df.queryExecution.executedPlan}"
+        )
+      }
+    }
+  }
+
+  // GLUTEN-12013 follow-up: closes the whole-stage reversion gap for runtime 
bloom filters.
+  // ExpandFallbackPolicy's whole-stage fallback can revert the runtime bloom 
filter's partial
+  // and final aggregation stages independently, since they are separate 
physical operators
+  // across a shuffle boundary. If only one side reverts, a native-Velox stage 
would otherwise
+  // pair with a vanilla-reverted stage. Registering the rewrite at injectPre 
closes this: the
+  // rewrite is already part of the plan the reversion falls back to, so both 
sides keep their
+  // Velox forms. This is only safe now that VeloxBloomFilterAggregate's 
JVM-side buffer sizing
+  // agrees with the native aggregate's (GLUTEN-12613): before that fix, a 
reverted final stage
+  // merging with a still-native partial stage silently corrupted the filter 
-- forcing a
+  // capacity mismatch here (without the fix) would drop rows instead of 
crashing.
+  test("GLUTEN-12013: runtime bloom filter survives whole-stage reversion of 
one stage") {
+    withTable("bf_fact", "bf_dim") {
+      spark
+        .range(0, 10000)
+        .selectExpr("id as key", "id % 100 as payload")
+        .write
+        .saveAsTable("bf_fact")
+      spark
+        .range(0, 100)
+        .selectExpr("id as key", "id % 10 as f")
+        .write
+        .saveAsTable("bf_dim")
+      // Threshold=1 forces ExpandFallbackPolicy to revert the runtime bloom 
filter's final
+      // aggregation stage (its own transition cost already meets the 
threshold) while the
+      // partial aggregation stage, in a separate shuffle-bounded physical 
operator, stays
+      // native. Without the injectFinal re-rewrite, this reverted final stage 
would be vanilla
+      // ObjectHashAggregateExec(BloomFilterAggregate), disagreeing on both 
byte format and (pre
+      // GLUTEN-12613) capacity with the still-native partial stage.
+      withSQLConf(
+        SQLConf.RUNTIME_BLOOM_FILTER_ENABLED.key -> "true",
+        SQLConf.RUNTIME_BLOOM_FILTER_APPLICATION_SIDE_SCAN_SIZE_THRESHOLD.key 
-> "3000",
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        GlutenConfig.COLUMNAR_WHOLESTAGE_FALLBACK_THRESHOLD.key -> "1",
+        SQLConf.ANSI_ENABLED.key -> "false"
+      ) {
+        val df = spark.sql(
+          "SELECT * FROM bf_fact JOIN bf_dim ON bf_fact.key = bf_dim.key WHERE 
bf_dim.f = 5")
+        assert(
+          df.queryExecution.optimizedPlan.toString.contains("might_contain"),
+          "Precondition failed: InjectRuntimeFilter did not inject a bloom 
filter"
+        )
+        // Must return all 10 matching rows, not a silently-corrupted subset.
+        val result = df.collect()
+        assert(
+          result.length == 10,
+          s"Expected 10 rows (reversion must not corrupt the bloom filter), 
got " +
+            s"${result.length}. Plan:\n${df.queryExecution.executedPlan}"
+        )
+        // Every aggregate function touching the runtime bloom filter must be 
the Velox variant,
+        // regardless of whether its own stage executes natively or was 
reverted to the JVM.
+        val vanillaAggs = 
collectWithSubqueries(df.queryExecution.executedPlan) {
+          case agg: BaseAggregateExec
+              if agg.aggregateExpressions.exists(
+                _.aggregateFunction.isInstanceOf[BloomFilterAggregate]) =>
+            agg
+        }
+        assert(
+          vanillaAggs.isEmpty,
+          "Expected no vanilla BloomFilterAggregate after reversion; 
injectFinal re-rewrite " +
+            s"should have restored the Velox variant. 
Plan:\n${df.queryExecution.executedPlan}"
+        )
+      }
+    }
+  }
+
+  // SPARK-54336 / GLUTEN-12013: a might_contain whose value argument is a 
literal (not a column),
+  // fed by a nested scalar subquery.  This mirrors Spark's upstream
+  // `BloomFilterAggregateQuerySuite."SPARK-54336"`.  Because the rewrite is 
unconditional, both the
+  // inner bloom_filter_agg and the outer might_contain become Velox forms 
together, so the byte
+  // formats agree.  The failure mode being guarded against is a partial 
rewrite: if only the outer
+  // side became velox_might_contain (version=1) while the inner aggregate 
stayed vanilla
+  // bloom_filter_agg and emitted version=0 bytes, deserialization would fail 
with
+  // (kBloomFilterV1 == version, 1 vs. 0).
+  //
+  // Gated to Spark 4.0+: the query exercises the exact 
`MergeScalarSubqueries` path that Spark's
+  // own SPARK-54336 fixes, and that fix only exists in Spark 4.0.2+/4.1.  On 
earlier Spark the
+  // analyzer/optimizer throws `UnresolvedException` (dataType on an 
unresolved ScalarSubquery)
+  // before Gluten's rule runs, so there is nothing for this test to verify 
there.
+  testWithMinSparkVersion(
+    "GLUTEN-12013: might_contain with a literal value keeps both sides 
consistent (SPARK-54336)",
+    "4.0") {
+    if 
(BackendsApiManager.getSettings.requireBloomFilterAggMightContainJointFallback())
 {
+      val table = "bloom_filter_lit_test"
+      withTempView(table) {
+        // Single non-null row [0]; the bloom filter therefore contains 0L.
+        Seq(0L).toDF("col").createOrReplaceTempView(table)
+        val sqlString =
+          s"""
+             |SELECT
+             |  (SELECT
+             |    first(might_contain(
+             |      (SELECT bloom_filter_agg(col) FROM $table),
+             |      0L
+             |    ))
+             |  FROM $table)
+             |FROM $table
+             |""".stripMargin
+        // Codegen-off, matching the upstream 
BloomFilterAggregateQuerySuiteCGOff variant that
+        // originally surfaced this crash.
+        withSQLConf(
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "false",
+          SQLConf.CODEGEN_FACTORY_MODE.key -> "NO_CODEGEN",
+          SQLConf.ANSI_ENABLED.key -> "false"
+        ) {
+          val df = spark.sql(sqlString)
+          // Producer and consumer must not end up on different byte formats.  
Either both sides
+          // are rewritten to their Velox forms, or neither is; a plan 
containing
+          // velox_might_contain without velox_bloom_filter_agg is the broken 
state.
+          val plan = df.queryExecution.executedPlan.toString
+          assert(
+            !plan.contains("velox_might_contain") || 
plan.contains("velox_bloom_filter_agg"),
+            s"Mismatched bloom filter byte formats: velox_might_contain 
without a matching " +
+              s"velox_bloom_filter_agg. 
Plan:\n${df.queryExecution.executedPlan}"
+          )
+          // Must not throw kBloomFilterV1 == version (1 vs. 0); 0L was 
inserted, so it is present.
+          checkAnswer(df, Row(true))
+        }
+      }
+    }
+  }
+}


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

Reply via email to