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

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


The following commit(s) were added to refs/heads/master by this push:
     new d058558df043 fix(spark): reorder nested variants on Spark 4.0 (#20161)
d058558df043 is described below

commit d058558df04364d614c728874fcf5672dafd881f
Author: voonhous <[email protected]>
AuthorDate: Thu Oct 1 12:24:22 2026 +0800

    fix(spark): reorder nested variants on Spark 4.0 (#20161)
    
    Spark 4.0 reads a parquet variant group by position as [value,
    metadata] while Hudi writes [metadata, value] (#18334), so
    Spark40HoodieParquetReadSupport reorders the group in the requested
    schema. Since #18674 that reorder, when handed the catalyst schema,
    covered top-level variant columns only. A variant below a struct, in
    an array or in a map value kept the file order on the row-based
    path: once an update sent the read through the file group reader
    (MOR) or the Spark-record merge handle (COW), the read failed with
    MALFORMED_VARIANT, and the COW rewrite persisted the swapped bytes
    for the rows it carried over.
    
    Walk the catalyst schema through structs, arrays and maps and reorder
    every group it types as VariantType. A plain struct with the same
    parquet shape is still left alone, at any depth.
    
    CI builds no Spark 4.0 profile. Run locally on Spark 4.0.2: the new
    unit test and the end-to-end test fail without the change and pass
    with it.
    
    Fixes #20157.
---
 .../sql/hudi/dml/schema/TestVariantDataType.scala  | 46 +++++++++++++++
 .../parquet/Spark40HoodieParquetReadSupport.scala  | 68 +++++++++++++++++-----
 .../TestSpark40HoodieParquetReadSupport.scala      | 68 +++++++++++++++++++++-
 3 files changed, 166 insertions(+), 16 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
index ec8637383cbd..c0b8ad06c3f9 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
@@ -198,6 +198,52 @@ class TestVariantDataType extends HoodieSparkSqlTestBase 
with VariantShreddingTe
     })
   }
 
