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 ef834187ce47 feat(variant): infer shredding schemas by default on 
Spark 4.1+ (#19808)
ef834187ce47 is described below

commit ef834187ce47eb235110f35160ccc961e577fca9
Author: voonhous <[email protected]>
AuthorDate: Wed Sep 2 18:19:36 2026 +0800

    feat(variant): infer shredding schemas by default on Spark 4.1+ (#19808)
    
    hoodie.parquet.variant.shredding.schema.inference.enabled now
    defaults to true, matching Spark 4.1's own
    spark.sql.variant.inferShreddingSchema and writeShredding defaults.
    The key is new in 1.3.0, so no released table changes behavior on
    upgrade; flipping it after a release is what would have.
    
    Only writers with a Spark 4.1+ inferrer on the classpath change.
    Spark 3.x, Spark 4.0, Flink and Java keep writing unshredded. The
    schema is inferred per file from a sample of its first records
    (4096 rows / 64MB), for top-level variant columns only, in base
    files and in native parquet log files on table version 10+; Avro
    log blocks stay unshredded and shred at compaction. Shredded-ness
    is therefore per file, never a table-level property, and mixed
    layouts within a table are supported.
    
    Shredded files can only be read back by Spark 4.1+. Spark 4.0,
    Hive and Flink already failed fast; Spark 3.x now does too. Its
    documented way to read a variant table declares the column as
    struct<value: binary, metadata: binary>, and parquet matched that
    request against a shredded group by name, leaving typed_value
    behind and returning a null value for every shredded row.
    ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs
    rejects that read from Spark33/34/35ParquetReader and the legacy
    file format for base files, and from the new
    Spark3HoodieParquetReadSupport, returned by BaseSpark3Adapter, for
    log blocks, which HoodieSparkParquetReader reads through the read
    support instead. The anchor is two-sided: on the request, binary
    members named metadata and value and nothing else - a subset too,
    since nested schema pruning narrows SELECT v.value to one member -
    resolved case-insensitively because the Spark 3.x readers force
    spark.sql.caseSensitive=false; on the file, typed_value beside a
    binary metadata, the anchor the Hive and Spark 4.0 guards use. A
    plain user struct, an unshredded file and a query that does not
    project the column are untouched.
    
    The row-writer factory now orders its inference gates by cost -
    static inferrer lookup, StructType scan for a top-level variant,
    then the config-schema parse - so a classpath that cannot infer,
    and any table without a variant, no longer pays an uncached parse
    of hoodie.avro.schema per file handle.
    
    Config docs: the reader list names Spark 3.x, and the un-shred
    recipe names hoodie.parquet.variant.write.shredding.enabled=false,
    the key that actually strips typed_value from a schema read back
    off shredded files. Opt out with either that key or the inference
    key on tables other engines read; clustering with it off rewrites
    already shredded files unshredded.
    
    Tests: TestVariantSchemaUtils and the Avro writer factory pin the
    default; TestParquetSchemaEvolutionUtils covers the schema walk
    (nesting, pruning, case, exemptions); TestSpark3HoodieParquetReadSupport
    covers the read-support wiring over a hand-built InitContext;
    TestVariantDataType gains a Spark 3.x fail-fast leg over the mixed
    shredded fixture, full and pruned, and its COW inference leg now
    relies on the default.
---
 .../row/HoodieInternalRowFileWriterFactory.java    |  26 +++-
 .../hudi/common/config/HoodieStorageConfig.java    |  36 +++---
 .../hudi/common/avro/TestVariantSchemaUtils.java   |  16 ++-
 ...oodieAvroFileWriterFactoryVariantInference.java |  16 ++-
 .../parquet/ParquetSchemaEvolutionUtils.scala      | 103 +++++++++++++++-
 .../parquet/TestParquetSchemaEvolutionUtils.scala  | 132 ++++++++++++++++++++-
 .../sql/hudi/dml/schema/TestVariantDataType.scala  |  49 +++++++-
 .../spark/sql/adapter/BaseSpark3Adapter.scala      |  19 ++-
 .../parquet/Spark3HoodieParquetReadSupport.scala   |  61 ++++++++++
 .../Spark3LegacyHoodieParquetFileFormat.scala      |   8 ++
 .../TestSpark3HoodieParquetReadSupport.scala       |  89 ++++++++++++++
 .../datasources/parquet/Spark33ParquetReader.scala |   5 +
 .../datasources/parquet/Spark34ParquetReader.scala |   5 +
 .../datasources/parquet/Spark35ParquetReader.scala |   5 +
 14 files changed, 533 insertions(+), 37 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowFileWriterFactory.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowFileWriterFactory.java
index b5a3ab365133..7d7a76fe6957 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowFileWriterFactory.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowFileWriterFactory.java
@@ -18,6 +18,7 @@
 
 package org.apache.hudi.io.storage.row;
 
+import org.apache.hudi.SparkAdapterSupport$;
 import org.apache.hudi.common.avro.VariantSchemaUtils;
 import org.apache.hudi.common.avro.VariantShreddingRuntime;
 import org.apache.hudi.common.avro.VariantShreddingSchemaInferrer;
@@ -36,6 +37,7 @@ import 
org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
 import org.apache.hudi.table.HoodieTable;
 
 import org.apache.hadoop.conf.Configuration;
+import org.apache.spark.sql.types.StructField;
 import org.apache.spark.sql.types.StructType;
 
 import java.io.IOException;
@@ -87,11 +89,15 @@ public class HoodieInternalRowFileWriterFactory {
     // The row write support resolves its HoodieSchema from the config 
(hoodie.write.schema /
     // hoodie.avro.schema), so inferable columns are detected on that same 
config schema and the
     // deferred creation splices a copied config; the StructType argument 
stays original.
-    // (getInferableVariantColumnsFromConfig only parses the schema once the 
flag gates pass.)
-    List<String> inferableColumns = 
VariantSchemaUtils.getInferableVariantColumnsFromConfig(writeConfig);
-    if (!inferableColumns.isEmpty()) {
-      Option<VariantShreddingSchemaInferrer> inferrer = 
VariantShreddingRuntime.lookupInferrer();
-      if (inferrer.isPresent()) {
+    // Gates are ordered by cost, because with inference on by default the 
flag gates inside
+    // getInferableVariantColumnsFromConfig pass on every table and the 
config-schema parse behind
+    // them is uncached and per file handle: the inferrer lookup is a static 
field, so a classpath
+    // that cannot infer (Spark 3.x, 4.0) pays nothing here; the StructType 
scan is a handful of type
+    // checks; only a table with a top-level variant then pays the parse.
+    Option<VariantShreddingSchemaInferrer> inferrer = 
VariantShreddingRuntime.lookupInferrer();
+    if (inferrer.isPresent() && hasTopLevelVariant(structType)) {
+      List<String> inferableColumns = 
VariantSchemaUtils.getInferableVariantColumnsFromConfig(writeConfig);
+      if (!inferableColumns.isEmpty()) {
         return new VariantShreddingInferenceInternalRowFileWriter(
             inferableColumns,
             
VariantShreddingInferenceInternalRowFileWriter.resolveOrdinals(structType, 
inferableColumns),
@@ -104,6 +110,16 @@ public class HoodieInternalRowFileWriterFactory {
     return createParquetInternalRowFileWriter(path, table, writeConfig, 
writeConfig, structType, bloomFilterOpt);
   }
 
+  /** Whether any top-level column is a variant; inference applies to no other 
position. */
+  private static boolean hasTopLevelVariant(StructType structType) {
+    for (StructField field : structType.fields()) {
+      if 
(SparkAdapterSupport$.MODULE$.sparkAdapter().isVariantType(field.dataType())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   private static HoodieInternalRowFileWriter 
createParquetInternalRowFileWriter(StoragePath path,
                                                                                
 HoodieTable table,
                                                                                
 HoodieWriteConfig writeConfig,
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieStorageConfig.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieStorageConfig.java
index 391ea62a571e..5e753b1f737c 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieStorageConfig.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieStorageConfig.java
@@ -266,8 +266,10 @@ public class HoodieStorageConfig extends HoodieConfig {
       .defaultValue(true)
       .sinceVersion("1.1.0")
       .withDocumentation("Controls whether variant columns are written in 
shredded format. "
-          + "When enabled (default), variant columns with shredding 
information in the schema will be written "
-          + "in shredded format with typed_value columns. When disabled, 
variant columns are always written "
+          + "When enabled (default), variant columns are written in shredded 
format with typed_value "
+          + "columns when the write schema carries shredding information or 
when "
+          + "hoodie.parquet.variant.shredding.schema.inference.enabled (on by 
default) infers one for "
+          + "a top-level column. When disabled, variant columns are always 
written "
           + "in unshredded format regardless of the schema. "
           + "Equivalent to Spark's spark.sql.variant.writeShredding.enabled.");
 
@@ -308,22 +310,28 @@ public class HoodieStorageConfig extends HoodieConfig {
 
   public static final ConfigProperty<Boolean> 
PARQUET_VARIANT_SHREDDING_SCHEMA_INFERENCE_ENABLED = ConfigProperty
       .key("hoodie.parquet.variant.shredding.schema.inference.enabled")
-      .defaultValue(false)
+      .defaultValue(true)
       .sinceVersion("1.3.0")
-      .withDocumentation("When enabled, the shredding schema for variant 
columns without an explicit "
-          + "typed_value in the write schema is inferred automatically per 
parquet file from a sample of "
-          + "the records written to that file, mirroring Spark 4.1's "
-          + "spark.sql.variant.inferShreddingSchema. Requires Spark 4.1+ on 
the writer classpath; "
-          + "writes stay unshredded otherwise (Spark 4.0, Flink, Java 
engines). Applies to every "
-          + "parquet file the writer produces: base files and, on table 
version 10+, the native "
+      .withDocumentation("Infers the shredding schema of variant columns that 
have no explicit "
+          + "typed_value in the write schema, per parquet file, from a sample 
of the records written "
+          + "to that file, mirroring Spark 4.1's 
spark.sql.variant.inferShreddingSchema (also on by "
+          + "default there). Takes effect only when a Spark 4.1+ writer is on 
the classpath; other "
+          + "writers (Spark 3.x, Spark 4.0, Flink, Java) ignore it and write 
unshredded. Applies to "
+          + "every parquet file the writer produces: base files and, on table 
version 10+, the native "
           + "parquet log files of MOR tables (each infers its own schema). 
Data blocks inside "
           + "Avro-format log files, whether Avro or parquet 
(hoodie.logfile.data.block.format), stay "
           + "unshredded and shred at compaction. Applies to top-level variant 
columns only; a variant "
-          + "nested inside a struct, array or map stays unshredded. This is a 
write config rather than "
-          + "a table config: SQL DML and procedures called by table name pick 
it up from the table's "
-          + "catalog properties, while path-based procedures, the DataSource 
writer and the streamer "
-          + "must be handed it explicitly. Up to 4096 records or 64MB are 
buffered per "
-          + "open file writer before the writer is created, on top of 
parquet's own row-group "
+          + "nested inside a struct, array or map stays unshredded. Shredded 
files can only be read "
+          + "back by Spark 4.1+: Spark 4.0, Spark 3.x, Hive and Flink readers 
fail fast on them, so "
+          + "disable this option (or 
hoodie.parquet.variant.write.shredding.enabled) on tables those "
+          + "engines read, and rewrite already shredded files by clustering 
with "
+          + "hoodie.parquet.variant.write.shredding.enabled=false - that key, 
not this one, is what "
+          + "strips typed_value from a schema read back off shredded files - 
to return to the "
+          + "unshredded layout. This is a write config rather than a table 
config: SQL DML and "
+          + "procedures called by table name pick it up from the table's 
catalog properties, while "
+          + "path-based procedures, the DataSource writer and the streamer 
must be handed it "
+          + "explicitly when a non-default value is wanted. Up to 4096 records 
or 64MB are buffered "
+          + "per open file writer before the writer is created, on top of 
parquet's own row-group "
           + "buffer, so size executor memory for concurrently open handles 
accordingly. Ignored when "
           + "hoodie.parquet.variant.force.shredding.schema.for.test is set, 
when write shredding "
           + "is disabled, or when the table has a schema-on-read internal 
schema "
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestVariantSchemaUtils.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestVariantSchemaUtils.java
index eb3d00cceadb..ecb8d379e934 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestVariantSchemaUtils.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestVariantSchemaUtils.java
@@ -92,8 +92,16 @@ public class TestVariantSchemaUtils {
   public void testGetInferableVariantColumnsGating() {
     HoodieSchema schema = schemaWithVariants();
 
-    // Inference disabled (default)
-    assertTrue(VariantSchemaUtils.getInferableVariantColumns(new 
HoodieConfig(), schema).isEmpty());
+    // Inference enabled (the default since #19690)
+    
assertTrue(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_SCHEMA_INFERENCE_ENABLED.defaultValue(),
+        "#19690 pins inference on by default");
+    assertEquals(Arrays.asList("v1", "v2"),
+        VariantSchemaUtils.getInferableVariantColumns(new HoodieConfig(), 
schema));
+
+    // Inference disabled explicitly
+    HoodieConfig inferenceOff = new HoodieConfig();
+    
inferenceOff.setValue(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_SCHEMA_INFERENCE_ENABLED,
 "false");
+    assertTrue(VariantSchemaUtils.getInferableVariantColumns(inferenceOff, 
schema).isEmpty());
 
     // Write shredding disabled
     HoodieConfig shreddingOff = inferenceEnabledConfig();
@@ -156,9 +164,11 @@ public class TestVariantSchemaUtils {
     writeOnly.setValue("hoodie.write.schema", schemaString);
     assertEquals(Arrays.asList("v1", "v2"), 
VariantSchemaUtils.getInferableVariantColumnsFromConfig(writeOnly));
 
-    // No schema in the config, or inference disabled: nothing, and no schema 
parse attempted.
+    // No schema in the config, or inference disabled: nothing, and with 
inference disabled no
+    // schema parse is attempted at all (the unparseable schema below would 
throw otherwise).
     
assertTrue(VariantSchemaUtils.getInferableVariantColumnsFromConfig(inferenceEnabledConfig()).isEmpty());
     HoodieConfig disabled = new HoodieConfig();
+    
disabled.setValue(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_SCHEMA_INFERENCE_ENABLED,
 "false");
     disabled.setValue("hoodie.avro.schema", "not a schema");
     
assertTrue(VariantSchemaUtils.getInferableVariantColumnsFromConfig(disabled).isEmpty());
   }
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieAvroFileWriterFactoryVariantInference.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieAvroFileWriterFactoryVariantInference.java
index 161f537dd101..d7f63b5ec6b0 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieAvroFileWriterFactoryVariantInference.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieAvroFileWriterFactoryVariantInference.java
@@ -54,9 +54,14 @@ import static org.junit.jupiter.api.Assumptions.assumeFalse;
 
 /**
  * Pins the no-inferrer degradation of shredding-schema inference in
- * {@link HoodieAvroFileWriterFactory}: this module's classpath carries no 
Spark version module,
- * so {@link VariantShreddingRuntime#lookupInferrer()} is empty here, which is 
what engines without
- * Spark 4.1+ (Flink, Java, Spark 4.0) see in production.
+ * {@link HoodieAvroFileWriterFactory}. Inference is on by default, so the 
inferrer gate decides what
+ * a Spark 4.0 classpath writes: the shredding provider ships in 
hudi-spark4-common and is therefore
+ * present there, which carries the factory past its provider gate and up to 
this one. Flink, Java
+ * and Spark 3.x classpaths carry no provider either and stop one gate 
earlier, which is why the test
+ * below names a provider class explicitly - it isolates the inferrer gate 
rather than passing
+ * through whichever gate happens to fire first. This module's classpath 
carries no Spark version
+ * module, so {@link VariantShreddingRuntime#lookupInferrer()} is empty here, 
and a write must
+ * degrade to the plain unshredded writer.
  */
 public class TestHoodieAvroFileWriterFactoryVariantInference {
 
@@ -64,7 +69,7 @@ public class TestHoodieAvroFileWriterFactoryVariantInference {
   java.nio.file.Path tmpDir;
 
   @Test
-  public void testInferenceFlagWithoutInferrerWritesPlainUnshreddedFile() 
throws Exception {
+  public void testDefaultInferenceWithoutInferrerWritesPlainUnshreddedFile() 
throws Exception {
     assumeFalse(VariantShreddingRuntime.lookupInferrer().isPresent(),
         "this test pins the fallback for classpaths without a shredding-schema 
inferrer");
 
@@ -72,7 +77,8 @@ public class TestHoodieAvroFileWriterFactoryVariantInference {
         HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
         HoodieSchemaField.of("v", 
HoodieSchema.createNullable(HoodieSchema.createVariant()))));
     HoodieConfig config = new HoodieConfig();
-    
config.setValue(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_SCHEMA_INFERENCE_ENABLED,
 "true");
+    
assertTrue(config.getBooleanOrDefault(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_SCHEMA_INFERENCE_ENABLED),
+        "inference is on by default since #19690; this test pins what that 
default does without an inferrer");
     config.setValue(HoodieStorageConfig.PARQUET_COMPRESSION_CODEC_NAME, 
"zstd");
     // Name a provider explicitly: the factory also declines when no shredding 
provider is available,
     // and this module ships none, so without this the inferrer gate (the one 
under test) would never
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
index 113d7fba1358..ad67f5cd007c 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
@@ -20,6 +20,7 @@ package org.apache.spark.sql.execution.datasources.parquet
 import org.apache.hudi.SparkAdapterSupport
 import org.apache.hudi.client.utils.SparkInternalSchemaConverter
 import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.schema.internal.{InternalSchema, Type => 
InternalType}
 import org.apache.hudi.common.schema.internal.Types
 import org.apache.hudi.common.schema.internal.action.InternalSchemaMerger
@@ -36,13 +37,14 @@ import org.apache.hudi.hadoop.fs.HadoopFSUtils
 import org.apache.hadoop.conf.Configuration
 import org.apache.hadoop.fs.Path
 import org.apache.parquet.hadoop.metadata.FileMetaData
-import org.apache.parquet.schema.{Type => ParquetType}
+import org.apache.parquet.schema.{GroupType, MessageType, Type => ParquetType}
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
 import org.apache.spark.sql.HoodieSchemaUtils
 import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
UnsafeProjection}
 import org.apache.spark.sql.execution.datasources.SparkSchemaTransformUtils
 import 
org.apache.spark.sql.execution.datasources.parquet.ParquetSchemaEvolutionUtils.pruneInternalSchema
 import org.apache.spark.sql.sources._
-import org.apache.spark.sql.types.{ArrayType, AtomicType, DataType, MapType, 
StructType}
+import org.apache.spark.sql.types.{ArrayType, AtomicType, BinaryType, 
DataType, MapType, StructType}
 
 import java.time.ZoneId
 
@@ -271,6 +273,103 @@ object ParquetSchemaEvolutionUtils {
     }
   }
 
+  /**
+   * Fails the read when a column requested as the unshredded variant struct 
sits over a parquet
+   * group that carries typed_value. This is the shape Spark 3.x readers use 
for a variant column:
+   * Spark 3.x has no VariantType, so the table's own schema does not convert 
(see
+   * BaseSpark3Adapter) and the documented way to read such a table is to 
declare the column as
+   * struct&lt;value: binary, metadata: binary&gt; - the same shape Hive sync 
writes to the
+   * metastore. Parquet reconciles requested against file fields by name, so 
without this guard a
+   * shredded group's typed_value is simply not projected and the rows come 
back with a null
+   * `value`: the payload is dropped silently. Reconstruction is not an option 
on Spark 3.x, whose
+   * classpath carries no VariantShreddingProvider (the only implementation 
ships in spark4-common),
+   * so the read fails instead, as it already does on Spark 4.0, Flink and 
Hive.
+   *
+   * The anchor is two-sided: the requested side must be binary members named 
`metadata` and
+   * `value`, either or both and nothing else (a struct carrying any further 
member is a plain user
+   * struct, exempt here as it is in the sibling Hive and Spark 4.0 guards), 
and the file must carry
+   * typed_value at that same path. A column the query does not project is 
never walked, so a read
+   * that does not touch the variant keeps working, as does an unshredded file.
+   */
+  def validateNoShreddedVariantStructs(requiredSchema: StructType, 
fileParquetSchema: MessageType): Unit = {
+    requiredSchema.fields.foreach { field =>
+      parquetFieldIgnoreCase(fileParquetSchema, field.name)
+        .foreach(validateNoShreddedVariantStruct(field.dataType, _, 
field.name))
+    }
+  }
+
+  /**
+   * The file field a requested name resolves to. Case-insensitive on purpose: 
the Spark 3.x
+   * readers this guards force spark.sql.caseSensitive=false 
(SparkParquetReaderBase.read), so a
+   * column declared `V` or a member declared `Value` still lands on the 
file's lower-case group,
+   * and a guard that only matched exactly would let that request straight 
through to the
+   * null-value read.
+   */
+  private def parquetFieldIgnoreCase(group: GroupType, name: String): 
Option[ParquetType] =
+    group.getFields.find(_.getName.equalsIgnoreCase(name))
+
+  private def validateNoShreddedVariantStruct(dataType: DataType, parquetType: 
ParquetType, path: String): Unit = {
+    if (!parquetType.isPrimitive) {
+      val group = parquetType.asGroupType()
+      dataType match {
+        case struct: StructType if isUnshreddedVariantStruct(struct) =>
+          if (isShreddedVariantGroup(group)) {
+            throw new HoodieException(String.format(
+              "Column '%s' is a shredded variant (typed_value present) 
requested as its unshredded "
+                + "struct shape; Spark 3.x cannot reconstruct shredded 
variants, and reading it "
+                + "here would return a null value for every shredded row. Read 
the table with "
+                + "Spark 4.1+, or rewrite it unshredded (e.g. cluster with "
+                + "hoodie.parquet.variant.write.shredding.enabled=false).", 
path))
+          }
+        case struct: StructType =>
+          struct.fields.foreach { field =>
+            parquetFieldIgnoreCase(group, field.name)
+              .foreach(validateNoShreddedVariantStruct(field.dataType, _, 
concatPath(path, field.name)))
+          }
+        case array: ArrayType =>
+          
parquetListElement(group).foreach(validateNoShreddedVariantStruct(array.elementType,
 _, concatPath(path, "element")))
+        case map: MapType =>
+          
parquetMapValue(group).foreach(validateNoShreddedVariantStruct(map.valueType, 
_, concatPath(path, "value")))
+        case _ =>
+      }
+    }
+  }
+
+  /**
+   * Whether a file group is a shredded variant: typed_value next to a binary 
metadata, the two
+   * members every shredded variant group carries (value is optional under the 
spec). The same
+   * file-side anchor as the sibling Hive and Spark 4.0 guards, and needed for 
the same reason the
+   * requested side is exact: once pruning has narrowed a request to a lone 
`value`, only the
+   * file can tell a variant apart from a user struct that merely holds a 
typed_value member.
+   */
+  private def isShreddedVariantGroup(group: GroupType): Boolean = {
+    group.containsField(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD) &&
+      group.containsField(HoodieSchema.Variant.VARIANT_METADATA_FIELD) && {
+        val metadata = 
group.getType(HoodieSchema.Variant.VARIANT_METADATA_FIELD)
+        metadata.isPrimitive && 
metadata.asPrimitiveType().getPrimitiveTypeName == PrimitiveTypeName.BINARY
+      }
+  }
+
+  /**
+   * Whether `struct` is the unshredded variant shape: a non-empty set of the 
binary members a
+   * variant group carries, in any order. A subset counts because Spark's 
nested schema pruning
+   * narrows the request to the leaves a query touches - `SELECT v.value` 
reaches this guard as a
+   * one-member struct (see TestNestedSchemaPruningOptimization for the 
pruning itself), and that
+   * single member is exactly the one a shredded file would return null for.
+   *
+   * Any member outside those two names exempts the struct, so a plain user 
struct is untouched, as
+   * is the {metadata, value, typed_value} shape whose caller already sees the 
shredded layout and
+   * is reading it deliberately - though pruning that shape down to `value` 
alone does land here,
+   * since nothing then distinguishes it from the variant request this guards.
+   */
+  private def isUnshreddedVariantStruct(struct: StructType): Boolean = {
+    // Names compared case-insensitively for the same reason 
parquetFieldIgnoreCase resolves them so.
+    struct.fields.nonEmpty && struct.fields.forall(field =>
+      field.dataType == BinaryType
+        && 
(field.name.equalsIgnoreCase(HoodieSchema.Variant.VARIANT_METADATA_FIELD)
+        || 
field.name.equalsIgnoreCase(HoodieSchema.Variant.VARIANT_VALUE_FIELD)))
+  }
+
   /**
    * The dotted path of the first PushVariantIntoScan rewrite struct in the 
schema, if any (see
    * SparkInternalSchemaConverter.isVariantRewriteStruct for the marker).
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
index f462633f7347..4321728c4cc3 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
@@ -25,17 +25,20 @@ import 
org.apache.hudi.common.schema.internal.convert.InternalSchemaConverter
 import org.apache.hudi.exception.HoodieException
 
 import org.apache.parquet.hadoop.metadata.FileMetaData
-import org.apache.parquet.schema.{Type, Types}
+import org.apache.parquet.schema.{MessageType, Type, Types}
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
 import 
org.apache.spark.sql.execution.datasources.parquet.VariantParquetTestFixtures.{shreddedVariant,
 stringKeyMap, threeLevelList, twoLevelList, unshreddedVariant}
-import org.apache.spark.sql.types.{BinaryType, MetadataBuilder, StructField, 
StructType}
+import org.apache.spark.sql.types.{ArrayType, BinaryType, IntegerType, 
MapType, MetadataBuilder, StringType, StructField, StructType}
 import org.junit.jupiter.api.{Assertions, Test}
 
 import java.util.{Arrays, Collections, HashMap}
 
 /**
- * Unit tests for [[ParquetSchemaEvolutionUtils.validateNoShreddedVariants]], 
the schema-on-read
- * guard that fails a read the merged internal-schema request would otherwise 
serve with the
- * typed_value clipped away. No SparkSession: the guard is a pure schema walk.
+ * Unit tests for the two shredded-variant read guards of 
[[ParquetSchemaEvolutionUtils]]:
+ * validateNoShreddedVariants, the schema-on-read guard that fails a read the 
merged
+ * internal-schema request would otherwise serve with the typed_value clipped 
away, and
+ * validateNoShreddedVariantStructs, the Spark 3.x guard for a variant 
requested as its
+ * unshredded struct shape. No SparkSession: both guards are pure schema walks.
  */
 class TestParquetSchemaEvolutionUtils {
 
@@ -199,6 +202,125 @@ class TestParquetSchemaEvolutionUtils {
   private def footerOf(column: Type): FileMetaData =
     new FileMetaData(Types.buildMessage().addField(column).named("test"), new 
HashMap[String, String](), "test")
 
+  /** The parquet schema of a file of one top-level column. */
+  private def schemaOf(column: Type): MessageType = 
Types.buildMessage().addField(column).named("test")
+
+  /**
+   * The Spark 3.x shape: no VariantType there, so a variant column is 
declared as
+   * struct&lt;value: binary, metadata: binary&gt; (the shape Hive sync also 
writes). Either member
+   * order is the same column, and the unshredded twin of the same file must 
still read.
+   */
+  @Test
+  def testValidateNoShreddedVariantStructsRejectsTopLevelShreddedVariant(): 
Unit = {
+    Seq(
+      ("metadata first", new StructType().add("metadata", 
BinaryType).add("value", BinaryType)),
+      ("value first", new StructType().add("value", 
BinaryType).add("metadata", BinaryType))
+    ).foreach { case (order, variant) =>
+      val requiredSchema = new StructType().add("v", variant)
+      val failure = Assertions.assertThrows(classOf[HoodieException], () =>
+        
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
schemaOf(shreddedVariant("v"))))
+      Assertions.assertTrue(
+        failure.getMessage.contains("shredded variant") && 
failure.getMessage.contains("'v'"),
+        s"The $order error must name the shredded variant column, got: 
${failure.getMessage}")
+
+      
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
schemaOf(unshreddedVariant("v")))
+    }
+  }
+
+  /** The walk has to reach a variant below a struct, a list element and a map 
value. */
+  @Test
+  def testValidateNoShreddedVariantStructsRejectsNestedShreddedVariant(): Unit 
= {
+    Seq(
+      ("struct", new StructType().add("s", new StructType().add("inner", 
variantStruct)),
+        
schemaOf(Types.optionalGroup().addField(shreddedVariant("inner")).named("s")), 
"'s.inner'"),
+      ("list", new StructType().add("v", ArrayType(variantStruct)),
+        schemaOf(threeLevelList("v", shreddedVariant("element"))), 
"'v.element'"),
+      ("map", new StructType().add("v", MapType(StringType, variantStruct)),
+        schemaOf(stringKeyMap("v", shreddedVariant("value"))), "'v.value'")
+    ).foreach { case (leg, requiredSchema, footer, path) =>
+      val failure = Assertions.assertThrows(classOf[HoodieException], () =>
+        
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
footer))
+      Assertions.assertTrue(failure.getMessage.contains(path),
+        s"The $leg error must name $path, got: ${failure.getMessage}")
+    }
+  }
+
+  /**
+   * The requested side must be the variant shape exactly, so a user struct 
that merely contains
+   * those two names, or carries them with another type, reads the same file 
untouched - as does a
+   * column the file does not hold at all. Each leg fails the test by throwing.
+   */
+  @Test
+  def testValidateNoShreddedVariantStructsLeavesOtherRequestsAlone(): Unit = {
+    Seq(
+      new StructType().add("metadata", BinaryType).add("value", 
BinaryType).add("extra", BinaryType),
+      new StructType().add("metadata", BinaryType).add("value", IntegerType),
+      new StructType().add("a", BinaryType).add("b", BinaryType)
+    ).foreach { requested =>
+      ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(
+        new StructType().add("v", requested), schemaOf(shreddedVariant("v")))
+    }
+
+    // A column added after the file was written has no footer field to walk.
+    ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(
+      new StructType().add("added", variantStruct), 
schemaOf(shreddedVariant("v")))
+
+    // The file side is anchored too: a user struct that merely holds a 
typed_value member has no
+    // binary metadata beside it, so even a request pruned down to `value` 
alone - which on the
+    // requested side is indistinguishable from a variant - is left to read.
+    val userStructWithTypedValue = Types.optionalGroup()
+      .addField(Types.optional(PrimitiveTypeName.BINARY).named("value"))
+      .addField(Types.optional(PrimitiveTypeName.INT32).named("typed_value"))
+      .named("v")
+    ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(
+      new StructType().add("v", new StructType().add("value", BinaryType)), 
schemaOf(userStructWithTypedValue))
+  }
+
+  /**
+   * Nested schema pruning narrows the request to the leaves a query touches, 
so `SELECT v.value`
+   * arrives here as a one-member struct. That member is exactly the one a 
shredded file returns
+   * null for, so a subset has to be rejected too.
+   */
+  @Test
+  def testValidateNoShreddedVariantStructsRejectsPrunedVariantStruct(): Unit = 
{
+    Seq("value", "metadata").foreach { member =>
+      val requiredSchema = new StructType().add("v", new 
StructType().add(member, BinaryType))
+      val failure = Assertions.assertThrows(classOf[HoodieException], () =>
+        
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
schemaOf(shreddedVariant("v"))))
+      Assertions.assertTrue(failure.getMessage.contains("'v'"),
+        s"The pruned-to-$member error must name the column, got: 
${failure.getMessage}")
+
+      
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
schemaOf(unshreddedVariant("v")))
+    }
+  }
+
+  /**
+   * The Spark 3.x readers force spark.sql.caseSensitive=false, so a request 
spelled in another case
+   * still resolves onto the file's lower-case group - at the column, at the 
member, and below a
+   * struct. The guard has to follow the same resolution or the mixed-case 
spelling reads past it.
+   */
+  @Test
+  def testValidateNoShreddedVariantStructsIgnoresCase(): Unit = {
+    val mixedCaseVariant = new StructType().add("Value", 
BinaryType).add("Metadata", BinaryType)
+    Seq(
+      ("column", new StructType().add("V", mixedCaseVariant), 
schemaOf(shreddedVariant("v")), "'V'"),
+      ("nested", new StructType().add("S", new StructType().add("Inner", 
mixedCaseVariant)),
+        
schemaOf(Types.optionalGroup().addField(shreddedVariant("inner")).named("s")), 
"'S.Inner'")
+    ).foreach { case (leg, requiredSchema, fileSchema, path) =>
+      val failure = Assertions.assertThrows(classOf[HoodieException], () =>
+        
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
fileSchema))
+      Assertions.assertTrue(failure.getMessage.contains(path),
+        s"The mixed-case $leg error must name $path as requested, got: 
${failure.getMessage}")
+    }
+
+    ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(
+      new StructType().add("V", mixedCaseVariant), 
schemaOf(unshreddedVariant("v")))
+  }
+
+  /** How a variant column is declared on Spark 3.x, which has no VariantType. 
*/
+  private def variantStruct: StructType =
+    new StructType().add("value", BinaryType).add("metadata", BinaryType)
+
   /**
    * What a 2-level repeated group wraps here: a single struct field "e" 
holding the shredded
    * variant "inner". The repeated group is itself the element record, so 
without the name arms
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 7644c232d61b..f89b51cfce65 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
@@ -1090,6 +1090,49 @@ class TestVariantDataType extends HoodieSparkSqlTestBase 
with VariantShreddingTe
     }
   }
 
+  test(s"Test Backward Compatibility: Shredded Variant Table Read Fails Fast 
in Spark 3.x") {
+    // The compat recipe above (declare the variant column as its two binary 
members) returns a null
+    // `value` for every shredded row, because parquet matches the request 
against the file by name
+    // and simply leaves typed_value behind. Spark 3.x cannot reconstruct one 
- no
+    // VariantShreddingProvider on its classpath - so the read must fail 
instead, through
+    // ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs. This is 
the leg that pins the
+    // guard's call sites; the schema walk itself is covered by 
TestParquetSchemaEvolutionUtils.
+    assume(HoodieSparkUtils.isSpark3, "This test verifies Spark 3.x rejects 
shredded Variant tables")
+
+    withTempDir { tmpDir =>
+      // The fixture's first file group is shredded and its second is not, so 
the failure has to come
+      // from the shredded file rather than from the table being uniformly 
unreadable.
+      HoodieTestUtils.extractZipToDirectory(
+        "variant_backward_compat/variant_shredded_mixed_cow.zip", 
tmpDir.toPath, getClass)
+      val tablePath = 
tmpDir.toPath.resolve("variant_shredded_mixed_cow").toString
+      val tableName = generateTableName
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  v struct<value: binary, metadata: binary>,
+           |  ts long
+           |) using hudi
+           |location '$tablePath'
+           |tblproperties (
+           |  primaryKey = 'id',
+           |  tableType = 'cow',
+           |  preCombineField = 'ts'
+           |)
+       """.stripMargin)
+
+      Seq("select id, v from %s order by id", "select id, v.value from %s 
order by id").foreach { query =>
+        val failure = 
intercept[Exception](spark.sql(query.format(tableName)).collect())
+        // Assert the guard's own message, not just any failure naming the 
column: the second query
+        // is pruned to a single member, which must land on the guard rather 
than read past it.
+        val messages = 
Iterator.iterate[Throwable](failure)(_.getCause).takeWhile(_ != null)
+          .map(t => if (t.getMessage == null) "" else t.getMessage)
+        assert(messages.exists(m => m.contains("shredded variant") && 
m.contains("'v'")),
+          s"The read must fail with the shredded-variant guard, got: 
${failure}")
+      }
+    }
+  }
+
   /**
    * Helper method to verify backward compatibility of reading Spark 4.0 
Variant tables in Spark 3.x
    */
@@ -1289,6 +1332,9 @@ class TestVariantDataType extends HoodieSparkSqlTestBase 
with VariantShreddingTe
 
     withRecordType()(withTempDir { tmp =>
       val tableName = generateTableName
+      // Deliberately no 
hoodie.parquet.variant.shredding.schema.inference.enabled here: this leg
+      // relies on the default (on since #19690) so the flip is pinned end to 
end - the inferred
+      // footers asserted below would stop appearing if the default went back 
to false.
       spark.sql(
         s"""
            |create table $tableName (
@@ -1302,8 +1348,7 @@ class TestVariantDataType extends HoodieSparkSqlTestBase 
with VariantShreddingTe
            | tblproperties (
            |  primaryKey = 'id',
            |  type = 'cow',
-           |  preCombineField = 'ts',
-           |  hoodie.parquet.variant.shredding.schema.inference.enabled = 
'true'
+           |  preCombineField = 'ts'
            | )
         """.stripMargin)
 
diff --git 
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
 
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
index 90be1df5068d..92010be7f336 100644
--- 
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
+++ 
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark3Adapter.scala
@@ -23,9 +23,11 @@ import org.apache.hudi.common.model.FileSlice
 import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.table.HoodieTableMetaClient
 import org.apache.hudi.common.table.cdc.HoodieCDCFileSplit
+import org.apache.hudi.common.util.{Option => HOption}
 import org.apache.hudi.common.util.JsonUtils
 import org.apache.hudi.spark.internal.ReflectUtil
 
+import org.apache.parquet.schema.MessageType
 import org.apache.parquet.schema.Type
 import org.apache.parquet.schema.Type.Repetition
 import org.apache.spark.api.java.JavaSparkContext
@@ -42,9 +44,10 @@ import 
org.apache.spark.sql.catalyst.planning.PhysicalOperation
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.catalyst.trees.Origin
 import org.apache.spark.sql.catalyst.util.DateFormatter
+import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
 import org.apache.spark.sql.execution.{PartitionedFileUtil, QueryExecution, 
SQLExecution}
 import org.apache.spark.sql.execution.datasources._
-import org.apache.spark.sql.execution.datasources.parquet.HoodieFormatTrait
+import org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, 
HoodieParquetReadSupport, Spark3HoodieParquetReadSupport}
 import org.apache.spark.sql.execution.streaming.MemoryStream
 import org.apache.spark.sql.hudi.{HoodieMemoryStream, SparkAdapter}
 import org.apache.spark.sql.sources.{BaseRelation, Filter}
@@ -220,6 +223,20 @@ abstract class BaseSpark3Adapter extends SparkAdapter with 
Logging {
   ): Type = {
     throw new UnsupportedOperationException("Spark 3.x does not support 
VariantType")
   }
+  override def createParquetReadSupport(convertTz: Option[java.time.ZoneId],
+                                       enableVectorizedReader: Boolean,
+                                       enableTimestampFieldRepair: Boolean,
+                                       datetimeRebaseSpec: RebaseSpec,
+                                       tableSchemaOpt: HOption[MessageType])
+      : HoodieParquetReadSupport = {
+    // Spark 3.x reads a variant as its unshredded struct shape and cannot 
reconstruct a shredded
+    // one; the subclass rejects such a file rather than returning null 
values. Needed on this route
+    // specifically because log blocks are read through the read support 
rather than through the
+    // per-version parquet reader that guards base files.
+    new Spark3HoodieParquetReadSupport(convertTz, enableVectorizedReader, 
enableTimestampFieldRepair,
+      datetimeRebaseSpec, getRebaseSpec("LEGACY"), tableSchemaOpt)
+  }
+
   override def isVariantShreddingStruct(structType: StructType): Boolean = {
     // Spark 3.x does not support Variant shredding
     false
diff --git 
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3HoodieParquetReadSupport.scala
 
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3HoodieParquetReadSupport.scala
new file mode 100644
index 000000000000..0c11f31f6321
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3HoodieParquetReadSupport.scala
@@ -0,0 +1,61 @@
+/*
+ * 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.execution.datasources.parquet
+
+import org.apache.hudi.common.util.{Option => HOption}
+
+import org.apache.parquet.hadoop.api.InitContext
+import org.apache.parquet.hadoop.api.ReadSupport.ReadContext
+import org.apache.parquet.schema.MessageType
+import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
+import org.apache.spark.sql.types.StructType
+
+import java.time.ZoneId
+
+/**
+ * The Spark 3.x [[HoodieParquetReadSupport]], rejecting a shredded variant 
the request cannot
+ * reconstruct. Mirrors 
[[org.apache.spark.sql.adapter.BaseSpark3Adapter#createParquetReadSupport]]'s
+ * Spark 4.0 sibling, which rejects at the same point for the same reason.
+ *
+ * The per-version parquet readers guard base-file reads, but they are not the 
only route: log
+ * blocks - native parquet log files and the inline blocks of an avro log file 
- are read by
+ * {@code HoodieSparkParquetReader.getUnsafeRowIterator}, which builds a 
{@code ParquetReader} on
+ * this read support instead. A shredded variant in a log block therefore only 
meets a guard here.
+ */
+class Spark3HoodieParquetReadSupport(convertTz: Option[ZoneId],
+                                     enableVectorizedReader: Boolean,
+                                     enableTimestampFieldRepair: Boolean,
+                                     datetimeRebaseSpec: RebaseSpec,
+                                     int96RebaseSpec: RebaseSpec,
+                                     tableSchemaOpt: HOption[MessageType] = 
HOption.empty())
+  extends HoodieParquetReadSupport(
+    convertTz, enableVectorizedReader, enableTimestampFieldRepair,
+    datetimeRebaseSpec, int96RebaseSpec, tableSchemaOpt) {
+
+  override def init(context: InitContext): ReadContext = {
+    val readContext = super.init(context)
+    // Anchored on the catalyst request and the file schema, not on the 
requested parquet schema:
+    // a Spark 3.x read asks for the variant's binary members alone, so the 
requested schema has
+    // already had typed_value clipped away by the time it gets here and only 
the file can show
+    // that the column is shredded.
+    
Option(context.getConfiguration.get(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA))
+      .map(StructType.fromString)
+      .foreach(ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(_, 
context.getFileSchema))
+    readContext
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala
 
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala
index 1e4411943e0e..e84201f1a33c 100644
--- 
a/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala
+++ 
b/hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark3LegacyHoodieParquetFileFormat.scala
@@ -247,6 +247,14 @@ abstract class 
Spark3LegacyHoodieParquetFileFormat(shouldAppendPartitionValues:
 
       // Clone new conf
       val hadoopAttemptConf = new 
Configuration(broadcastedHadoopConf.value.value)
+      // A variant column is declared as its unshredded struct shape on Spark 
3.x (no VariantType);
+      // reject a file that shreds it before either branch below, so the read 
fails naming the
+      // column instead of projecting the group by name and returning a null 
value for every
+      // shredded row. Gated like the schema-on-read guard: an empty 
projection (count(*)) reads no
+      // column data and must not pay a footer read.
+      if (requiredSchema.nonEmpty) {
+        
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
footerFileMetaData.getSchema)
+      }
       val typeChangeInfos: java.util.Map[Integer, Pair[DataType, DataType]] = 
if (shouldUseInternalSchema) {
         // Same guard as ParquetSchemaEvolutionUtils.getHadoopConfClone: 
schema-on-read cannot
         // reconstruct shredded variants, so fail loudly instead of silently 
dropping typed_value.
diff --git 
a/hudi-spark-datasource/hudi-spark3-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark3HoodieParquetReadSupport.scala
 
b/hudi-spark-datasource/hudi-spark3-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark3HoodieParquetReadSupport.scala
new file mode 100644
index 000000000000..1edd02fb3cb8
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark3-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestSpark3HoodieParquetReadSupport.scala
@@ -0,0 +1,89 @@
+/*
+ * 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.execution.datasources.parquet
+
+import org.apache.hudi.common.util.{Option => HOption}
+import org.apache.hudi.exception.HoodieException
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.parquet.hadoop.api.InitContext
+import org.apache.parquet.schema.{MessageType, Type, Types}
+import org.apache.spark.sql.execution.datasources.DataSourceUtils
+import 
org.apache.spark.sql.execution.datasources.parquet.VariantParquetTestFixtures.{shreddedVariant,
 unshreddedVariant}
+import org.apache.spark.sql.types.{BinaryType, StructType}
+import org.junit.jupiter.api.{Assertions, Test}
+
+import java.util.{HashMap => JHashMap, Set => JSet}
+
+/**
+ * Pins the wiring of [[Spark3HoodieParquetReadSupport]] over a hand-built 
{@link InitContext}: that
+ * `init` reads the catalyst request from SPARK_ROW_REQUESTED_SCHEMA and hands 
the guard the file
+ * schema. The schema walk itself is covered by 
TestParquetSchemaEvolutionUtils; a slip in either
+ * wire here would not fail there, it would fall straight back to the 
null-value read this class
+ * exists to stop. No SparkSession: Spark's own `init` only needs the 
requested schema in the conf.
+ */
+class TestSpark3HoodieParquetReadSupport {
+
+  /** A variant column the way a Spark 3.x reader declares it: its two binary 
members. */
+  private val variantRequestedAsStruct =
+    new StructType().add("v", new StructType().add("value", 
BinaryType).add("metadata", BinaryType))
+
+  @Test
+  def testInitRejectsShreddedFileForVariantRequestedAsStruct(): Unit = {
+    val failure = Assertions.assertThrows(classOf[HoodieException], () =>
+      readSupport().init(initContext(variantRequestedAsStruct, 
fileOf(shreddedVariant("v")))))
+    Assertions.assertTrue(
+      failure.getMessage.contains("shredded variant") && 
failure.getMessage.contains("'v'"),
+      s"init must fail with the guard's own message naming the column, got: 
${failure.getMessage}")
+  }
+
+  @Test
+  def testInitReadsUnshreddedFileForVariantRequestedAsStruct(): Unit = {
+    val context = readSupport().init(initContext(variantRequestedAsStruct, 
fileOf(unshreddedVariant("v"))))
+    Assertions.assertTrue(context.getRequestedSchema.containsField("v"),
+      "an unshredded file must initialise as before, with the column in the 
requested schema")
+  }
+
+  /**
+   * Why `init` anchors on the file schema: Spark clips the requested parquet 
schema to the catalyst
+   * request, and a two-member request leaves no typed_value in it. Anchoring 
on the requested schema
+   * instead would therefore never fire - which is what the base class, 
guard-free, demonstrates.
+   */
+  @Test
+  def testClippedRequestCarriesNoTypedValue(): Unit = {
+    val base = new HoodieParquetReadSupport(None, false, false, rebaseSpec, 
rebaseSpec, HOption.empty())
+    val context = base.init(initContext(variantRequestedAsStruct, 
fileOf(shreddedVariant("v"))))
+    val requested = context.getRequestedSchema
+    
Assertions.assertFalse(requested.getType(requested.getFieldIndex("v")).asGroupType().containsField("typed_value"),
+      "the clipped request must not carry typed_value, so only the file schema 
can show the shredding")
+  }
+
+  private def readSupport(): Spark3HoodieParquetReadSupport =
+    new Spark3HoodieParquetReadSupport(None, false, false, rebaseSpec, 
rebaseSpec, HOption.empty())
+
+  /** A CORRECTED rebase spec built the version-neutral way the readers 
themselves use. */
+  private def rebaseSpec = DataSourceUtils.datetimeRebaseSpec(_ => null, 
"CORRECTED")
+
+  private def initContext(requested: StructType, fileSchema: MessageType): 
InitContext = {
+    val conf = new Configuration(false)
+    conf.set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA, requested.json)
+    new InitContext(conf, new JHashMap[String, JSet[String]](), fileSchema)
+  }
+
+  private def fileOf(column: Type): MessageType = 
Types.buildMessage().addField(column).named("file")
+}
diff --git 
a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33ParquetReader.scala
 
b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33ParquetReader.scala
index becf3911c426..e5f94df47866 100644
--- 
a/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33ParquetReader.scala
+++ 
b/hudi-spark-datasource/hudi-spark3.3.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark33ParquetReader.scala
@@ -113,6 +113,11 @@ class Spark33ParquetReader(enableVectorizedReader: Boolean,
     }
 
     lazy val footerFileMetaData = fileFooter.getFileMetaData
+    // A variant column is declared as its unshredded struct shape on Spark 
3.x (no VariantType);
+    // reject a file that shreds it here, before the reader is built, so the 
read fails naming the
+    // column instead of projecting the group by name and returning a null 
value for every
+    // shredded row.
+    
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
footerFileMetaData.getSchema)
     val datetimeRebaseSpec = DataSourceUtils.datetimeRebaseSpec(
       footerFileMetaData.getKeyValueMetaData.get,
       datetimeRebaseModeInRead)
diff --git 
a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34ParquetReader.scala
 
b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34ParquetReader.scala
index 9f09d03dba07..f350afacfecf 100644
--- 
a/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34ParquetReader.scala
+++ 
b/hudi-spark-datasource/hudi-spark3.4.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark34ParquetReader.scala
@@ -110,6 +110,11 @@ class Spark34ParquetReader(enableVectorizedReader: Boolean,
     }
 
     lazy val footerFileMetaData = fileFooter.getFileMetaData
+    // A variant column is declared as its unshredded struct shape on Spark 
3.x (no VariantType);
+    // reject a file that shreds it here, before the reader is built, so the 
read fails naming the
+    // column instead of projecting the group by name and returning a null 
value for every
+    // shredded row.
+    
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
footerFileMetaData.getSchema)
     val datetimeRebaseSpec = DataSourceUtils.datetimeRebaseSpec(
       footerFileMetaData.getKeyValueMetaData.get,
       datetimeRebaseModeInRead)
diff --git 
a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35ParquetReader.scala
 
b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35ParquetReader.scala
index e91d01d07f22..31955d13dbdf 100644
--- 
a/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35ParquetReader.scala
+++ 
b/hudi-spark-datasource/hudi-spark3.5.x/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/Spark35ParquetReader.scala
@@ -117,6 +117,11 @@ class Spark35ParquetReader(enableVectorizedReader: Boolean,
     }
 
     val footerFileMetaData = fileFooter.getFileMetaData
+    // A variant column is declared as its unshredded struct shape on Spark 
3.x (no VariantType);
+    // reject a file that shreds it here, before the reader is built, so the 
read fails naming the
+    // column instead of projecting the group by name and returning a null 
value for every
+    // shredded row.
+    
ParquetSchemaEvolutionUtils.validateNoShreddedVariantStructs(requiredSchema, 
footerFileMetaData.getSchema)
     val datetimeRebaseSpec = DataSourceUtils.datetimeRebaseSpec(
       footerFileMetaData.getKeyValueMetaData.get,
       datetimeRebaseModeInRead)

Reply via email to