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 eb1213214ef3 fix(variant): port #19783 to release-1.2.1 (#20039)
eb1213214ef3 is described below

commit eb1213214ef3a742583e3d5326a07a99ccb0c758
Author: voonhous <[email protected]>
AuthorDate: Wed Sep 30 12:24:05 2026 +0800

    fix(variant): port #19783 to release-1.2.1 (#20039)
    
    * test(spark): pushVariantIntoScan legs for release-1.2.1
    
    Ported from master's TestVariantShreddingMixedLayouts (#19688, #19783)
    over unshredded data. Evidence for the #19783 backport decision: on
    Spark 4.1.1 the MOR pushVariantIntoScan=true legs fail (top-level: a
    null variant written through a log is lost by 'v is null'; nested:
    SIGBUS in StubRoutines::forward_copy_longs), every COW leg and every
    pushVariantIntoScan=false leg passes.
    
    * fix(variant): port #19783 to release-1.2.1
    
    Under Spark 4.1's default spark.sql.variant.pushVariantIntoScan=true, a
    MOR read that merges a log file lost the row of a variant nulled through
    the log (`where v is null`) and failed the task on a struct-nested
    variant (SparkOutOfMemoryError on x86, SIGBUS on aarch64): the reader
    context overlaid and projected the pushed-down struct at the top level
    only, and the projector had no null guard.
    
    Hand-port of master c59987a024cd (#19783); a cherry-pick conflicts in
    10 of 13 files on this branch. The overlay and its detection recurse
    into struct members, SparkAdapter gains containsVariantProjection, the
    4.1 projector is recursive and projects a null variant to a NULL
    struct, and the SparkSchemaTransformUtils arm and the two adapter unit
    tests are applied verbatim. The comment-only CDC and MergeOnReadRDDV2
    hunks are not ported.
    
    Closes #20037
    
    * fix(variant): read nested variants row-based
    
    Port the recursive supportBatch gates from #19777 so a variant or a
    PushVariantIntoScan projection nested in a struct, array or map reads
    row-based, as a top-level one already does. Without it a nested
    variant still took the vectorized path on Spark 4.1 (#18605).
    
    Drop the VARIANT_LEGS / VARIANT_SKIP_LEGS leg filter from
    TestVariantPushVariantIntoScan so the suite cannot pass as skipped.
---
 .../SparkFileFormatInternalRowReaderContext.scala  |  19 ++-
 .../datasources/SparkSchemaTransformUtils.scala    |  13 ++
 .../org/apache/spark/sql/hudi/SparkAdapter.scala   |  12 ++
 .../HoodieFileGroupReaderBasedFileFormat.scala     |  38 ++++-
 .../TestBaseSpark4AdapterVariantMethods.scala      | 109 +++++++++++-
 .../schema/TestVariantPushVariantIntoScan.scala    | 187 +++++++++++++++++++++
 .../apache/spark/sql/adapter/Spark4_1Adapter.scala |  82 ++++++---
 7 files changed, 420 insertions(+), 40 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
index edfcc44e9aa2..1429f5d5363f 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
@@ -84,12 +84,19 @@ class 
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
   private lazy val allFilters = filters ++ requiredFilters
 
   // For each field of `target`, replace its dataType with the matching 
field's projected
-  // variant struct from `source` (when present). Non-matching fields pass 
through.
+  // variant struct from `source` (when present), recursing into struct 
members so a variant
+  // reached through a struct path is overlaid too. Fields are matched by name 
(findFieldByName);
+  // non-matching fields pass through. The recursion mirrors 
PushVariantIntoScan's
+  // VariantInRelation.rewriteType, which rewrites variants at the root of the 
relation output
+  // and below STRUCT paths only, so an array element or a map value is never 
overlaid here
+  // either (#19783).
   private def overlayVariantProjections(target: StructType, source: 
StructType): StructType = {
     StructType(target.fields.map { f =>
-      SparkFileFormatInternalRowReaderContext.findFieldByName(source, 
f.name).map(_.dataType) match {
-        case Some(projStruct: StructType) if 
sparkAdapter.isVariantProjectionStruct(projStruct) =>
+      (f.dataType, 
SparkFileFormatInternalRowReaderContext.findFieldByName(source, 
f.name).map(_.dataType)) match {
+        case (_, Some(projStruct: StructType)) if 
sparkAdapter.isVariantProjectionStruct(projStruct) =>
           f.copy(dataType = projStruct)
+        case (targetStruct: StructType, Some(sourceStruct: StructType)) =>
+          f.copy(dataType = overlayVariantProjections(targetStruct, 
sourceStruct))
         case _ => f
       }
     })
@@ -101,10 +108,8 @@ class 
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
   // bare required schema would drop them and the merger would read garbage 
offsets.
   override def getLogBlockRecordProjection(
       dataBlockSchema: HoodieSchema): HOption[JFunction[InternalRow, 
InternalRow]] = {
-    val needsProjection = sparkRequiredSchema.exists(_.fields.exists(f => 
f.dataType match {
-      case st: StructType => sparkAdapter.isVariantProjectionStruct(st)
-      case _ => false
-    }))
+    val needsProjection =
+      sparkRequiredSchema.exists(_.fields.exists(f => 
sparkAdapter.containsVariantProjection(f.dataType)))
     if (!needsProjection) {
       return HOption.empty[JFunction[InternalRow, InternalRow]]()
     }
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
index c2676c710b9b..3150216c5e1e 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/SparkSchemaTransformUtils.scala
@@ -426,6 +426,13 @@ object SparkSchemaTransformUtils {
     case (ArrayType(rt, _), ArrayType(ft, _)) => 
ArrayType(addMissingFields(rt, ft))
     case (MapType(requiredKey, requiredValue, _), MapType(fileKey, fileValue, 
_)) =>
       MapType(addMissingFields(requiredKey, fileKey), 
addMissingFields(requiredValue, fileValue))
+    // A Spark 4.1 variant projection struct requested over a file VariantType 
is the pair
+    // isDataTypeEqual declares equal through the adapter, and the reader has 
to be handed the
+    // projection struct, not the file's VariantType: parquet-mr decodes into 
the projected shape
+    // natively, while the type-change Cast applied afterwards would cast the 
variant VALUE to that
+    // struct. Without this arm a type change on a SIBLING member of the 
enclosing struct rewrote
+    // the projection back to VariantType (#19775).
+    case (requiredStruct: StructType, _) if 
isVariantProjectionOverVariant(requiredStruct, fileType) => requiredStruct
     case (StructType(requiredFields), StructType(fileFields)) =>
       val fileFieldMap = fileFields.map(f => f.name -> f).toMap
       StructType(requiredFields.map(f => {
@@ -436,4 +443,10 @@ object SparkSchemaTransformUtils {
       }))
     case _ => fileType
   }
+
+  // Same Try as in isDataTypeEqual: the adapter module may be absent from the 
classpath
+  // (hudi-spark-client tests), in which case there is no projection struct to 
recognise anyway.
+  private def isVariantProjectionOverVariant(requiredType: StructType, 
fileType: DataType): Boolean =
+    Try(HoodieSparkUtils.sparkAdapter.isVariantProjectionStruct(requiredType)
+      && 
HoodieSparkUtils.sparkAdapter.isVariantType(fileType)).getOrElse(false)
 }
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
index 2934536f4ec9..00e609f09915 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
@@ -490,6 +490,18 @@ trait SparkAdapter extends Serializable {
    */
   def isVariantProjectionStruct(structType: StructType): Boolean = false
 
+  /**
+   * True when `dataType` is a variant projection struct or holds one below a 
struct path: the two
+   * places PushVariantIntoScan puts them (the root of the relation output and 
STRUCT members), so
+   * an array element or a map value never matches. The reader context's 
schema overlay and the
+   * adapter's row projector both key off this, and they have to agree on it.
+   */
+  def containsVariantProjection(dataType: DataType): Boolean = dataType match {
+    case st: StructType =>
+      isVariantProjectionStruct(st) || st.fields.exists(f => 
containsVariantProjection(f.dataType))
+    case _ => false
+  }
+
   /**
    * If `sparkRequiredSchema` contains any field that's a Spark 4.1 variant 
projection struct
    * (i.e., the same-named field in `sparkDataSchema` is `VariantType`), 
returns a row
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
index 34e477895c8b..1955f45c2622 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
@@ -55,7 +55,7 @@ import 
org.apache.spark.sql.execution.vectorized.{OffHeapColumnVector, OnHeapCol
 import org.apache.spark.sql.hudi.MultipleColumnarFileFormatReader
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.sources.Filter
-import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType}
 import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnarBatchUtils}
 import org.apache.spark.util.SerializableConfiguration
 
@@ -133,6 +133,25 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
     }
   }
 
+  /**
+   * True when `dataType` itself, or anything it nests, satisfies `predicate`. 
A struct that
+   * matches is reported without descending into it, so a variant projection 
struct is answered
+   * as one type rather than as its pushed-down extraction fields.
+   */
+  private def containsType(dataType: DataType, predicate: DataType => 
Boolean): Boolean = {
+    predicate(dataType) || (dataType match {
+      case s: StructType => s.fields.exists(f => containsType(f.dataType, 
predicate))
+      case a: ArrayType => containsType(a.elementType, predicate)
+      case m: MapType => containsType(m.valueType, predicate)
+      case _ => false
+    })
+  }
+
+  private def isVariantProjection(dataType: DataType): Boolean = dataType 
match {
+    case s: StructType => sparkAdapter.isVariantProjectionStruct(s)
+    case _ => false
+  }
+
   /**
    * Checks if the file format supports vectorized reading, please refer to 
SPARK-40918.
    *
@@ -151,8 +170,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
       supportVectorizedRead = false
       supportReturningBatch = false
       false
-    } else if (schema.fields.exists(f => f.dataType.isInstanceOf[StructType]
-        && 
sparkAdapter.isVariantProjectionStruct(f.dataType.asInstanceOf[StructType]))) {
+    } else if (schema.fields.exists(f => containsType(f.dataType, 
isVariantProjection))) {
       // Spark 4.1's PushVariantIntoScan rewrites a variant column to a struct 
of pushed-down
       // extractions. The Spark vectorized parquet reader treats this as a 
nested type change
       // (data column is VariantType, required is a struct) and refuses to 
read in vectorized
@@ -160,9 +178,21 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: 
String,
       supportVectorizedRead = false
       supportReturningBatch = false
       false
-    } else if (HoodieSparkUtils.gteqSpark4_1 && schema.fields.exists(f => 
sparkAdapter.isVariantType(f.dataType))) {
+    } else if (HoodieSparkUtils.gteqSpark4_1
+        && schema.fields.exists(f => containsType(f.dataType, 
sparkAdapter.isVariantType))) {
       // #18605: Spark 4.1's vectorized variant read produces UnsafeRow 
encodings that SIGBUS
       // during RangePartitioner sampling. Force row-based reads. Spark 4.0 
unaffected.
+      //
+      // Both checks above walk the schema instead of scanning top-level 
fields only. Spark's
+      // ParquetUtils.isBatchReadSupported treats VariantType as an atomic 
type and, once
+      // spark.sql.parquet.enableNestedColumnVectorizedReader is on - on by 
default since Spark
+      // 3.4.0 (added in 3.3.0, off) - nested columns as batch-readable, so at 
stock settings a
+      // variant reached the vectorized reader whenever it sat inside a 
struct/array/map even
+      // though a top-level one did not. The SIGBUS is in the UnsafeRow 
encoding of the vectorized
+      // variant vectors that RangePartitioner samples, and it samples WHOLE 
rows, so a variant
+      // carried inside a struct is exposed exactly the same way under any 
range-partitioned query.
+      // The cost is that nested-variant tables lose vectorization on 4.1+ by 
default, exactly as
+      // top-level ones do.
       supportVectorizedRead = false
       supportReturningBatch = false
       false
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
index 8f14dd1ceed8..b9a7dbe07500 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/adapter/TestBaseSpark4AdapterVariantMethods.scala
@@ -23,7 +23,10 @@ import org.apache.hudi.common.schema.{HoodieSchema, 
HoodieSchemaType}
 
 import org.apache.parquet.schema.PrimitiveType
 import org.apache.parquet.schema.Type.Repetition
-import org.apache.spark.sql.types.{BinaryType, IntegerType, StringType, 
StructField, StructType}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.execution.datasources.SparkSchemaTransformUtils
+import org.apache.spark.sql.types.{BinaryType, IntegerType, LongType, 
Metadata, MetadataBuilder, StringType, StructField, StructType}
+import org.apache.spark.unsafe.types.UTF8String
 import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, 
assertThrows, assertTrue}
 import org.junit.jupiter.api.Assumptions.assumeTrue
 import org.junit.jupiter.api.Test
@@ -259,4 +262,108 @@ class TestBaseSpark4AdapterVariantMethods extends 
SparkAdapterSupport {
     val result = sparkAdapter.isDataTypeEqualForPhysicalSchema(invalidStruct, 
variantType)
     assertTrue(result.isEmpty, "Should return None when reversed struct 
doesn't match variant physical schema")
   }
+
+  /**
+   * The per-child metadata PushVariantIntoScan attaches to a projection 
struct. Built key by key
+   * rather than through Spark 4.1's `VariantMetadata`, which does not exist 
on the Spark 3.x
+   * profiles this suite also compiles for; the test pins the shape against 
the adapter before it
+   * relies on it.
+   */
+  private def variantProjectionMetadata(path: String): Metadata =
+    new MetadataBuilder().putMetadata("__VARIANT_METADATA_KEY",
+      new MetadataBuilder()
+        .putString("path", path)
+        .putBoolean("failOnError", true)
+        .putString("timeZoneId", "UTC")
+        .build()).build()
+
+  /** A `VariantVal` holding `json`, reached by reflection for the same 
cross-version reason. */
+  private def parseJson(json: String): Any = {
+    val cls = 
Class.forName("org.apache.spark.sql.catalyst.expressions.variant.VariantExpressionEvalUtils$")
+    val module = cls.getField("MODULE$").get(null)
+    // parseJson(UTF8String, allowDuplicateKeys, ...): the trailing flags 
gained an entry across
+    // 4.x, so every one of them is passed as its default false.
+    val method = cls.getMethods.find(m => m.getName == "parseJson"
+      && m.getParameterTypes.headOption.contains(classOf[UTF8String])).get
+    val args: Seq[AnyRef] = UTF8String.fromString(json) +:
+      Seq.fill(method.getParameterCount - 1)(Boolean.box(false))
+    method.invoke(module, args: _*)
+  }
+
+  @Test
+  def testBuildVariantProjectorRecursesIntoStructPaths(): Unit = {
+    assumeTrue(HoodieSparkUtils.gteqSpark4_1, "Variant projection structs only 
exist on Spark 4.1+")
+    val variantType = sparkAdapter.getVariantDataType.get
+    val projectionStruct = StructType(Seq(
+      StructField("0", StringType, metadata = 
variantProjectionMetadata("$.k"))))
+    assertTrue(sparkAdapter.isVariantProjectionStruct(projectionStruct),
+      "the hand-built metadata must be what Spark recognizes as a projection 
struct")
+
+    // `cast(s.inner as string)` on `s struct<inner: variant, other: int>`: 
PushVariantIntoScan
+    // rewrites the variant one struct member down, leaving the struct itself 
in place.
+    val dataSchema = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", variantType),
+        StructField("other", IntegerType))))))
+    val requiredSchema = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", projectionStruct),
+        StructField("other", IntegerType))))))
+
+    val projector = sparkAdapter.buildVariantProjector(dataSchema, 
requiredSchema)
+    assertTrue(projector.isDefined, "a projection below a struct path must 
build a projector")
+    val project = projector.get
+
+    val row = project(InternalRow(1, InternalRow(parseJson("""{"k":"n1"}"""), 
7)))
+    assertEquals(1, row.getInt(0))
+    val struct = row.getStruct(1, 2)
+    assertEquals("n1", struct.getStruct(0, 1).getUTF8String(0).toString,
+      "the nested variant must be projected to its requested extraction")
+    assertEquals(7, struct.getInt(1), "the sibling of the projected variant 
must survive")
+
+    // CreateNamedStruct is never null on its own, so a null struct has to be 
preserved explicitly.
+    assertTrue(project(InternalRow(2, null)).isNullAt(1), "a null struct must 
stay null")
+
+    // A null variant inside a live struct: its projection struct must be 
NULL, not a struct of
+    // nulls, because PushVariantIntoScan rewrites `s.inner is null` onto that 
struct.
+    val nullVariant = project(InternalRow(3, InternalRow(null, 9)))
+    val liveStruct = nullVariant.getStruct(1, 2)
+    assertTrue(liveStruct.isNullAt(0), "a null variant must project to a null 
struct")
+    assertEquals(9, liveStruct.getInt(1), "the sibling of a null variant must 
survive")
+
+    assertTrue(sparkAdapter.buildVariantProjector(dataSchema, 
dataSchema).isEmpty,
+      "a required schema with no projection struct anywhere must not build a 
projector")
+  }
+
+  @Test
+  def testImplicitSchemaChangeKeepsNestedVariantProjection(): Unit = {
+    assumeTrue(HoodieSparkUtils.gteqSpark4_1, "Variant projection structs only 
exist on Spark 4.1+")
+    val variantType = sparkAdapter.getVariantDataType.get
+    val projectionStruct = StructType(Seq(
+      StructField("0", StringType, metadata = 
variantProjectionMetadata("$.k"))))
+
+    // The file wrote s.n as int and the table has since widened it to long, 
so `s` is an implicit
+    // type change on this file and every member of `s` is reconciled by 
addMissingFields --
+    // including the projected variant next to the widened column.
+    val fileStruct = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", variantType),
+        StructField("n", IntegerType))))))
+    val requiredSchema = StructType(Seq(
+      StructField("id", IntegerType),
+      StructField("s", StructType(Seq(
+        StructField("inner", projectionStruct),
+        StructField("n", LongType))))))
+
+    val (typeChanges, readerSchema) = 
SparkSchemaTransformUtils.buildImplicitSchemaChangeInfo(fileStruct, 
requiredSchema)
+    val readerS = readerSchema("s").dataType.asInstanceOf[StructType]
+    assertEquals(projectionStruct, readerS("inner").dataType,
+      "the reader must be handed the projection struct, not the file's 
VariantType")
+    assertEquals(IntegerType, readerS("n").dataType, "the widened sibling is 
still read at its file type")
+    assertTrue(typeChanges.containsKey(1), "s is an implicit type change")
+    assertEquals(readerS, typeChanges.get(1).getRight, "the recorded reader 
type is the reconciled struct")
+  }
 }
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
new file mode 100644
index 000000000000..790317b5c86d
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantPushVariantIntoScan.scala
@@ -0,0 +1,187 @@
+/*
+ * 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.spark.sql.hudi.dml.schema
+
+import org.apache.hudi.{HoodieSparkUtils, SparkAdapterSupport}
+import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
+
+import org.apache.spark.sql.execution.FileSourceScanExec
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+import org.apache.spark.sql.types.{DataType, StructType}
+
+import scala.collection.mutable.ListBuffer
+import scala.util.{Failure, Success, Try}
+
+/**
+ * The pushVariantIntoScan legs of master's TestVariantShreddingMixedLayouts 
(#19688, #19783),
+ * ported to release-1.2.1 over unshredded data only, since this branch has no 
shredding writer.
+ * spark.sql.variant.pushVariantIntoScan is swept on and off: both arms expect 
the very same rows,
+ * and only the plan tells them apart (whether the rule rewrote the variant 
into a projection
+ * struct inside the scan). Record types are swept too, which on table version 
9 gives avro
+ * (AVRO) and parquet (SPARK) log blocks on the MOR legs.
+ *
+ * Every leg runs to the end and its verdict is recorded, so one wrong result 
does not hide the
+ * others.
+ */
+class TestVariantPushVariantIntoScan extends HoodieSparkSqlTestBase {
+
+  private val SPARK_4_1_GATE = "PushVariantIntoScan is on by default from 
Spark 4.1"
+
+  // ids 0-2 keep their inserted value, id 3 is updated and id 4's variant is 
nulled by the update.
+  private def merged(id: Int): String = if (id < 3) s"x$id" else if (id == 3) 
"y3" else null
+  private def mergedJson(id: Int): String = Option(merged(id)).map(k => 
s"""{"k":"$k"}""").orNull
+
+  private def variantProjectionPushedIntoScan(sql: String): Boolean = {
+    def containsProjection(dataType: DataType): Boolean = dataType match {
+      case st: StructType =>
+        SparkAdapterSupport.sparkAdapter.isVariantProjectionStruct(st) ||
+          st.fields.exists(f => containsProjection(f.dataType))
+      case _ => false
+    }
+    val scans = spark.sql(sql).queryExecution.sparkPlan.collect { case scan: 
FileSourceScanExec => scan }
+    assert(scans.nonEmpty, s"expected a file scan in the plan of: $sql")
+    scans.exists(_.requiredSchema.fields.exists(f => 
containsProjection(f.dataType)))
+  }
+
+  private def assertPushed(sql: String, pushed: Boolean, leg: String): Unit = {
+    val verdict = if (pushed) "should have" else "must not have"
+    assert(variantProjectionPushedIntoScan(sql) == pushed,
+      s"[$leg] PushVariantIntoScan $verdict rewritten the variant into a 
projection struct")
+  }
+
+  /**
+   * Sweeps table type x pushVariantIntoScan x record type, runs `body` once 
per leg on a fresh
+   * table, and fails at the end with every leg that failed.
+   */
+  private def sweep(scope: String)(body: (String, String, String, Boolean) => 
Unit): Unit = {
+    val failures = ListBuffer.empty[String]
+    var ran = 0
+    // The conf-off arm runs first so that, when a conf-on leg crashes the 
JVM, every other verdict
+    // is already in the log.
+    Seq("cow", "mor").foreach { tableType =>
+      Seq("false", "true").foreach { pushIntoScan =>
+        Seq(HoodieRecordType.AVRO, HoodieRecordType.SPARK).foreach { 
recordType =>
+          val key = s"$scope:$tableType:$pushIntoScan:$recordType"
+          ran += 1
+          withSQLConf("spark.sql.variant.pushVariantIntoScan" -> pushIntoScan) 
{
+            withRecordType(Seq(recordType))(withTempDir { tmp =>
+              val tableName = generateTableName
+              val leg = s"$key, $tableName"
+              // scalastyle:off println
+              println(s"LEG START $key")
+              Try(body(tableName, tmp.getCanonicalPath, tableType, 
pushIntoScan.toBoolean)) match {
+                case Success(_) => println(s"LEG PASS $key")
+                case Failure(e) =>
+                  val msg = 
Option(e.getMessage).getOrElse(e.toString).linesIterator.take(3).mkString(" | ")
+                  println(s"LEG FAIL $key: ${e.getClass.getSimpleName}: $msg")
+                  failures += s"[$leg] ${e.getClass.getSimpleName}: $msg"
+              }
+              // scalastyle:on println
+            })
+          }
+        }
+      }
+    }
+    assert(failures.isEmpty, s"${failures.size} of $ran $scope legs failed:\n" 
+ failures.mkString("\n"))
+  }
+
+  private def createTable(tableName: String, tablePath: String, tableType: 
String, variantColumnDdl: String): Unit = {
+    spark.sql(
+      s"""
+         |create table $tableName (
+         |  id int,
+         |  $variantColumnDdl,
+         |  ts long
+         |) using hudi
+         | location '$tablePath'
+         | tblproperties (
+         |  primaryKey = 'id',
+         |  preCombineField = 'ts',
+         |  type = '$tableType',
+         |  hoodie.compact.inline = 'false'
+         | )
+       """.stripMargin)
+  }
+
+  test("top-level variant_get projections and filters under 
pushVariantIntoScan on and off") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    sweep("toplevel") { (tableName, tablePath, tableType, pushed) =>
+      createTable(tableName, tablePath, tableType, "v variant")
+      spark.sql(s"insert into $tableName select cast(id as int) as id, " +
+        """parse_json(concat('{"k":"x', id, '"}')) as v, 1000L as ts from 
range(0, 5, 1, 1)""")
+      // ids >= 3 go through a log file on MOR (a rewritten base file on COW); 
id 4 is nulled.
+      spark.sql(s"update $tableName set " +
+        """v = parse_json(case when id = 4 then cast(null as string) """ +
+        """else concat('{"k":"y', id, '"}') end), ts = 1001 """ +
+        "where id >= 3")
+
+      checkAnswer(s"select id, variant_get(v, '$$.k', 'string') from 
$tableName order by id")(
+        (0 until 5).map(id => Seq(id, merged(id))): _*)
+      checkAnswer(s"select id from $tableName where variant_get(v, '$$.k', 
'string') = 'y3'")(Seq(3))
+      checkAnswer(s"select id from $tableName where variant_get(v, '$$.k', 
'string') = 'x1'")(Seq(1))
+      checkAnswer(s"select id, cast(v as string) from $tableName order by id")(
+        (0 until 5).map(id => Seq(id, mergedJson(id))): _*)
+      // The rule rewrites `v is null` onto the projection struct itself, so a 
null variant
+      // projected as a struct OF nulls instead of a NULL struct loses this 
row.
+      checkAnswer(s"select id from $tableName where v is null")(Seq(4))
+      checkAnswer(s"select count(*) from $tableName where v is not 
null")(Seq(4))
+      assertPushed(s"select id, variant_get(v, '$$.k', 'string') from 
$tableName", pushed, tableName)
+    }
+  }
+
+  test("nested variant_get projections and filters under pushVariantIntoScan 
on and off") {
+    assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+    // MOR with a log file is the #19783 case: before that fix the internal 
reader applied the
+    // PushVariantIntoScan projection to TOP-LEVEL fields only, so the merged 
row still held a raw
+    // VariantVal at s.inner while the plan read that memory as the projected 
struct s.inner.0
+    // (SIGBUS / InternalError / OOM, or silent nulls).
+    sweep("nested") { (tableName, tablePath, tableType, pushed) =>
+      createTable(tableName, tablePath, tableType, "s struct<inner: variant>")
+      spark.sql(s"insert into $tableName select cast(id as int) as id, " +
+        """named_struct('inner', parse_json(concat('{"k":"x', id, '"}'))) as 
s, """ +
+        "1000L as ts from range(0, 5, 1, 1)")
+      spark.sql(s"update $tableName set " +
+        """s = named_struct('inner', parse_json(case when id = 4 then 
cast(null as string) """ +
+        """else concat('{"k":"y', id, '"}') end)), ts = 1001 """ +
+        "where id >= 3")
+
+      checkAnswer(s"select id, variant_get(s.inner, '$$.k', 'string') from 
$tableName order by id")(
+        (0 until 5).map(id => Seq(id, merged(id))): _*)
+      checkAnswer(s"select id from $tableName where variant_get(s.inner, 
'$$.k', 'string') = 'y3'")(Seq(3))
+      checkAnswer(s"select id from $tableName where variant_get(s.inner, 
'$$.k', 'string') = 'x1'")(Seq(1))
+      checkAnswer(s"select id, cast(s.inner as string) from $tableName order 
by id")(
+        (0 until 5).map(id => Seq(id, mergedJson(id))): _*)
+      checkAnswer(s"select id from $tableName where s.inner is null")(Seq(4))
+      checkAnswer(s"select count(*) from $tableName where s.inner is not 
null")(Seq(4))
+      // The whole struct: no extraction, nothing is rewritten, native 
VariantType at depth.
+      val wholeStruct = spark.sql(s"select id, s from $tableName order by 
id").collect()
+      assert(wholeStruct.length == 5, s"[$tableName] whole-struct read should 
return 5 rows")
+      wholeStruct.foreach { row =>
+        val id = row.getInt(0)
+        assert(!row.isNullAt(1), s"[$tableName] whole-struct read nulled out s 
for id $id")
+        val inner = row.getStruct(1).getAs[Any]("inner")
+        assert(Option(inner).map(_.toString).orNull == mergedJson(id),
+          s"[$tableName] whole-struct read of s.inner for id $id should be 
${mergedJson(id)}, got $inner")
+      }
+      assertPushed(s"select id, variant_get(s.inner, '$$.k', 'string') from 
$tableName", pushed, tableName)
+    }
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
 
b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
index f6a8e19b0e6c..b8a779968caa 100644
--- 
a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
+++ 
b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
@@ -32,7 +32,7 @@ import org.apache.spark.sql.avro._
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.{EliminateSubqueryAliases, 
ResolvedTable}
 import org.apache.spark.sql.catalyst.catalog.CatalogTable
-import org.apache.spark.sql.catalyst.expressions.{BoundReference, 
CreateNamedStruct, Expression, Literal, UnsafeProjection}
+import org.apache.spark.sql.catalyst.expressions.{BoundReference, 
CreateNamedStruct, Expression, GetStructField, If, IsNull, Literal, 
UnsafeProjection}
 import org.apache.spark.sql.catalyst.expressions.variant.VariantGet
 import org.apache.spark.sql.catalyst.parser.{ParseException, ParserInterface}
 import org.apache.spark.sql.catalyst.planning.PhysicalOperation
@@ -230,39 +230,65 @@ class Spark4_1Adapter extends BaseSpark4Adapter {
 
   override def buildVariantProjector(sparkDataSchema: StructType,
                                      sparkRequiredSchema: StructType): 
Option[InternalRow => InternalRow] = {
-    // Quick check: any required field a variant projection struct?
-    if (!sparkRequiredSchema.fields.exists(f => 
VariantMetadata.isVariantStruct(f.dataType))) {
+    // Quick check: does any required field carry a variant projection struct, 
at any depth?
+    if (!sparkRequiredSchema.fields.exists(f => 
containsVariantProjection(f.dataType))) {
       None
     } else {
       // Surface mismatched schemas with both field lists rather than Spark's 
bare
-      // IllegalArgumentException from fieldIndex.
-      def lookupDataField(name: String): (Int, StructField) = {
-        val idx = sparkDataSchema.getFieldIndex(name).getOrElse(
+      // IllegalArgumentException from fieldIndex. `path` is the dotted field 
path of `name`.
+      def lookupDataField(dataStruct: StructType, requiredStruct: StructType,
+                          name: String, path: String): (Int, StructField) = {
+        val idx = dataStruct.getFieldIndex(name).getOrElse(
           throw new IllegalStateException(
-            s"Required field '$name' is absent from sparkDataSchema; " +
-              s"required=${sparkRequiredSchema.fieldNames.mkString("[", ",", 
"]")}, " +
-              s"data=${sparkDataSchema.fieldNames.mkString("[", ",", "]")}"))
-        (idx, sparkDataSchema.fields(idx))
+            s"Required field '$path' is absent from sparkDataSchema; " +
+              s"required=${requiredStruct.fieldNames.mkString("[", ",", "]")}, 
" +
+              s"data=${dataStruct.fieldNames.mkString("[", ",", "]")}"))
+        (idx, dataStruct.fields(idx))
       }
+
+      // `ref` reads the data-schema value of type `dataType`; the result has 
type `requiredType`.
+      def projectionExpr(ref: Expression, dataType: DataType, requiredType: 
DataType,
+                         path: String): Expression = requiredType match {
+        case projectedStruct: StructType if 
VariantMetadata.isVariantStruct(projectedStruct) =>
+          require(isVariantType(dataType),
+            s"Expected VariantType for field '$path' in data schema, got 
$dataType")
+          val childExprs: Seq[Expression] = 
projectedStruct.fields.toSeq.flatMap { child =>
+            val vm = VariantMetadata.fromMetadata(child.metadata)
+            val pathLit = Literal(UTF8String.fromString(vm.path), 
DataTypes.StringType)
+            val variantGet: Expression =
+              VariantGet(ref, pathLit, child.dataType, vm.failOnError, 
Option(vm.timeZoneId))
+            Seq(Literal(UTF8String.fromString(child.name), 
DataTypes.StringType), variantGet)
+          }
+          val projected = CreateNamedStruct(childExprs)
+          // A null variant has to come out as a NULL struct, not a struct of 
nulls: CreateNamedStruct
+          // is never null, the parquet paths leave the field null, and 
PushVariantIntoScan rewrites
+          // IsNull(v) / IsNotNull(v) onto this struct directly.
+          If(IsNull(ref), Literal(null, projected.dataType), projected)
+        case requiredStruct: StructType =>
+          dataType match {
+            // Rebuild the struct member by member only when something below 
it is projected;
+            // otherwise the reference is already in the required shape and is 
cheaper untouched.
+            case dataStruct: StructType if 
containsVariantProjection(requiredStruct) =>
+              val childExprs: Seq[Expression] = 
requiredStruct.fields.toSeq.flatMap { rf =>
+                val childPath = s"$path.${rf.name}"
+                val (childIdx, childField) = lookupDataField(dataStruct, 
requiredStruct, rf.name, childPath)
+                val childRef = GetStructField(ref, childIdx, Some(rf.name))
+                Seq(Literal(UTF8String.fromString(rf.name), 
DataTypes.StringType),
+                  projectionExpr(childRef, childField.dataType, rf.dataType, 
childPath))
+              }
+              val rebuilt = CreateNamedStruct(childExprs)
+              // CreateNamedStruct is never null, so a null struct would come 
back as a struct of
+              // nulls without this guard.
+              If(IsNull(ref), Literal(null, rebuilt.dataType), rebuilt)
+            case _ => ref
+          }
+        case _ => ref
+      }
+
       val exprs: Array[Expression] = sparkRequiredSchema.fields.map { rf =>
-        rf.dataType match {
-          case projectedStruct: StructType if 
VariantMetadata.isVariantStruct(projectedStruct) =>
-            val (dataIdx, dataField) = lookupDataField(rf.name)
-            require(isVariantType(dataField.dataType),
-              s"Expected VariantType for field '${rf.name}' in data schema, 
got ${dataField.dataType}")
-            val variantRef: Expression = BoundReference(dataIdx, 
dataField.dataType, dataField.nullable)
-            val childExprs: Seq[Expression] = 
projectedStruct.fields.toSeq.flatMap { child =>
-              val vm = VariantMetadata.fromMetadata(child.metadata)
-              val pathLit = Literal(UTF8String.fromString(vm.path), 
DataTypes.StringType)
-              val tz: Option[String] = Option(vm.timeZoneId)
-              val variantGet: Expression = VariantGet(variantRef, pathLit, 
child.dataType, vm.failOnError, tz)
-              Seq(Literal(UTF8String.fromString(child.name), 
DataTypes.StringType), variantGet)
-            }
-            CreateNamedStruct(childExprs)
-          case _ =>
-            val (dataIdx, dataField) = lookupDataField(rf.name)
-            BoundReference(dataIdx, dataField.dataType, dataField.nullable)
-        }
+        val (dataIdx, dataField) = lookupDataField(sparkDataSchema, 
sparkRequiredSchema, rf.name, rf.name)
+        val ref: Expression = BoundReference(dataIdx, dataField.dataType, 
dataField.nullable)
+        projectionExpr(ref, dataField.dataType, rf.dataType, rf.name)
       }
 
       val projection = UnsafeProjection.create(exprs.toIndexedSeq, 
DataTypeUtils.toAttributes(sparkDataSchema))

Reply via email to