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

andygrove pushed a commit to branch branch-1.0
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/branch-1.0 by this push:
     new 0de93c30c4 fix: apply the parent struct's null mask before hashing its 
fields (#5754) (#5823)
0de93c30c4 is described below

commit 0de93c30c4fe04672ba9833e613e19006990b3c0
Author: Andy Grove <[email protected]>
AuthorDate: Wed Sep 9 21:47:17 2026 -0600

    fix: apply the parent struct's null mask before hashing its fields (#5754) 
(#5823)
---
 native/spark-expr/src/hash_funcs/murmur3.rs        | 85 +++++++++++++++++++
 native/spark-expr/src/hash_funcs/utils.rs          | 26 +++++-
 native/spark-expr/src/hash_funcs/xxhash64.rs       | 66 +++++++++++++++
 .../apache/comet/CometHashExpressionSuite.scala    | 97 +++++++++++++++++++++-
 4 files changed, 271 insertions(+), 3 deletions(-)

diff --git a/native/spark-expr/src/hash_funcs/murmur3.rs 
b/native/spark-expr/src/hash_funcs/murmur3.rs
index 233097ffc1..a9e5b67aa9 100644
--- a/native/spark-expr/src/hash_funcs/murmur3.rs
+++ b/native/spark-expr/src/hash_funcs/murmur3.rs
@@ -302,6 +302,91 @@ mod tests {
         );
     }
 
+    /// Arrow lets a `StructArray`'s children carry their own validity, so at 
a row where the struct
+    /// itself is null the child buffer can still hold a value. Spark hashes a 
null struct as the
+    /// seed, so those hidden child values must not reach the hash. This is 
the same null-mask
+    /// propagation problem that #4432 fixed for `GetStructField`.
+    #[test]
+    fn test_null_struct_ignores_hidden_child_values() {
+        use arrow::array::{Int32Array, StructArray};
+        use arrow::buffer::NullBuffer;
+        use arrow::datatypes::{DataType, Field, Fields};
+
+        let fields: Fields = vec![Arc::new(Field::new("a", DataType::Int32, 
true))].into();
+        // Row 1 is a null struct whose child still holds 999.
+        let child: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), 
Some(999)]));
+        let nulls = NullBuffer::from(vec![true, false]);
+        let with_hidden: ArrayRef = Arc::new(StructArray::new(
+            fields.clone(),
+            vec![Arc::clone(&child)],
+            Some(nulls.clone()),
+        ));
+        // Same shape, but the hidden slot is null too.
+        let child_null: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), 
None]));
+        let without_hidden: ArrayRef =
+            Arc::new(StructArray::new(fields, vec![child_null], Some(nulls)));
+
+        let mut a = vec![42u32; 2];
+        create_murmur3_hashes(&[with_hidden], &mut a).unwrap();
+        let mut b = vec![42u32; 2];
+        create_murmur3_hashes(&[without_hidden], &mut b).unwrap();
+
+        assert_eq!(
+            a, b,
+            "a null struct must hash the same regardless of what its child 
buffer holds"
+        );
+        assert_eq!(a[1], 42, "a null struct must leave the seed untouched");
+    }
+
+    /// The struct branch is also reached once per element when hashing 
`array<struct<..>>`, which
+    /// is the path #5567 made usable as a shuffle partitioning key. The test 
above hashes a struct
+    /// directly, so it does not cover that route.
+    ///
+    /// Here the null is the list *element* itself, with valid elements either 
side so the chaining
+    /// is exercised. The end-to-end test in `CometHashExpressionSuite` covers 
the other shape, a
+    /// valid element wrapping a null struct, which is the one a query can 
produce.
+    #[test]
+    fn test_null_struct_element_of_list_ignores_hidden_child_values() {
+        use arrow::array::{Int32Array, ListArray, StructArray};
+        use arrow::buffer::{NullBuffer, OffsetBuffer};
+        use arrow::datatypes::{DataType, Field, Fields};
+
+        let fields: Fields = vec![Arc::new(Field::new("a", DataType::Int32, 
true))].into();
+        // Element 1 is a null struct whose child still holds 999; elements 0 
and 2 are valid.
+        let element_nulls = NullBuffer::from(vec![true, false, true]);
+        let with_hidden: ArrayRef = Arc::new(StructArray::new(
+            fields.clone(),
+            vec![Arc::new(Int32Array::from(vec![Some(1), Some(999), Some(3)])) 
as ArrayRef],
+            Some(element_nulls.clone()),
+        ));
+        // The same shape with the hidden slot null as well.
+        let without_hidden: ArrayRef = Arc::new(StructArray::new(
+            fields,
+            vec![Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])) as 
ArrayRef],
+            Some(element_nulls),
+        ));
+
+        // One row holding all three elements, so element hashes chain in 
order.
+        let as_list = |elements: ArrayRef| -> ArrayRef {
+            Arc::new(ListArray::new(
+                Arc::new(Field::new("item", elements.data_type().clone(), 
true)),
+                OffsetBuffer::new(vec![0i32, 3].into()),
+                elements,
+                None,
+            ))
+        };
+
+        let mut from_hidden = vec![42u32; 1];
+        create_murmur3_hashes(&[as_list(with_hidden)], &mut 
from_hidden).unwrap();
+        let mut from_null = vec![42u32; 1];
+        create_murmur3_hashes(&[as_list(without_hidden)], &mut 
from_null).unwrap();
+
+        assert_eq!(
+            from_hidden, from_null,
+            "a null struct element must hash the same regardless of its child 
buffer"
+        );
+    }
+
     #[test]
     fn test_i8() {
         test_murmur3_hash::<i8, Int8Array>(
diff --git a/native/spark-expr/src/hash_funcs/utils.rs 
b/native/spark-expr/src/hash_funcs/utils.rs
index 18e5a41bc7..4186751f92 100644
--- a/native/spark-expr/src/hash_funcs/utils.rs
+++ b/native/spark-expr/src/hash_funcs/utils.rs
@@ -828,8 +828,30 @@ macro_rules! create_hashes_internal {
                 }
                 DataType::Struct(_) => {
                     let struct_array = 
col.as_any().downcast_ref::<StructArray>().unwrap();
-                    // Hash each field of the struct - Spark hashes all fields 
recursively
-                    let columns: Vec<ArrayRef> = 
struct_array.columns().to_vec();
+                    // Hash each field of the struct - Spark hashes all fields 
recursively.
+                    //
+                    // Arrow keeps a struct's children validity independent of 
the parent's, so at a
+                    // row where the struct is null a child buffer can still 
hold a value. Spark
+                    // hashes a null struct as the seed, so the parent's nulls 
have to be pushed
+                    // into each child before recursing, the same way #4432 
fixed `GetStructField`.
+                    // Without it a null struct hashes whatever happens to sit 
in the child slot.
+                    // `flatten` does exactly this union, and skips 
revalidating the child data
+                    // buffers: it only ever adds nulls, so the buffers 
themselves are unchanged.
+                    // Rebuilding them through the checked builder would 
rescan every child buffer
+                    // (for a string child, the whole UTF-8 values buffer) on 
each call, and this
+                    // branch is reached once per element when hashing a list 
of structs.
+                    //
+                    // Only call it when there is actually a null to push 
down. `flatten` returns
+                    // early when there is no null buffer at all, but with a 
buffer present it
+                    // builds a fresh `Fields` with every non-nullable field 
re-marked nullable,
+                    // which this call site discards. So the case worth 
skipping is a buffer that
+                    // is present and all-valid -- what slicing leaves behind 
-- which would
+                    // otherwise pay a `Vec` and an `Arc<[FieldRef]>` for 
nothing. `NullBuffer`
+                    // caches its null count, so the test itself is O(1).
+                    let columns: Vec<ArrayRef> = match struct_array.nulls() {
+                        Some(nulls) if nulls.null_count() > 0 => 
struct_array.flatten().1,
+                        _ => struct_array.columns().to_vec(),
+                    };
                     if !columns.is_empty() {
                         $recursive_hash_method(&columns, $hashes_buffer)?;
                     }
diff --git a/native/spark-expr/src/hash_funcs/xxhash64.rs 
b/native/spark-expr/src/hash_funcs/xxhash64.rs
index 7009fc99c2..93ac9304fc 100644
--- a/native/spark-expr/src/hash_funcs/xxhash64.rs
+++ b/native/spark-expr/src/hash_funcs/xxhash64.rs
@@ -208,6 +208,72 @@ mod tests {
         assert_eq!(from_dict, from_decoded);
     }
 
+    /// The struct branch is shared with murmur3 through 
`create_hashes_internal!`, so the parent
+    /// null mask has to reach xxhash64's children too. See #4432 for the same 
problem in
+    /// `GetStructField`.
+    #[test]
+    fn test_null_struct_ignores_hidden_child_values() {
+        use arrow::array::StructArray;
+        use arrow::buffer::NullBuffer;
+        use arrow::datatypes::{DataType, Field, Fields};
+
+        let fields: Fields = vec![Arc::new(Field::new("a", DataType::Int32, 
true))].into();
+        let nulls = NullBuffer::from(vec![true, false]);
+        let hidden: ArrayRef = Arc::new(StructArray::new(
+            fields.clone(),
+            vec![Arc::new(Int32Array::from(vec![Some(1), Some(999)])) as 
ArrayRef],
+            Some(nulls.clone()),
+        ));
+        let plain: ArrayRef = Arc::new(StructArray::new(
+            fields,
+            vec![Arc::new(Int32Array::from(vec![Some(1), None])) as ArrayRef],
+            Some(nulls),
+        ));
+
+        let mut a = vec![42u64; 2];
+        create_xxhash64_hashes(&[hidden], &mut a).unwrap();
+        let mut b = vec![42u64; 2];
+        create_xxhash64_hashes(&[plain], &mut b).unwrap();
+        assert_eq!(a, b, "a null struct must hash the same either way");
+        assert_eq!(a[1], 42, "a null struct must leave the seed untouched");
+    }
+
+    /// Companion to the murmur3 case: the struct branch is shared through the 
macro, so the
+    /// per-element route needs covering here too.
+    #[test]
+    fn test_null_struct_element_of_list_ignores_hidden_child_values() {
+        use arrow::array::{ListArray, StructArray};
+        use arrow::buffer::{NullBuffer, OffsetBuffer};
+        use arrow::datatypes::{DataType, Field, Fields};
+
+        let fields: Fields = vec![Arc::new(Field::new("a", DataType::Int32, 
true))].into();
+        let element_nulls = NullBuffer::from(vec![true, false, true]);
+        let with_hidden: ArrayRef = Arc::new(StructArray::new(
+            fields.clone(),
+            vec![Arc::new(Int32Array::from(vec![Some(1), Some(999), Some(3)])) 
as ArrayRef],
+            Some(element_nulls.clone()),
+        ));
+        let without_hidden: ArrayRef = Arc::new(StructArray::new(
+            fields,
+            vec![Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])) as 
ArrayRef],
+            Some(element_nulls),
+        ));
+        let as_list = |elements: ArrayRef| -> ArrayRef {
+            Arc::new(ListArray::new(
+                Arc::new(Field::new("item", elements.data_type().clone(), 
true)),
+                OffsetBuffer::new(vec![0i32, 3].into()),
+                elements,
+                None,
+            ))
+        };
+
+        let mut from_hidden = vec![42u64; 1];
+        create_xxhash64_hashes(&[as_list(with_hidden)], &mut 
from_hidden).unwrap();
+        let mut from_null = vec![42u64; 1];
+        create_xxhash64_hashes(&[as_list(without_hidden)], &mut 
from_null).unwrap();
+        assert_eq!(from_hidden, from_null);
+    }
+
     #[test]
     fn test_i8() {
         test_xxhash64_hash::<i8, Int8Array>(
diff --git 
a/spark/src/test/scala/org/apache/comet/CometHashExpressionSuite.scala 
b/spark/src/test/scala/org/apache/comet/CometHashExpressionSuite.scala
index 23a539fa1e..c997da0012 100644
--- a/spark/src/test/scala/org/apache/comet/CometHashExpressionSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometHashExpressionSuite.scala
@@ -21,8 +21,9 @@ package org.apache.comet
 
 import scala.util.Random
 
-import org.apache.spark.sql.CometTestBase
+import org.apache.spark.sql.{CometTestBase, Row}
 import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
 
 import org.apache.comet.testing.{DataGenOptions, FuzzDataGenerator, 
ParquetGenerator, SchemaGenOptions}
 
@@ -256,6 +257,100 @@ class CometHashExpressionSuite extends CometTestBase with 
AdaptiveSparkPlanHelpe
     }
   }
 