+  test("Test variants nested in structs, arrays and maps read back after an 
update") {
+    assume(HoodieSparkUtils.gteqSpark4_0, "Variant type requires Spark 4.0 or 
higher")
+    // Spark 4.0 reads a variant group by position as [value, metadata] and 
Hudi reorders the
+    // requested group to match. Before #20157 only top-level variant columns 
were reordered on the
+    // row-based path, so once an update sent the read through the file group 
reader (MOR) or the
+    // Spark-record merge handle (COW), a variant below a struct, an array or 
a map came back with
+    // its bytes swapped: MALFORMED_VARIANT on read, and swapped bytes on disk 
after the COW rewrite.
+    Seq("cow", "mor").foreach { tableType =>
+      withRecordType()(withTempDir { tmp =>
+        val tableName = generateTableName
+        spark.sql(
+          s"""
+             |create table $tableName (
+             |  id int,
+             |  v variant,
+             |  s struct<inner: variant>,
+             |  arr array<variant>,
+             |  m map<string, variant>,
+             |  ts long
+             |) using hudi
+             | location '${tmp.getCanonicalPath}'
+             | tblproperties (
+             |  primaryKey = 'id',
+             |  preCombineField = 'ts',
+             |  type = '$tableType',
+             |  hoodie.compact.inline = 'false'
+             | )
+           """.stripMargin)
+        spark.sql(s"insert into $tableName select cast(id as int) as id, " +
+          """parse_json(concat('{"k":"v', id, '"}')) as v, """ +
+          """named_struct('inner', parse_json(concat('{"k":"s', id, '"}'))) as 
s, """ +
+          """array(parse_json(concat('{"k":"a', id, '"}'))) as arr, """ +
+          """map('a', parse_json(concat('{"k":"m', id, '"}'))) as m, """ +
+          "1000L as ts from range(0, 4, 1, 1)")
+        // Assigns ts alone: ids 0 and 1 are carried over by the COW rewrite, 
ids 2 and 3 go through
+        // the merge (a log file on MOR).
+        spark.sql(s"update $tableName set ts = 1001 where id >= 2")
+
+        checkAnswer(s"select id, variant_get(v, '$$.k', 'string'), 
variant_get(s.inner, '$$.k', 'string'), " +
+          s"variant_get(arr[0], '$$.k', 'string'), variant_get(m['a'], '$$.k', 
'string'), ts " +
+          s"from $tableName order by id")(
+          (0 until 4).map(id => Seq(id, s"v$id", s"s$id", s"a$id", s"m$id", if 
(id >= 2) 1001L else 1000L)): _*)
+      })
+    }
+  }
+
   test("Test Spark 4.0 rejects reads rewritten by pushVariantIntoScan") {
     // The conf is off by default on Spark 4.0 and on from 4.1 (SPARK-54454). 
Spark 4.0 still runs
     // the rewrite once it is set but cannot read the projection struct it 
produces, so Hudi fails
diff --git 
a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40HoodieParquetReadSupport.scala
 
b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40HoodieParquetReadSupport.scala
index 377186036f63..f94c3560eb2a 100644
--- 
a/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40HoodieParquetReadSupport.scala
+++ 
b/hudi-spark-datasource/hudi-spark4.0.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark40HoodieParquetReadSupport.scala
@@ -55,8 +55,8 @@ class Spark40HoodieParquetReadSupport(
   override def init(context: InitContext): ReadContext = {
     val baseContext = super.init(context)
     // Resolve the Spark catalyst requested schema so the reorder is gated on
-    // VariantType -- a user struct that happens to be <value: binary, 
metadata: binary>
-    // shouldn't be silently reshuffled.
+    // VariantType at any depth -- a user struct that happens to be
+    // <value: binary, metadata: binary> shouldn't be silently reshuffled.
     val sparkRequestedSchema = Option(context.getConfiguration.get(
       ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA))
       .map(StructType.fromString)
@@ -76,26 +76,66 @@ object Spark40HoodieParquetReadSupport {
    * parquet-mr reconciles the requested schema against the file schema by 
field name,
    * so the correct bytes still flow to the correct converters regardless of 
file order.
    *
-   * When a Spark catalyst schema is supplied, reorder only the top-level 
fields that are
-   * actually typed `VariantType` in catalyst; this prevents reshuffling a 
user-defined
-   * `struct<value: binary, metadata: binary>` that happens to match the 
parquet shape.
+   * When a Spark catalyst schema is supplied, the walk is anchored on it and 
recurses through
+   * structs, arrays and maps, so only what catalyst types as `VariantType` is 
reordered, at any
+   * depth; a user-defined `struct<value: binary, metadata: binary>` that 
happens to match the
+   * parquet shape is left alone wherever it sits.
    *
    * Shredded groups are left untouched here; rejecting them is 
[[rejectShreddedVariants]]'s
-   * job, which walks the whole schema rather than just the top level.
+   * job.
    */
   def reorderVariantFields(schema: MessageType, sparkSchema: 
Option[StructType] = None): MessageType = {
-    val variantFieldNames: Set[String] = sparkSchema match {
-      case Some(s) => s.fields.collect { case f if 
f.dataType.isInstanceOf[VariantType] => f.name }.toSet
-      case None => null
+    val reordered = sparkSchema match {
+      case Some(catalyst) => reorderFields(schema, catalyst)
+      case None => schema.getFields.asScala.map(reorderVariantType).toSeq
     }
-    val reordered = schema.getFields.asScala.map { f =>
-      if (variantFieldNames == null || variantFieldNames.contains(f.getName)) {
-        reorderVariantType(f)
-      } else f
-    }.toArray[Type]
     Types.buildMessage().addFields(reordered: _*).named(schema.getName)
   }
 
+  /** Reorders the fields of `group` against `struct`; fields the struct does 
not name are left as they are. */
+  private def reorderFields(group: GroupType, struct: StructType): Seq[Type] = 
{
+    group.getFields.asScala.map { field =>
+      struct.fields.find(_.name == field.getName) match {
+        case Some(catalystField) => reorderByCatalyst(field, 
catalystField.dataType)
+        case None => field
+      }
+    }.toSeq
+  }
+
+  private def reorderByCatalyst(parquetType: Type, dataType: DataType): Type = 
{
+    (parquetType, dataType) match {
+      case (group: GroupType, _: VariantType) =>
+        reorderVariantType(group)
+      case (group: GroupType, struct: StructType) =>
+        group.withNewFields(reorderFields(group, struct).asJava)
+      case (group: GroupType, array: ArrayType) =>
+        ParquetSchemaEvolutionUtils.parquetListElement(group)
+          .map(element => replaceType(group, element, 
reorderByCatalyst(element, array.elementType)))
+          .getOrElse(group)
+      case (group: GroupType, map: MapType) =>
+        ParquetSchemaEvolutionUtils.parquetMapValue(group)
+          .map(value => replaceType(group, value, reorderByCatalyst(value, 
map.valueType)))
+          .getOrElse(group)
+      case _ => parquetType
+    }
+  }
+
+  /**
+   * Rebuilds `parent` with its descendant `target` (matched by reference) 
swapped for
+   * `replacement`. The by-reference match covers both list layouts: in the 
2-level one the
+   * repeated group is itself the element, so `target` can be a direct child 
of `parent`.
+   */
+  private def replaceType(parent: Type, target: Type, replacement: Type): Type 
= {
+    if (parent eq target) {
+      replacement
+    } else if (parent.isPrimitive) {
+      parent
+    } else {
+      val group = parent.asGroupType()
+      group.withNewFields(group.getFields.asScala.map(replaceType(_, target, 
replacement)).asJava)
+    }
+  }
+
   private def reorderVariantType(t: Type): Type = {
     t match {
       case group: GroupType if isVariantGroup(group) && 
group.containsField("typed_value") =>
diff --git 
a/hudi-spark-datasource/hudi-spark4.0.x/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark40HoodieParquetReadSupport.scala
 
b/hudi-spark-datasource/hudi-spark4.0.x/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark40HoodieParquetReadSupport.scala
index 1e61d93180e1..74f5852ce6fa 100644
--- 
a/hudi-spark-datasource/hudi-spark4.0.x/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark40HoodieParquetReadSupport.scala
+++ 
b/hudi-spark-datasource/hudi-spark4.0.x/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark40HoodieParquetReadSupport.scala
@@ -19,11 +19,11 @@ package org.apache.spark.sql.execution.datasources.parquet
 
 import org.apache.hudi.exception.HoodieException
 
+import org.apache.parquet.schema.{MessageType, Type, Types}
 import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
-import org.apache.parquet.schema.Types
 import org.apache.spark.sql.execution.datasources.VariantMetadata
 import 
org.apache.spark.sql.execution.datasources.parquet.VariantParquetTestFixtures.{shreddedVariant,
 stringKeyMap, threeLevelList, twoLevelList, unshreddedVariant}
-import org.apache.spark.sql.types.{ArrayType, BinaryType, IntegerType, 
MapType, MetadataBuilder, StringType, StructField, StructType, VariantType}
+import org.apache.spark.sql.types.{ArrayType, BinaryType, DataType, 
IntegerType, MapType, MetadataBuilder, StringType, StructField, StructType, 
VariantType}
 import org.junit.jupiter.api.{Assertions, Test}
 
 class TestSpark40HoodieParquetReadSupport {
@@ -198,6 +198,70 @@ class TestSpark40HoodieParquetReadSupport {
     Assertions.assertEquals("metadata", group.getFields.get(1).getName)
   }
 
+  /**
+   * With a catalyst schema the reorder follows it through structs, arrays and 
maps (#20157): a
+   * variant at the top level, below one or two structs, as a list element in 
either layout, as a
+   * map value or inside a list's struct element comes back as [value, 
metadata], and nothing
+   * else in the message changes.
+   */
+  @Test
+  def testReorderVariantFieldsReordersNestedVariants(): Unit = {
+    val result = Spark40HoodieParquetReadSupport.reorderVariantFields(
+      nestedVariantMessage(unshreddedVariant), 
Some(nestedVariantCatalyst(VariantType)))
+    Assertions.assertEquals(nestedVariantMessage(valueFirstVariant), result)
+  }
+
+  /**
+   * The same parquet shapes typed as a plain struct of two binaries in 
catalyst keep the file
+   * order [metadata, value] at every depth.
+   */
+  @Test
+  def testReorderVariantFieldsLeavesPlainStructsAtAnyDepth(): Unit = {
+    val schema = nestedVariantMessage(unshreddedVariant)
+    val plainStruct = new StructType().add("metadata", 
BinaryType).add("value", BinaryType)
+    val result = Spark40HoodieParquetReadSupport.reorderVariantFields(
+      schema, Some(nestedVariantCatalyst(plainStruct)))
+    Assertions.assertEquals(schema, result)
+  }
+
+  /**
+   * Every variant position the catalyst-anchored reorder walks, each group 
built by `variant`:
+   * v, s.inner, d.mid.inner, the 3-level list l3, the 2-level (parquet-avro) 
list l2 of
+   * struct<inner>, the map m and the 3-level list items of struct<inner>.
+   */
+  private def nestedVariantMessage(variant: String => Type): MessageType = {
+    def innerStruct(name: String): Type = 
Types.optionalGroup().addField(variant("inner")).named(name)
+    Types.buildMessage()
+      .addField(variant("v"))
+      .addField(innerStruct("s"))
+      .addField(Types.optionalGroup().addField(innerStruct("mid")).named("d"))
+      .addField(threeLevelList("l3", variant("element")))
+      .addField(twoLevelList("l2", "array", variant("inner")))
+      .addField(stringKeyMap("m", variant("value")))
+      .addField(threeLevelList("items", innerStruct("element")))
+      .named("test")
+  }
+
+  /** The catalyst schema of [[nestedVariantMessage]], typing each 
variant-shaped group as `leafType`. */
+  private def nestedVariantCatalyst(leafType: DataType): StructType = {
+    val inner = new StructType().add("inner", leafType)
+    new StructType()
+      .add("v", leafType)
+      .add("s", inner)
+      .add("d", new StructType().add("mid", inner))
+      .add("l3", ArrayType(leafType))
+      .add("l2", ArrayType(inner))
+      .add("m", MapType(StringType, leafType))
+      .add("items", ArrayType(inner))
+  }
+
+  /** [[unshreddedVariant]] in the [value, metadata] order Spark 4.0 reads by 
position. */
+  private def valueFirstVariant(name: String): Type =
+    Types.optionalGroup()
+      .addField(Types.required(PrimitiveTypeName.BINARY).named("value"))
+      .addField(Types.required(PrimitiveTypeName.BINARY).named("metadata"))
+      .named(name)
+
   /**
    * Spark's PushVariantIntoScan rewrite of one variant column: extraction 
fields named by
    * ordinal, each carrying the marker metadata the rewrite arm keys on.

Reply via email to