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

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


The following commit(s) were added to refs/heads/branch-1.2.x by this push:
     new 1768edc35bc5 fix(variant): port #20136 to branch-1.2.x (#20184)
1768edc35bc5 is described below

commit 1768edc35bc565d40286ed69c336068b677003f0
Author: voonhous <[email protected]>
AuthorDate: Fri Oct 2 18:15:29 2026 +0800

    fix(variant): port #20136 to branch-1.2.x (#20184)
    
    On Spark 4.1, where spark.sql.variant.pushVariantIntoScan is on by
    default, a variant column added by ALTER TABLE ADD COLUMNS without
    schema-on-read cannot be read from files written before the DDL:
    the pushed-down read fails with INVALID_VARIANT_SHREDDING_SCHEMA on
    COW and MOR, base files and log blocks alike. Hudi's readers turn
    nested schema pruning off, so the missing column stayed in the
    parquet read schema as a plain group, over which Spark builds its
    variant converter.
    
    Port of master 827644e8699b. HoodieParquetReadSupport and its unit
    test apply as they are. Master's end-to-end test runs over shredded
    files, which this branch cannot write, so TestVariantPushVariantIntoScan
    gets an unshredded version on its table type x rule x record type
    sweep: reads over the old files only, after an insert that carries
    v2, after an update of one row per file group (the COW merge handle;
    on MOR beside a log block written before the DDL) and, on MOR, after
    compaction. The rule-on legs and the unit pin fail without the fix.
---
 .../parquet/HoodieParquetReadSupport.scala         |  17 +++-
 .../parquet/TestHoodieParquetReadSupport.scala     |  36 +++++++
 .../schema/TestVariantPushVariantIntoScan.scala    | 108 +++++++++++++++++++++
 3 files changed, 159 insertions(+), 2 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