+  test("hash - null struct with a required child field") {
+    // `c` is nullable but its child is REQUIRED, so Spark writes
+    // `optional group c { required int32 a; }`. On read the child leaf has 
nowhere to record a
+    // null of its own, so its buffer holds a value at exactly the rows where 
the struct is null.
+    // That is the shape where the parent's null mask has to reach the 
children; a struct built in
+    // the plan, or one whose child is also nullable, has null children there 
and hides the bug.
+    withTempPath { dir =>
+      val schema = StructType(
+        Seq(
+          StructField(
+            "c",
+            StructType(Seq(StructField("a", IntegerType, nullable = false))),
+            nullable = true)))
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        val rows = Seq(Row(Row(1)), Row(null), Row(Row(3)), Row(null))
+        spark
+          .createDataFrame(spark.sparkContext.parallelize(rows), schema)
+          .coalesce(1)
+          .write
+          .parquet(dir.toString)
+      }
+      spark.read.parquet(dir.toString).createOrReplaceTempView("null_struct_t")
+      checkSparkAnswerAndOperator("SELECT hash(c), xxhash64(c) FROM 
null_struct_t ORDER BY 1, 2")
+    }
+  }
+
+  test("hash - null struct whose child is itself a struct") {
+    // The union has to recurse: the outer struct's nulls reach the inner 
struct, whose own
+    // children are required and so carry values under the null.
+    withTempPath { dir =>
+      val inner = StructType(Seq(StructField("x", IntegerType, nullable = 
false)))
+      val schema = StructType(
+        Seq(
+          StructField(
+            "c",
+            StructType(Seq(StructField("b", inner, nullable = false))),
+            nullable = true)))
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        val rows = Seq(Row(Row(Row(1))), Row(null), Row(Row(Row(3))), 
Row(null))
+        spark
+          .createDataFrame(spark.sparkContext.parallelize(rows), schema)
+          .coalesce(1)
+          .write
+          .parquet(dir.toString)
+      }
+      
spark.read.parquet(dir.toString).createOrReplaceTempView("null_nested_struct_t")
+      checkSparkAnswerAndOperator(
+        "SELECT hash(c), xxhash64(c) FROM null_nested_struct_t ORDER BY 1, 2")
+    }
+  }
+
+  test("hash - list element wrapping a null struct with a required child 
field") {
+    // The per-element path in `hash_list_array!`, which #5567 made usable as 
a shuffle key.
+    //
+    // Wrapping the struct rather than using it as the element directly is 
what makes this
+    // reachable: `array(named_struct('tag', 1, 'b', c))` produces an element 
that is itself
+    // valid, so it is copied rather than rebuilt, and the null `c` inside it 
keeps the child
+    // values Parquet wrote under it. Using `c` as the element directly does 
not reproduce,
+    // because a null element is rebuilt on the way in and the hidden values 
go with it.
+    //
+    // So this covers a null struct nested inside a valid element. The unit 
test in murmur3.rs
+    // covers the complementary shape, where the element itself is the null 
struct.
+    withTempPath { dir =>
+      val schema = StructType(
+        Seq(
+          StructField(
+            "c",
+            StructType(Seq(StructField("a", IntegerType, nullable = false))),
+            nullable = true)))
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        val rows = Seq(Row(Row(1)), Row(null), Row(Row(3)), Row(null))
+        spark
+          .createDataFrame(spark.sparkContext.parallelize(rows), schema)
+          .coalesce(1)
+          .write
+          .parquet(dir.toString)
+      }
+      
spark.read.parquet(dir.toString).createOrReplaceTempView("wrapped_null_struct_t")
+
+      checkSparkAnswerAndOperator("""
+        SELECT
+          hash(array(named_struct('tag', 1, 'b', c))),
+          xxhash64(array(named_struct('tag', 1, 'b', c)))
+        FROM wrapped_null_struct_t ORDER BY 1, 2""")
+
+      // Two elements, so the hash of the second chains onto the first.
+      checkSparkAnswerAndOperator("""
+        SELECT
+          hash(array(named_struct('tag', 1, 'b', c), named_struct('tag', 2, 
'b', c))),
+          xxhash64(array(named_struct('tag', 1, 'b', c), named_struct('tag', 
2, 'b', c)))
+        FROM wrapped_null_struct_t ORDER BY 1, 2""")
+    }
+  }
+
   test("hash - struct with array field") {
     withTable("t") {
       sql("CREATE TABLE t(c STRUCT<a: INT, b: ARRAY<STRING>>) USING parquet")


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

Reply via email to