index c63ec0eef9e4..a164d09838a9 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
@@ -60,16 +60,29 @@ object HoodieParquetReadSupport {
    * schema is trimmed down to the requested fields. This can happen when the 
table schema evolves and only a subset of
    * the nested fields are required by the query.
    *
+   * A top-level field the file does not have at all (a column added by DDL, 
or by a later write) is
+   * removed as well. ParquetRowConverter builds its converters per parquet 
field and leaves the
+   * catalyst columns it never sees null, which is what Spark's own row-based 
reader gets from
+   * intersectParquetGroups when nested schema pruning is on (the file group 
reader switches it
+   * off). Kept, the field reaches the converter as a group synthesised from 
the catalyst type, and
+   * for a Spark 4.1 variant requested as the PushVariantIntoScan projection 
struct that is a plain
+   * group the variant converter rejects (INVALID_VARIANT_SHREDDING_SCHEMA, 
#20135).
+   *
+   * This holds whatever enableVectorizedReader the read support was built 
with: every reader that
+   * uses it is row-based, HoodieSparkParquetReader included although it 
passes true, and the
+   * vectorized readers pin READ_SUPPORT_CLASS to Spark's own 
ParquetReadSupport.
+   *
    * @param requestedSchema the initial parquet schema requested by Spark
    * @param fileSchema the actual parquet schema of the file
-   * @return a potentially updated schema with empty struct fields removed
+   * @return a potentially updated schema with missing fields and empty struct 
fields removed
    */
   def trimParquetSchema(requestedSchema: MessageType, fileSchema: 
MessageType): MessageType = {
     val trimmedFields = requestedSchema.getFields.asScala.map(field => {
       if (fileSchema.containsField(field.getName)) {
         trimParquetType(field, fileSchema.asGroupType().getType(field.getName))
       } else {
-        Some(field)
+        // Not in the file: left out of the read schema, and the row converter 
null-fills it.
+        None
       }
     }).filter(_.isDefined).map(_.get).toArray[Type]
     Types.buildMessage().addFields(trimmedFields: 
_*).named(requestedSchema.getName)
diff --git 
a/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
 
b/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
index 973454d1b70c..6461c9fe853f 100644
--- 
a/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
+++ 
b/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
@@ -113,4 +113,40 @@ class TestHoodieParquetReadSupport {
         .named("required")
     Assertions.assertEquals(expectedSchema, trimmedSchema)
   }
+
+  /**
+   * A top-level field the file does not have at all (a column added by DDL, 
say) is dropped from
+   * the read schema; the row converter leaves the catalyst column it never 
sees null. The missing
+   * field is shaped like the Spark 4.1 PushVariantIntoScan projection struct 
of a variant column,
+   * the case that fails when it is kept (#20135): the synthesised group is 
not a variant group,
+   * yet the variant converter is built over it. A request left with no field 
at all trims to an
+   * empty message.
+   */
+  @Test
+  def testSchemaTrimmingMissingTopLevelField(): Unit = {
+    val projectionStruct = Types.optionalGroup()
+        .addField(Types.optional(PrimitiveTypeName.BINARY).named("0"))
+        .named("v2")
+    val requiredSchema = Types.buildMessage()
+        .addField(Types.required(PrimitiveTypeName.INT32).named("id"))
+        .addField(Types.optional(PrimitiveTypeName.BINARY).named("v"))
+        .addField(projectionStruct)
+        .named("required")
+    val dataSchema = Types.buildMessage()
+        .addField(Types.required(PrimitiveTypeName.INT32).named("id"))
+        .addField(Types.optional(PrimitiveTypeName.BINARY).named("v"))
+        .named("data")
+
+    val expectedSchema = Types.buildMessage()
+        .addField(Types.required(PrimitiveTypeName.INT32).named("id"))
+        .addField(Types.optional(PrimitiveTypeName.BINARY).named("v"))
+        .named("required")
+    Assertions.assertEquals(expectedSchema, 
HoodieParquetReadSupport.trimParquetSchema(requiredSchema, dataSchema))
+
+    // A request whose only field is missing trims to an empty message, and 
the reader returns
+    // rows of nulls for it.
+    val onlyMissingField = 
Types.buildMessage().addField(projectionStruct).named("required")
+    Assertions.assertEquals(Types.buildMessage().named("required"),
+      HoodieParquetReadSupport.trimParquetSchema(onlyMissingField, dataSchema))
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantPushVariantIntoScan.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantPushVariantIntoScan.scala
index a05a1f844cda..b2d7af1d522a 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantPushVariantIntoScan.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantPushVariantIntoScan.scala
@@ -21,6 +21,7 @@ package org.apache.spark.sql.hudi.dml.schema
 import org.apache.hudi.{DataSourceWriteOptions, HoodieSparkUtils, 
SparkAdapterSupport}
 import org.apache.hudi.common.config.HoodieMetadataConfig
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
+import org.apache.hudi.common.model.WriteOperationType
 import org.apache.hudi.config.{HoodieBootstrapConfig, HoodieWriteConfig}
 import org.apache.hudi.keygen.NonpartitionedKeyGenerator
 import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
@@ -30,6 +31,9 @@ import org.apache.spark.sql.execution.FileSourceScanExec
 import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
 import org.apache.spark.sql.types.{DataType, StructType}
 
+import java.nio.file.{Files, Paths}
+
+import scala.collection.JavaConverters._
 import scala.collection.mutable.ListBuffer
 import scala.util.{Failure, Success, Try}
 
@@ -214,6 +218,110 @@ class TestVariantPushVariantIntoScan extends 
HoodieSparkSqlTestBase {
     }
   }
 
+  test("a second variant column added by DDL reads through old and new files") 
{
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // Without schema-on-read, `alter table add columns` writes an empty 
ALTER_SCHEMA commit and
+    // touches no data file, so every file written before it lacks v2. Hudi's 
readers switch
+    // Spark's nested schema pruning off, so the missing column has to leave 
the parquet read schema
+    // through HoodieParquetReadSupport.trimParquetSchema; kept there, it 
becomes a plain group over
+    // which Spark builds its variant converter and fails with 
INVALID_VARIANT_SHREDDING_SCHEMA
+    // (#20135). The same read sits under the COW merge handle, the native 
parquet log reader and
+    // MOR compaction, so the old file group is read through each of them.
+    def dataFiles(tablePath: String): Seq[String] = {
+      val stream = Files.walk(Paths.get(tablePath))
+      try {
+        stream.iterator().asScala.filter(Files.isRegularFile(_))
+          .map(p => Paths.get(tablePath).relativize(p).toString)
+          .filter(p => !p.startsWith(".hoodie") && !p.endsWith(".crc") && 
(p.endsWith(".parquet") || p.contains(".log.")))
+          .toSeq.sorted
+      } finally {
+        stream.close()
+      }
+    }
+    def rowsSql(lo: Int, hi: Int, kPrefix: String, ts: Long, v2Prefix: 
Option[String]): String = {
+      val v2Col = v2Prefix.map(p => s""", parse_json(concat('{"k":"$p', id, 
'"}')) as v2""").getOrElse("")
+      s"""select cast(id as int) as id, parse_json(concat('{"k":"$kPrefix', 
id, '"}')) as v,
+         | ${ts}L as ts$v2Col from range($lo, $hi, 1, 1)""".stripMargin
+    }
+    // ids 0-2 come from the writes before the DDL, ids 3-5 from the insert 
after it.
+    def vOf(id: Int): String = if (id < 3) s"a$id" else s"b$id"
+
+    // `v2Of` gives the expected $.k of v2 per id, null where the row has no 
v2; `v2FilterIds` names
+    // one row per file or block that holds a v2 value, each matched by its 
own filter.
+    def assertReads(tableName: String, leg: String, pushed: Boolean, ids: 
Range, v2Of: Int => String,
+                    v2FilterIds: Seq[Int] = Seq.empty): Unit = {
+      checkAnswer(s"select id, variant_get(v, '$$.k', 'string'), 
variant_get(v2, '$$.k', 'string') " +
+        s"from $tableName order by id")(ids.map(id => Seq(id, vOf(id), 
v2Of(id))): _*)
+      checkAnswer(s"select id from $tableName where v2 is null order by id")(
+        ids.filter(v2Of(_) == null).map(Seq(_)): _*)
+      checkAnswer(s"select id from $tableName where variant_get(v, '$$.k', 
'string') = 'a1'")(Seq(1))
+      checkAnswer(s"select id from $tableName where variant_get(v2, '$$.k', 
'string') = 'none'")()
+      v2FilterIds.foreach { id =>
+        checkAnswer(s"select id from $tableName where variant_get(v2, '$$.k', 
'string') = '${v2Of(id)}'")(Seq(id))
+      }
+      checkAnswer(s"select id, cast(v2 as string) from $tableName order by 
id")(
+        ids.map(id => Seq(id, Option(v2Of(id)).map(k => 
s"""{"k":"$k"}""").orNull)): _*)
+      Seq("v", "v2").foreach { column =>
+        assertPushed(s"select id, variant_get($column, '$$.k', 'string') from 
$tableName", pushed, s"$leg, $column")
+      }
+    }
+
+    sweep("ddl-variant") { (tableName, tablePath, tableType, pushed) =>
+      val leg = s"$tableType, $tableName"
+      createTable(tableName, tablePath, tableType, "v variant")
+      // Seeded values that the update below replaces; on MOR the update is a 
log block written
+      // before the DDL, so the old file group reads v2 through a log file 
that lacks it too.
+      spark.sql(s"insert into $tableName ${rowsSql(0, 3, "o", 1000L, None)}")
+      spark.sql(s"update $tableName set " + """v = 
parse_json(concat('{"k":"a', id, '"}')), ts = 1001""")
+      if (tableType == "mor") {
+        assert(dataFiles(tablePath).count(_.contains(".log.")) == 1,
+          s"[$leg] expected one log file before the DDL: 
${dataFiles(tablePath)}")
+      }
+      val filesBeforeDdl = dataFiles(tablePath)
+
+      spark.sql(s"alter table $tableName add columns (v2 variant)")
+      val timeline = createMetaClient(spark, tablePath).getActiveTimeline
+      val alterMetadata = timeline.readCommitMetadata(
+        timeline.getCommitsTimeline.filterCompletedInstants.lastInstant.get)
+      assert(alterMetadata.getOperationType == WriteOperationType.ALTER_SCHEMA,
+        s"[$leg] the DDL commit should be ALTER_SCHEMA, got 
${alterMetadata.getOperationType}")
+      assert(dataFiles(tablePath) == filesBeforeDdl, s"[$leg] the DDL must not 
add or rewrite data files")
+      assert(spark.table(tableName).schema("v2").dataType.simpleString == 
"variant",
+        s"[$leg] the catalog should report v2 as variant")
+
+      // Old files only: every row reads v2 through a file that lacks the 
column.
+      assertReads(tableName, leg, pushed, 0 until 3, _ => null)
+
+      // Rows that carry v2; the small-file limit of 0 keeps them out of the 
old file group.
+      withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+        spark.sql(s"insert into $tableName ${rowsSql(3, 6, "b", 1000L, 
Some("z"))}")
+      }
+      assertReads(tableName, leg, pushed, 0 until 6, id => if (id < 3) null 
else s"z$id", v2FilterIds = Seq(3))
+
+      // One row of each file group: COW rewrites both groups through the 
merge handle, MOR writes
+      // one log block per group beside the one from before the DDL.
+      spark.sql(s"update $tableName set " +
+        """v2 = parse_json(concat('{"k":"u', id, '"}')), ts = 1002 where id in 
(2, 4)""")
+      def updatedV2(id: Int): String = id match {
+        case 2 | 4 => s"u$id"
+        case _ if id < 3 => null
+        case _ => s"z$id"
+      }
+      assertReads(tableName, leg, pushed, 0 until 6, updatedV2, v2FilterIds = 
Seq(2, 3, 4))
+
+      if (tableType == "mor") {
+        assert(dataFiles(tablePath).count(_.contains(".log.")) == 3,
+          s"[$leg] expected three log files after the update: 
${dataFiles(tablePath)}")
+        val baseFilesBefore = 
dataFiles(tablePath).count(_.endsWith(".parquet"))
+        spark.sql(s"call run_compaction(op => 'run', table => '$tableName')")
+        assert(dataFiles(tablePath).count(_.endsWith(".parquet")) == 
baseFilesBefore + 2,
+          s"[$leg] compaction should write a new base file for both file 
groups: ${dataFiles(tablePath)}")
+        assertReads(tableName, leg, pushed, 0 until 6, updatedV2, v2FilterIds 
= Seq(2, 3, 4))
+      }
+    }
+  }
+
   test("bootstrapped tables read the projection through the skeleton and data 
file join") {
     assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
 

Reply via email to