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 e1dd86e968eb fix(avro): detect shredded variant base files by shape so 
reconstruct… (#19582)
e1dd86e968eb is described below

commit e1dd86e968ebff25c43ad5d9774c3cf19fc6bb43
Author: voonhous <[email protected]>
AuthorDate: Thu Aug 13 18:09:03 2026 +0800

    fix(avro): detect shredded variant base files by shape so reconstruct… 
(#19582)
    
    * fix(avro): detect shredded variant base files by shape so reconstruction 
engages
    
    HoodieVariantReconstruction never engaged on real files: the reader's file
    schema comes from converting the parquet footer MessageType, which loses the
    variant logical type, so a shredded variant group arrives as a plain
    {metadata, value, typed_value} record. Detection required a
    HoodieSchema.Variant with isShredded(), found none, and the reader proceeded
    at the unshredded schema, silently clipping typed_value away. Any avro-path
    rewrite of a shredded base file persisted the nulls; the easiest trigger is
    the COW small-file merge, where a plain second INSERT bin-packs into an
    existing file group (#19567). This is the second leg of #19556; the Spark
    reader-context leg was #19558.
    
    Detect the on-disk side by SHAPE, anchored by the requested side: the
    requested column (table schema, logical type intact) must be a variant for
    the shape match to count, so plain user structs of the same shape are
    unaffected. A file schema that kept its logical type still short-circuits on
    isShredded. With detection engaging, the existing fail-fast branches
    (shredded reading disabled, no provider available) now fire where they
    previously never triggered, turning silent corruption into loud errors.
    
    Extracted from the shredding-inference branch (c2ec87d977d5), leaving the
    inference-specific parts behind.
    
    - Unit tests: footer-style plain-record schema engages and reconstructs end
      to end; the same shape without a variant requested column stays 
disengaged.
    - Functional test: the issue's repro, a COW small-file merge round trip with
      the shredded layout and the same-file-group bin-pack pinned.
    
    * review(19582): make the small-file merge hand the reader a 
variant-anchored requested schema
    
    Round-1 review traced the repro path further than the fix went: the COW
    small-file bin-pack runs HoodieConcatHandle -> HoodieMergeHelper, where
    isStrictProjectionOf(readerSchema, writerSchema) fails on RECORD vs VARIANT
    because the footer-derived reader schema lost the variant logical type. The
    merge then read the base file AT the footer schema, so the requested side 
had
    no variant column for the shape detection to anchor on, reconstruction 
stayed
    disengaged, and the subsequent rewriteRecordWithNewSchema silently dropped
    typed_value. Detection alone left the E2E scenario broken.
    
    - Share the anchored predicate as VariantSchemaUtils.isShreddedVariantTarget
      and add alignShreddedVariants, which swaps footer-plain shredded variant
      columns for their requested form; HoodieVariantReconstruction delegates.
    - HoodieMergeHelper aligns shredded variant columns before the strict
      projection check, so recordSchema resolves to the writer schema, the avro
      reader engages reconstruction, and the lossy rewrite is skipped.
    - Pin the E2E test to the AVRO record type: the record type picks the reader
      the merge uses, and this PR covers the avro leg.
    - Add the requested negative test: a footer-derived plain unshredded
      {metadata, value} record against a variant requested column stays
      disengaged; add a compatibility test for align + strict projection.
    
    Residual: a shredded base file merged while the writer schema evolves (added
    columns, or the internal-schema transformer) still takes the rewrite path 
and
    drops typed_value; that needs a variant-aware record rewrite and is left to 
a
    follow-up.
    
    * review(19582): close the evolving-schema leg of the variant-dropping merge
    
    Round-2 review asked whether the schema-evolution path still drops
    typed_value. It did. The previous commit aligned shredded variant columns
    only inside the strict-projection check, so the fix reached one branch of
    recordSchema = isPureProjection ? writerSchema : readerSchema. Whenever the
    writer schema is not a strict projection of the file's - an added column, or
    the Advanced Schema Evolution transformer - runMerge still handed the reader
    the raw footer schema, reconstruction could not anchor on a variant 
requested
    column, and rewriteRecordWithNewSchema copied {metadata, value} by name and
    dropped typed_value. Same silent corruption as #19567, reached by an 
ordinary
    ALTER TABLE ADD COLUMNS with no schema-on-read config.
    
    - Align the reader schema once, where it is derived, so both branches and 
the
      schema-evolution transformer reason about the same variant-bearing form.
      This also drops the inline alignment from the projection check, so the
      method reads as it did before.
    - Test the added-column leg end to end: shredded base file, ALTER TABLE ADD
      COLUMNS, then the bin-pack merge; rows from the first commit keep their
      variants and pick up a null for the new column.
    - Add the requested unshredded twin of the small-file merge test, split the
      way the clustering pair is, plus a unit assertion that 
alignShreddedVariants
      leaves a footer-derived plain {metadata, value} column untouched. Both pin
      that the alignment stays a no-op for ordinary variant tables now that it
      runs on every runMerge.
    
    * review(19582): order isShreddedVariantTarget file-first like its siblings
    
    VariantSchemaUtils.isShreddedVariantTarget took (requested, file) while
    alignShreddedVariants and HoodieVariantReconstruction.create both take
    (file, requested). Both parameters are HoodieSchema, so a swapped call
    compiles and silently inverts the detection instead of failing.
    
    Flip the signature to (file, requested) so the class reads one way
    throughout, update the two call sites, and document the two parameters.
    No behaviour change.
    
    * review(19582): reconstruct shredded variants nested below the top level
    
    HoodieRowParquetWriteSupport.processNestedDataType shreds variants at any
    depth, but the avro read path only ever looked at top-level columns: a
    variant inside a struct, an array element or a map value reached
    HoodieVariantReconstruction as a plain {metadata, value, typed_value}
    record that nothing matched. create() saw no target, returned null, and
    the column read at the unshredded schema, so typed_value was dropped -
    the same silent loss as the top-level defect, and without tripping either
    fail-fast branch, since nothing at the top level looked shredded.
    
    Make the walk recursive on both halves:
    
    - VariantSchemaUtils gains one shared recursion over records, array
      elements and map values. alignShreddedVariants keeps its contract
      (file schema in, requested-aligned schema out, same instance when
      nothing matches) and gains a dual, toShreddedReadSchema, which is the
      schema to read the file at. stripVariantShredding recurses too, so the
      output schema is unshredded all the way down.
    - HoodieVariantReconstruction replaces the flat isTarget[] plus parallel
      sub-schema arrays with a rebuild plan built once at create(): a variant
      node per target, and record/array/map nodes that descend into it. A
      position with nothing shredded below it plans to null and its value is
      passed through untouched, so non-variant tables walk the same path they
      did before.
    
    Tests: nested reconstruction across all three container kinds (verified
    red on the assertNotNull before the change), the nested unshredded twin
    that must stay disengaged, and a nested alignment case in
    TestHoodieSchemaCompatibility.
---
 .../table/action/commit/HoodieMergeHelper.java     |  12 +-
 .../hudi/common/avro/VariantSchemaUtils.java       | 224 ++++++++++++++++++---
 .../schema/TestHoodieSchemaCompatibility.java      |  55 +++++
 .../hadoop/HoodieVariantReconstruction.java        | 217 ++++++++++++++------
 .../hadoop/TestHoodieVariantReconstruction.java    | 192 ++++++++++++++++++
 .../sql/hudi/dml/schema/TestVariantDataType.scala  | 204 +++++++++++++++++++
 6 files changed, 812 insertions(+), 92 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java
index d04cf143640c..b9e963a7b80a 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java
@@ -18,6 +18,7 @@
 
 package org.apache.hudi.table.action.commit;
 
+import org.apache.hudi.common.avro.VariantSchemaUtils;
 import org.apache.hudi.common.config.HoodieCommonConfig;
 import org.apache.hudi.common.model.HoodieBaseFile;
 import org.apache.hudi.common.model.HoodieRecord;
@@ -83,7 +84,16 @@ public class HoodieMergeHelper<T> extends BaseMergeHelper {
     HoodieFileReader bootstrapFileReader = null;
 
     HoodieSchema writerSchema = mergeHandle.getWriterSchemaWithMetaFields();
-    HoodieSchema readerSchema = baseFileReader.getSchema();
+    // A shredded variant column loses its logical type through the parquet 
footer, so the base
+    // file's schema surfaces it as a plain {metadata, value, typed_value} 
record. Align such
+    // columns to the writer's variant form once, here, because every 
downstream use of the reader
+    // schema needs the aligned form: the strict-projection check below 
(RECORD vs VARIANT can
+    // never pass), and - whichever branch it lands on - the schema the reader 
is handed, since
+    // HoodieVariantReconstruction anchors on the requested column being a 
variant. Read at the raw
+    // footer schema instead and reconstruction stays disengaged, so the 
rewrite below copies
+    // {metadata, value} by name and silently drops typed_value (#19567). 
Returns the file schema
+    // untouched when no column has the shredded shape, so non-variant tables 
are unaffected.
+    HoodieSchema readerSchema = 
VariantSchemaUtils.alignShreddedVariants(baseFileReader.getSchema(), 
writerSchema);
 
     // In case Advanced Schema Evolution is enabled we might need to rewrite 
currently
     // persisted records to adhere to an evolved schema
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantSchemaUtils.java 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantSchemaUtils.java
index 9ef2b847e359..b2155d35eb05 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantSchemaUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantSchemaUtils.java
@@ -22,6 +22,7 @@ package org.apache.hudi.common.avro;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.HoodieSchemaField;
 import org.apache.hudi.common.schema.HoodieSchemaType;
+import org.apache.hudi.common.util.Option;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -37,8 +38,10 @@ public class VariantSchemaUtils {
   }
 
   /**
-   * Strips shredding from top-level variant fields in {@code schema}, 
replacing each shredded
-   * variant with its unshredded form (dropping {@code typed_value}). 
Non-variant fields and
+   * Strips shredding from the variant fields in {@code schema}, replacing 
each shredded variant
+   * with its unshredded form (dropping {@code typed_value}). Variants nested 
inside records, array
+   * elements and map values are stripped too, since the row writer shreds at 
any depth
+   * ({@code HoodieRowParquetWriteSupport.processNestedDataType}). Non-variant 
fields and
    * already-unshredded variants pass through unchanged; returns {@code 
schema} as-is when nothing
    * changes.
    */
@@ -46,40 +49,213 @@ public class VariantSchemaUtils {
     if (schema.getType() != HoodieSchemaType.RECORD) {
       return schema;
     }
+    return stripRecordVariantShredding(schema);
+  }
 
-    List<HoodieSchemaField> fields = schema.getFields();
+  private static HoodieSchema stripRecordVariantShredding(HoodieSchema record) 
{
     List<HoodieSchemaField> newFields = new ArrayList<>();
     boolean changed = false;
-
-    for (HoodieSchemaField field : fields) {
+    for (HoodieSchemaField field : record.getFields()) {
       HoodieSchema fieldSchema = field.schema();
-      boolean wasNullable = fieldSchema.isNullable();
-      HoodieSchema unwrapped = wasNullable ? fieldSchema.getNonNullType() : 
fieldSchema;
+      HoodieSchema replacement = stripVariantShreddingAt(fieldSchema);
+      if (replacement != fieldSchema) {
+        changed = true;
+      }
+      // withSchema makes a fresh Avro Field: reusing one already bound to 
this record would fail
+      // Schema.setFields with "Field already used" when building the 
replacement record below.
+      newFields.add(field.withSchema(replacement));
+    }
+    if (!changed) {
+      return record;
+    }
+    return HoodieSchema.createRecord(
+        record.getAvroSchema().getName(),
+        record.getAvroSchema().getNamespace(),
+        record.getAvroSchema().getDoc(),
+        newFields);
+  }
 
-      if (unwrapped.getType() == HoodieSchemaType.VARIANT) {
-        HoodieSchema.Variant variant = (HoodieSchema.Variant) unwrapped;
-        if (variant.isShredded()) {
-          HoodieSchema.Variant unshredded = HoodieSchema.createVariant(
-              unwrapped.getAvroSchema().getName(),
-              unwrapped.getAvroSchema().getNamespace(),
-              unwrapped.getAvroSchema().getDoc());
-          HoodieSchema replacement = wasNullable ? 
HoodieSchema.createNullable(unshredded) : unshredded;
-          newFields.add(field.withSchema(replacement));
-          changed = true;
-          continue;
+  /** Strips shredding at one schema position, returning the argument instance 
when nothing changes. */
+  private static HoodieSchema stripVariantShreddingAt(HoodieSchema schema) {
+    boolean wasNullable = schema.isNullable();
+    HoodieSchema unwrapped = wasNullable ? schema.getNonNullType() : schema;
+    HoodieSchema replacement;
+    switch (unwrapped.getType()) {
+      case VARIANT:
+        if (!((HoodieSchema.Variant) unwrapped).isShredded()) {
+          return schema;
         }
+        replacement = HoodieSchema.createVariant(
+            unwrapped.getAvroSchema().getName(),
+            unwrapped.getAvroSchema().getNamespace(),
+            unwrapped.getAvroSchema().getDoc());
+        break;
+      case RECORD:
+        replacement = stripRecordVariantShredding(unwrapped);
+        break;
+      case ARRAY: {
+        HoodieSchema elementType = unwrapped.getElementType();
+        HoodieSchema strippedElement = stripVariantShreddingAt(elementType);
+        replacement = strippedElement == elementType ? unwrapped : 
HoodieSchema.createArray(strippedElement);
+        break;
+      }
+      case MAP: {
+        HoodieSchema valueType = unwrapped.getValueType();
+        HoodieSchema strippedValue = stripVariantShreddingAt(valueType);
+        replacement = strippedValue == valueType ? unwrapped : 
HoodieSchema.createMap(strippedValue);
+        break;
       }
-      newFields.add(field);
+      default:
+        return schema;
     }
-
-    if (!changed) {
+    if (replacement == unwrapped) {
       return schema;
     }
+    return wasNullable ? HoodieSchema.createNullable(replacement) : 
replacement;
+  }
+
+  /**
+   * Whether this column sits shredded on disk and must be read in that shape 
and reconstructed to
+   * serve the requested schema. A file schema that kept its variant logical 
type answers via
+   * {@link HoodieSchema.Variant#isShredded()}; but a file schema derived from 
converting the
+   * parquet footer MessageType loses the logical type (variant groups come 
back as plain records),
+   * so the on-disk side is detected by SHAPE, anchored by the requested side: 
the requested column
+   * (from the table schema, logical type intact) must be a variant for the 
shape match to count,
+   * leaving plain user structs of the same shape alone (#19567).
+   *
+   * @param fileFieldSchema      the column as it sits in the file schema
+   * @param requestedFieldSchema the same column as requested, carrying the 
variant logical type
+   */
+  public static boolean isShreddedVariantTarget(HoodieSchema fileFieldSchema, 
HoodieSchema requestedFieldSchema) {
+    HoodieSchema file = fileFieldSchema.getNonNullType();
+    if (file.getType() == HoodieSchemaType.VARIANT && ((HoodieSchema.Variant) 
file).isShredded()) {
+      return true;
+    }
+    HoodieSchema requested = requestedFieldSchema.getNonNullType();
+    return requested.getType() == HoodieSchemaType.VARIANT && 
isShreddedVariantShape(file);
+  }
+
+  /**
+   * Returns {@code fileSchema} with each shredded variant column (per
+   * {@link #isShreddedVariantTarget}) replaced by its requested counterpart, 
for projection or
+   * compatibility checks against {@code requestedSchema}. A footer-derived 
shredded variant column
+   * surfaces as a plain {@code {metadata, value, typed_value}} record and so 
can never look like a
+   * projection source of the requested variant, even though the readers 
reconstruct it (see
+   * HoodieVariantReconstruction). Returns {@code fileSchema} as-is when 
nothing matches.
+   */
+  public static HoodieSchema alignShreddedVariants(HoodieSchema fileSchema, 
HoodieSchema requestedSchema) {
+    if (fileSchema.getType() != HoodieSchemaType.RECORD || 
requestedSchema.getType() != HoodieSchemaType.RECORD) {
+      return fileSchema;
+    }
+    return swapShreddedVariantFields(fileSchema, requestedSchema, true);
+  }
+
+  /**
+   * The dual of {@link #alignShreddedVariants}: returns {@code 
requestedSchema} with each shredded
+   * variant column swapped to its on-disk (typed_value-bearing) form taken 
from {@code fileSchema}.
+   * This is the schema to read the file at, so parquet materializes {@code 
typed_value} for the
+   * reader to reconstruct from. Returns {@code requestedSchema} as-is when 
nothing matches, which
+   * is how callers detect that the file has no shredded variant column to 
reconstruct.
+   */
+  public static HoodieSchema toShreddedReadSchema(HoodieSchema 
requestedSchema, HoodieSchema fileSchema) {
+    if (fileSchema.getType() != HoodieSchemaType.RECORD || 
requestedSchema.getType() != HoodieSchemaType.RECORD) {
+      return requestedSchema;
+    }
+    return swapShreddedVariantFields(requestedSchema, fileSchema, false);
+  }
 
+  /**
+   * Walks {@code base} against its matching {@code other} fields by name, 
replacing every shredded
+   * variant position with the other side's schema. Recurses through records, 
array elements and map
+   * values, since the row writer shreds variants at any depth
+   * ({@code HoodieRowParquetWriteSupport.processNestedDataType}). {@code 
baseIsFile} says which of
+   * the two is the file side, which is what {@link #isShreddedVariantTarget} 
needs to anchor
+   * detection. Returns {@code base} when nothing matches.
+   */
+  private static HoodieSchema swapShreddedVariantFields(HoodieSchema base, 
HoodieSchema other, boolean baseIsFile) {
+    List<HoodieSchemaField> newFields = new ArrayList<>();
+    boolean changed = false;
+    for (HoodieSchemaField baseField : base.getFields()) {
+      HoodieSchema baseFieldSchema = baseField.schema();
+      Option<HoodieSchemaField> otherField = other.getField(baseField.name());
+      HoodieSchema replacement = otherField.isPresent()
+          ? swapShreddedVariantsAt(baseFieldSchema, otherField.get().schema(), 
baseIsFile)
+          : baseFieldSchema;
+      if (replacement != baseFieldSchema) {
+        changed = true;
+      }
+      // Copy untouched fields too (withSchema makes a fresh Avro Field): 
reusing a field already
+      // bound to the base record would fail Schema.setFields with "Field 
already used" when
+      // building the swapped record below.
+      newFields.add(baseField.withSchema(replacement));
+    }
+    if (!changed) {
+      return base;
+    }
     return HoodieSchema.createRecord(
-        schema.getAvroSchema().getName(),
-        schema.getAvroSchema().getNamespace(),
-        schema.getAvroSchema().getDoc(),
+        base.getAvroSchema().getName(),
+        base.getAvroSchema().getNamespace(),
+        base.getAvroSchema().getDoc(),
         newFields);
   }
+
+  /** Swaps at one schema position, returning the {@code base} instance when 
nothing changes. */
+  private static HoodieSchema swapShreddedVariantsAt(HoodieSchema base, 
HoodieSchema other, boolean baseIsFile) {
+    if (baseIsFile ? isShreddedVariantTarget(base, other) : 
isShreddedVariantTarget(other, base)) {
+      // Take the other side's schema wholesale, nullability included.
+      return other;
+    }
+    boolean wasNullable = base.isNullable();
+    HoodieSchema baseInner = wasNullable ? base.getNonNullType() : base;
+    HoodieSchema otherInner = other.isNullable() ? other.getNonNullType() : 
other;
+    if (baseInner.getType() != otherInner.getType()) {
+      return base;
+    }
+    HoodieSchema replacement;
+    switch (baseInner.getType()) {
+      // VARIANT is deliberately absent: a variant that is not a target here 
is either unshredded or
+      // has no requested-side anchor, and its typed_value internals are the 
provider's business.
+      case RECORD:
+        replacement = swapShreddedVariantFields(baseInner, otherInner, 
baseIsFile);
+        break;
+      case ARRAY: {
+        HoodieSchema baseElement = baseInner.getElementType();
+        HoodieSchema swappedElement = swapShreddedVariantsAt(baseElement, 
otherInner.getElementType(), baseIsFile);
+        replacement = swappedElement == baseElement ? baseInner : 
HoodieSchema.createArray(swappedElement);
+        break;
+      }
+      case MAP: {
+        HoodieSchema baseValue = baseInner.getValueType();
+        HoodieSchema swappedValue = swapShreddedVariantsAt(baseValue, 
otherInner.getValueType(), baseIsFile);
+        replacement = swappedValue == baseValue ? baseInner : 
HoodieSchema.createMap(swappedValue);
+        break;
+      }
+      default:
+        return base;
+    }
+    if (replacement == baseInner) {
+      return base;
+    }
+    return wasNullable ? HoodieSchema.createNullable(replacement) : 
replacement;
+  }
+
+  /** The on-disk shredded variant shape: a record of exactly {metadata: 
bytes, value: [nullable] bytes, typed_value}. */
+  private static boolean isShreddedVariantShape(HoodieSchema schema) {
+    if (schema.getType() != HoodieSchemaType.RECORD || 
schema.getFields().size() != 3) {
+      return false;
+    }
+    if 
(!schema.getField(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD).isPresent()) {
+      return false;
+    }
+    return isBytesField(schema, HoodieSchema.Variant.VARIANT_METADATA_FIELD)
+        && isBytesField(schema, HoodieSchema.Variant.VARIANT_VALUE_FIELD);
+  }
+
+  private static boolean isBytesField(HoodieSchema schema, String fieldName) {
+    return schema.getField(fieldName)
+        .map(HoodieSchemaField::schema)
+        .map(s -> s.isNullable() ? s.getNonNullType() : s)
+        .map(s -> s.getType() == HoodieSchemaType.BYTES)
+        .orElse(false);
+  }
 }
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
index 0d10076a5999..5be6716c2e7a 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaCompatibility.java
@@ -18,6 +18,7 @@
 
 package org.apache.hudi.common.schema;
 
+import org.apache.hudi.common.avro.VariantSchemaUtils;
 import org.apache.hudi.exception.SchemaBackwardsCompatibilityException;
 import org.apache.hudi.exception.SchemaCompatibilityException;
 
@@ -41,6 +42,7 @@ import static 
org.apache.hudi.common.schema.TestHoodieSchemaUtils.SIMPLE_SCHEMA;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -215,6 +217,59 @@ public class TestHoodieSchemaCompatibility {
     assertTrue(HoodieSchemaCompatibility.isStrictProjectionOf(sourceSchema, 
converted));
   }
 
+  @Test
+  public void testIsStrictProjectionWithAlignedShreddedVariants() {
+    // A shredded variant base file surfaces through the parquet footer as a 
plain
+    // {metadata, value, typed_value} record, so it can never look like a 
strict projection
+    // source of the table's variant column. HoodieMergeHelper aligns such 
columns via
+    // VariantSchemaUtils.alignShreddedVariants before the check so the merge 
reads at the
+    // writer schema and variant reconstruction can engage (#19567).
+    HoodieSchema footerShredded = HoodieSchema.createRecord("v", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("metadata", 
HoodieSchema.create(HoodieSchemaType.BYTES)),
+        HoodieSchemaField.of("value", 
HoodieSchema.createNullable(HoodieSchemaType.BYTES)),
+        HoodieSchemaField.of("typed_value", 
HoodieSchema.createNullable(HoodieSchemaType.INT))));
+    HoodieSchema fileSchema = HoodieSchema.createRecord("rec", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+        HoodieSchemaField.of("v", 
HoodieSchema.createNullable(footerShredded))));
+    HoodieSchema writerSchema = HoodieSchema.createRecord("rec", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+        HoodieSchemaField.of("v", 
HoodieSchema.createNullable(HoodieSchema.createVariant()))));
+
+    assertFalse(HoodieSchemaCompatibility.isStrictProjectionOf(fileSchema, 
writerSchema));
+    assertTrue(HoodieSchemaCompatibility.isStrictProjectionOf(
+        VariantSchemaUtils.alignShreddedVariants(fileSchema, writerSchema), 
writerSchema));
+
+    // Without a variant on the requested side there is nothing to align: a 
plain user struct of
+    // the same shape passes through untouched.
+    assertSame(fileSchema, 
VariantSchemaUtils.alignShreddedVariants(fileSchema, fileSchema));
+
+    // The alignment now runs on every HoodieMergeHelper.runMerge, so pin the 
ordinary unshredded
+    // table too: a footer-derived plain {metadata, value} column carries no 
typed_value to
+    // reconstruct, and must pass through untouched even against a variant 
requested column.
+    HoodieSchema footerUnshredded = HoodieSchema.createRecord("v", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("metadata", 
HoodieSchema.create(HoodieSchemaType.BYTES)),
+        HoodieSchemaField.of("value", 
HoodieSchema.createNullable(HoodieSchemaType.BYTES))));
+    HoodieSchema unshreddedFileSchema = HoodieSchema.createRecord("rec", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+        HoodieSchemaField.of("v", 
HoodieSchema.createNullable(footerUnshredded))));
+    assertSame(unshreddedFileSchema,
+        VariantSchemaUtils.alignShreddedVariants(unshreddedFileSchema, 
writerSchema));
+
+    // The row writer shreds variants at any depth, so the alignment has to 
descend as well:
+    // a variant nested in a struct hits the same lossy rewrite when it is 
left unaligned.
+    HoodieSchema nestedFileSchema = HoodieSchema.createRecord("rec", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+        HoodieSchemaField.of("nested", HoodieSchema.createRecord("nested_rec", 
"example.schema", null,
+            Arrays.asList(HoodieSchemaField.of("v", 
HoodieSchema.createNullable(footerShredded)))))));
+    HoodieSchema nestedWriterSchema = HoodieSchema.createRecord("rec", 
"example.schema", null, Arrays.asList(
+        HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+        HoodieSchemaField.of("nested", HoodieSchema.createRecord("nested_rec", 
"example.schema", null,
+            Arrays.asList(HoodieSchemaField.of("v", 
HoodieSchema.createNullable(HoodieSchema.createVariant())))))));
+    
assertFalse(HoodieSchemaCompatibility.isStrictProjectionOf(nestedFileSchema, 
nestedWriterSchema));
+    assertTrue(HoodieSchemaCompatibility.isStrictProjectionOf(
+        VariantSchemaUtils.alignShreddedVariants(nestedFileSchema, 
nestedWriterSchema), nestedWriterSchema));
+  }
+
   @Test
   public void testIsCompatibleProjection() {
     HoodieSchema sourceSchema = HoodieSchema.parse(SOURCE_SCHEMA);
diff --git 
a/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieVariantReconstruction.java
 
b/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieVariantReconstruction.java
index 25da535e0187..a3cf0e5bd237 100644
--- 
a/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieVariantReconstruction.java
+++ 
b/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieVariantReconstruction.java
@@ -35,8 +35,9 @@ import org.apache.avro.generic.GenericData;
 import org.apache.avro.generic.GenericRecord;
 import org.apache.avro.generic.IndexedRecord;
 
-import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * Reconstructs unshredded variants when reading an already-shredded base file 
on the Avro
@@ -53,23 +54,14 @@ import java.util.List;
 final class HoodieVariantReconstruction {
 
   private final HoodieSchema intermediateSchema;
-  private final Schema outputAvroSchema;
   private final VariantShreddingProvider provider;
-  // Indexed by field position in the (requested == output) record. For target 
fields, the file's
-  // shredded sub-schema and the unshredded target sub-schema for rebuild; 
null for non-targets.
-  private final boolean[] isTarget;
-  private final Schema[] shreddedSubSchemas;
-  private final Schema[] unshreddedSubSchemas;
-
-  private HoodieVariantReconstruction(HoodieSchema intermediateSchema, Schema 
outputAvroSchema,
-                                VariantShreddingProvider provider, boolean[] 
isTarget,
-                                Schema[] shreddedSubSchemas, Schema[] 
unshreddedSubSchemas) {
+  private final Rebuilder rootRebuilder;
+
+  private HoodieVariantReconstruction(HoodieSchema intermediateSchema,
+                                      VariantShreddingProvider provider, 
Rebuilder rootRebuilder) {
     this.intermediateSchema = intermediateSchema;
-    this.outputAvroSchema = outputAvroSchema;
     this.provider = provider;
-    this.isTarget = isTarget;
-    this.shreddedSubSchemas = shreddedSubSchemas;
-    this.unshreddedSubSchemas = unshreddedSubSchemas;
+    this.rootRebuilder = rootRebuilder;
   }
 
   /**
@@ -91,26 +83,10 @@ final class HoodieVariantReconstruction {
       return null;
     }
 
-    List<HoodieSchemaField> requestedFields = requestedSchema.getFields();
-    List<HoodieSchemaField> intermediateFields = new ArrayList<>();
-    boolean[] isTarget = new boolean[requestedFields.size()];
-    boolean anyTarget = false;
-    for (int i = 0; i < requestedFields.size(); i++) {
-      HoodieSchemaField requestedField = requestedFields.get(i);
-      Option<HoodieSchemaField> fileField = 
fileSchema.getField(requestedField.name());
-      if (fileField.isPresent() && 
isShreddedVariant(fileField.get().schema())) {
-        isTarget[i] = true;
-        anyTarget = true;
-        // Read this column in its on-disk shredded shape.
-        
intermediateFields.add(requestedField.withSchema(fileField.get().schema()));
-      } else {
-        // Copy non-target fields too (withSchema makes a fresh Avro Field): 
reusing the requested
-        // field's Avro Field, already bound to the requested record, would 
fail Schema.setFields with
-        // "Field already used" when building the intermediate record below.
-        
intermediateFields.add(requestedField.withSchema(requestedField.schema()));
-      }
-    }
-    if (!anyTarget) {
+    // Records leave this reader unshredded; output field order matches the 
requested/intermediate order.
+    HoodieSchema outputSchema = 
VariantSchemaUtils.stripVariantShredding(requestedSchema);
+    Rebuilder rootRebuilder = buildRebuilder(outputSchema, fileSchema);
+    if (rootRebuilder == null) {
       // No shredded variant columns in the file: nothing to reconstruct, 
regardless of the flag.
       return null;
     }
@@ -134,25 +110,8 @@ final class HoodieVariantReconstruction {
           + " or add a provider implementation (e.g. the Spark variant module) 
to the classpath.");
     }
 
-    HoodieSchema intermediateSchema = HoodieSchema.createRecord(
-        requestedSchema.getAvroSchema().getName(),
-        requestedSchema.getAvroSchema().getNamespace(),
-        requestedSchema.getAvroSchema().getDoc(),
-        intermediateFields);
-    // Records leave this reader unshredded; output field order matches the 
requested/intermediate order.
-    HoodieSchema outputSchema = 
VariantSchemaUtils.stripVariantShredding(requestedSchema);
-
-    Schema[] shreddedSubSchemas = new Schema[requestedFields.size()];
-    Schema[] unshreddedSubSchemas = new Schema[requestedFields.size()];
-    for (int i = 0; i < requestedFields.size(); i++) {
-      if (isTarget[i]) {
-        shreddedSubSchemas[i] = 
fileSchema.getField(requestedFields.get(i).name()).get().schema().getNonNullType().getAvroSchema();
-        unshreddedSubSchemas[i] = 
outputSchema.getFields().get(i).schema().getNonNullType().getAvroSchema();
-      }
-    }
-
-    return new HoodieVariantReconstruction(intermediateSchema, 
outputSchema.toAvroSchema(), provider,
-        isTarget, shreddedSubSchemas, unshreddedSubSchemas);
+    return new HoodieVariantReconstruction(
+        VariantSchemaUtils.toShreddedReadSchema(requestedSchema, fileSchema), 
provider, rootRebuilder);
   }
 
   /**
@@ -160,23 +119,147 @@ final class HoodieVariantReconstruction {
    * a record conforming to the unshredded output schema.
    */
   IndexedRecord reconstruct(IndexedRecord in) {
-    GenericRecord out = new GenericData.Record(outputAvroSchema);
-    for (int i = 0; i < isTarget.length; i++) {
-      Object value = in.get(i);
-      if (isTarget[i] && value instanceof GenericRecord) {
-        out.put(i, provider.rebuildVariantRecord((GenericRecord) value, 
shreddedSubSchemas[i], unshreddedSubSchemas[i]));
-      } else {
-        // Non-variant column, or a null variant column: pass through 
unchanged.
-        out.put(i, value);
+    return (IndexedRecord) rootRebuilder.rebuild(in, provider);
+  }
+
+  /**
+   * Plans the rebuild for one schema position, or returns {@code null} when 
nothing below it is a
+   * shredded variant and the value can be passed through untouched. The walk 
is driven by the
+   * output (requested) side because that is the shape the record read from 
parquet has; the file
+   * side is matched into it by field name. Detection is anchored by the 
requested side because the
+   * file schema usually comes from converting the parquet footer MessageType, 
which loses the
+   * variant logical type; see VariantSchemaUtils.isShreddedVariantTarget 
(#19567). Records, array
+   * elements and map values are all descended into, since the row writer 
shreds variants at any
+   * depth ({@code HoodieRowParquetWriteSupport.processNestedDataType}).
+   */
+  private static Rebuilder buildRebuilder(HoodieSchema outputSchema, 
HoodieSchema fileSchema) {
+    if (VariantSchemaUtils.isShreddedVariantTarget(fileSchema, outputSchema)) {
+      return new VariantRebuilder(fileSchema.getNonNullType().getAvroSchema(),
+          outputSchema.getNonNullType().getAvroSchema());
+    }
+    HoodieSchema output = outputSchema.getNonNullType();
+    HoodieSchema file = fileSchema.getNonNullType();
+    if (output.getType() != file.getType()) {
+      return null;
+    }
+    switch (output.getType()) {
+      case RECORD: {
+        List<HoodieSchemaField> outputFields = output.getFields();
+        Rebuilder[] fieldRebuilders = new Rebuilder[outputFields.size()];
+        boolean anyTarget = false;
+        for (int i = 0; i < outputFields.size(); i++) {
+          HoodieSchemaField outputField = outputFields.get(i);
+          Option<HoodieSchemaField> fileField = 
file.getField(outputField.name());
+          fieldRebuilders[i] = fileField.isPresent()
+              ? buildRebuilder(outputField.schema(), fileField.get().schema())
+              : null;
+          anyTarget |= fieldRebuilders[i] != null;
+        }
+        return anyTarget ? new RecordRebuilder(output.getAvroSchema(), 
fieldRebuilders) : null;
+      }
+      case ARRAY: {
+        Rebuilder elementRebuilder = buildRebuilder(output.getElementType(), 
file.getElementType());
+        return elementRebuilder == null ? null : new 
ArrayRebuilder(output.getAvroSchema(), elementRebuilder);
+      }
+      case MAP: {
+        Rebuilder valueRebuilder = buildRebuilder(output.getValueType(), 
file.getValueType());
+        return valueRebuilder == null ? null : new 
MapRebuilder(valueRebuilder);
+      }
+      default:
+        return null;
+    }
+  }
+
+  /** Rebuilds one value read at the file's shape into its unshredded output 
shape. */
+  private interface Rebuilder {
+    Object rebuild(Object value, VariantShreddingProvider provider);
+  }
+
+  private static final class VariantRebuilder implements Rebuilder {
+    private final Schema shreddedSchema;
+    private final Schema unshreddedSchema;
+
+    private VariantRebuilder(Schema shreddedSchema, Schema unshreddedSchema) {
+      this.shreddedSchema = shreddedSchema;
+      this.unshreddedSchema = unshreddedSchema;
+    }
+
+    @Override
+    public Object rebuild(Object value, VariantShreddingProvider provider) {
+      // A null variant passes through unchanged.
+      return value instanceof GenericRecord
+          ? provider.rebuildVariantRecord((GenericRecord) value, 
shreddedSchema, unshreddedSchema)
+          : value;
+    }
+  }
+
+  private static final class RecordRebuilder implements Rebuilder {
+    private final Schema outputSchema;
+    // Indexed by field position in the (output == intermediate) record; null 
for non-targets.
+    private final Rebuilder[] fieldRebuilders;
+
+    private RecordRebuilder(Schema outputSchema, Rebuilder[] fieldRebuilders) {
+      this.outputSchema = outputSchema;
+      this.fieldRebuilders = fieldRebuilders;
+    }
+
+    @Override
+    public Object rebuild(Object value, VariantShreddingProvider provider) {
+      if (!(value instanceof IndexedRecord)) {
+        return value;
       }
+      IndexedRecord in = (IndexedRecord) value;
+      GenericRecord out = new GenericData.Record(outputSchema);
+      for (int i = 0; i < fieldRebuilders.length; i++) {
+        Object fieldValue = in.get(i);
+        out.put(i, fieldRebuilders[i] == null ? fieldValue : 
fieldRebuilders[i].rebuild(fieldValue, provider));
+      }
+      return out;
     }
-    return out;
   }
 
-  private static boolean isShreddedVariant(HoodieSchema schema) {
-    HoodieSchema unwrapped = schema.getNonNullType();
-    return unwrapped.getType() == HoodieSchemaType.VARIANT
-        && ((HoodieSchema.Variant) unwrapped).isShredded();
+  private static final class ArrayRebuilder implements Rebuilder {
+    private final Schema outputSchema;
+    private final Rebuilder elementRebuilder;
+
+    private ArrayRebuilder(Schema outputSchema, Rebuilder elementRebuilder) {
+      this.outputSchema = outputSchema;
+      this.elementRebuilder = elementRebuilder;
+    }
+
+    @Override
+    public Object rebuild(Object value, VariantShreddingProvider provider) {
+      if (!(value instanceof List)) {
+        return value;
+      }
+      List<?> in = (List<?>) value;
+      GenericData.Array<Object> out = new GenericData.Array<>(in.size(), 
outputSchema);
+      for (Object element : in) {
+        out.add(elementRebuilder.rebuild(element, provider));
+      }
+      return out;
+    }
+  }
+
+  private static final class MapRebuilder implements Rebuilder {
+    private final Rebuilder valueRebuilder;
+
+    private MapRebuilder(Rebuilder valueRebuilder) {
+      this.valueRebuilder = valueRebuilder;
+    }
+
+    @Override
+    public Object rebuild(Object value, VariantShreddingProvider provider) {
+      if (!(value instanceof Map)) {
+        return value;
+      }
+      Map<?, ?> in = (Map<?, ?>) value;
+      Map<Object, Object> out = new LinkedHashMap<>(in.size());
+      for (Map.Entry<?, ?> entry : in.entrySet()) {
+        out.put(entry.getKey(), valueRebuilder.rebuild(entry.getValue(), 
provider));
+      }
+      return out;
+    }
   }
 
   private static VariantShreddingProvider loadProvider(HoodieStorage storage) {
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstruction.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstruction.java
index f522c21d1caf..99399e361fea 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstruction.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstruction.java
@@ -39,6 +39,9 @@ import java.nio.ByteBuffer;
 import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -139,6 +142,195 @@ class TestHoodieVariantReconstruction {
     assertNull(reconstruction.reconstruct(input).get(1));
   }
 
+  @Test
+  void engagesOnFooterDerivedPlainShreddedShape(@TempDir Path tmp) {
+    // #19567: a real file schema comes from converting the parquet footer 
MessageType, which
+    // loses the variant logical type, so the shredded column arrives as a 
PLAIN record of
+    // {metadata, value, typed_value}. Detection must anchor on the requested 
column being a
+    // variant and match the file side by shape, then reconstruction proceeds 
as usual.
+    HoodieSchema fileSchema = 
recordWithIdAndVariant(footerStylePlainShreddedSchema());
+    HoodieSchema requestedSchema = 
recordWithIdAndVariant(HoodieSchema.createVariant());
+    HoodieStorage storage = storageWithReadingShredded(tmp, true);
+    
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key(),
+        TestVariantShreddingProvider.class.getName());
+
+    HoodieVariantReconstruction reconstruction = 
HoodieVariantReconstruction.create(
+        fileSchema, requestedSchema, storage);
+    assertNotNull(reconstruction, "Plain-record shredded shape with a variant 
requested column must engage");
+
+    GenericRecord shredded = new GenericData.Record(
+        
reconstruction.intermediateSchema().getField("v").get().schema().getNonNullType().toAvroSchema());
+    shredded.put("metadata", ByteBuffer.wrap(new byte[] {1}));
+    shredded.put("value", null);
+    shredded.put("typed_value", 42);
+    GenericRecord input = new 
GenericData.Record(reconstruction.intermediateSchema().toAvroSchema());
+    input.put("id", "record-1");
+    input.put("v", shredded);
+
+    IndexedRecord output = reconstruction.reconstruct(input);
+    assertEquals("record-1", output.get(0).toString());
+    GenericRecord variant = (GenericRecord) output.get(1);
+    assertEquals(ByteBuffer.wrap(new byte[] {1}), variant.get("metadata"));
+    assertEquals(ByteBuffer.wrap(new byte[] {42}), variant.get("value"));
+  }
+
+  @Test
+  void returnsNullForFooterDerivedPlainUnshreddedShape(@TempDir Path tmp) {
+    // The ordinary unshredded layout after footer conversion: a plain 
{metadata, value} record
+    // with the variant logical type lost. There is no typed_value to 
reconstruct, so detection
+    // must stay disengaged (the column reads directly at the requested 
schema) even with
+    // shredded reading disabled.
+    HoodieSchema fileSchema = 
recordWithIdAndVariant(footerStylePlainUnshreddedSchema());
+    HoodieSchema requestedSchema = 
recordWithIdAndVariant(HoodieSchema.createVariant());
+    assertNull(HoodieVariantReconstruction.create(fileSchema, requestedSchema,
+        storageWithReadingShredded(tmp, false)));
+  }
+
+  @Test
+  void ignoresShreddedShapeWhenRequestedColumnIsNotVariant(@TempDir Path tmp) {
+    // A user struct that merely has the {metadata, value, typed_value} shape 
must not be
+    // treated as a shredded variant: without the requested-side variant 
anchor there is
+    // nothing to reconstruct, so create() returns null even with shredded 
reading disabled.
+    HoodieSchema fileSchema = 
recordWithIdAndVariant(footerStylePlainShreddedSchema());
+    HoodieSchema requestedSchema = 
recordWithIdAndVariant(footerStylePlainShreddedSchema());
+    assertNull(HoodieVariantReconstruction.create(fileSchema, requestedSchema,
+        storageWithReadingShredded(tmp, false)));
+  }
+
+  @Test
+  void reconstructsShreddedVariantsNestedInRecordsArraysAndMaps(@TempDir Path 
tmp) {
+    // HoodieRowParquetWriteSupport.processNestedDataType shreds variants at 
any depth, so a nested
+    // variant reaches this reader as a plain {metadata, value, typed_value} 
record too. Detection
+    // and rebuild must descend into records, array elements and map values, 
or the nested payload
+    // is dropped the way #19567 dropped the top-level one - and silently, 
since nothing at the top
+    // level looks shredded and neither fail-fast branch is reached.
+    HoodieSchema fileSchema = recordWithNestedVariants(
+        footerStylePlainShreddedSchema("nested_v"),
+        footerStylePlainShreddedSchema("element_v"),
+        footerStylePlainShreddedSchema("map_v"));
+    HoodieSchema requestedSchema = recordWithNestedVariants(
+        HoodieSchema.createVariant("nested_v", "org.apache.hudi.test", null),
+        HoodieSchema.createVariant("element_v", "org.apache.hudi.test", null),
+        HoodieSchema.createVariant("map_v", "org.apache.hudi.test", null));
+    HoodieStorage storage = storageWithReadingShredded(tmp, true);
+    
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key(),
+        TestVariantShreddingProvider.class.getName());
+
+    HoodieVariantReconstruction reconstruction = 
HoodieVariantReconstruction.create(
+        fileSchema, requestedSchema, storage);
+    assertNotNull(reconstruction, "A variant nested below the top level must 
engage reconstruction");
+
+    // The file's shredded shape must reach every nested position of the read 
schema, otherwise
+    // parquet never materializes typed_value there.
+    Schema intermediateAvro = 
reconstruction.intermediateSchema().toAvroSchema();
+    Schema nestedAvro = intermediateAvro.getField("nested").schema();
+    Schema itemsAvro = intermediateAvro.getField("items").schema();
+    Schema tagsAvro = intermediateAvro.getField("tags").schema();
+    assertNotNull(nestedAvro.getField("v").schema().getField("typed_value"));
+    assertNotNull(itemsAvro.getElementType().getField("typed_value"));
+    assertNotNull(tagsAvro.getValueType().getField("typed_value"));
+
+    GenericRecord nested = new GenericData.Record(nestedAvro);
+    nested.put("v", shreddedVariantRecord(nestedAvro.getField("v").schema(), 
7));
+    GenericData.Array<Object> items = new GenericData.Array<>(2, itemsAvro);
+    items.add(shreddedVariantRecord(itemsAvro.getElementType(), 8));
+    // A null element must survive the descent untouched rather than blow up 
the rebuild.
+    items.add(null);
+    Map<String, Object> tags = new HashMap<>();
+    tags.put("a", shreddedVariantRecord(tagsAvro.getValueType(), 9));
+
+    GenericRecord input = new GenericData.Record(intermediateAvro);
+    input.put("id", "record-1");
+    input.put("nested", nested);
+    input.put("items", items);
+    input.put("tags", tags);
+
+    IndexedRecord output = reconstruction.reconstruct(input);
+    assertEquals("record-1", output.get(0).toString());
+    // The fake provider folds typed_value into value, so a rebuilt variant 
carries it there.
+    assertEquals(ByteBuffer.wrap(new byte[] {7}),
+        ((GenericRecord) ((GenericRecord) 
output.get(1)).get("v")).get("value"));
+    assertEquals(ByteBuffer.wrap(new byte[] {8}),
+        ((GenericRecord) ((List<?>) output.get(2)).get(0)).get("value"));
+    assertNull(((List<?>) output.get(2)).get(1));
+    assertEquals(ByteBuffer.wrap(new byte[] {9}),
+        ((GenericRecord) ((Map<?, ?>) output.get(3)).get("a")).get("value"));
+
+    // Empty containers have nothing to rebuild and must come back empty, not 
null.
+    input.put("items", new GenericData.Array<>(0, itemsAvro));
+    input.put("tags", new HashMap<String, Object>());
+    IndexedRecord emptied = reconstruction.reconstruct(input);
+    assertTrue(((List<?>) emptied.get(2)).isEmpty());
+    assertTrue(((Map<?, ?>) emptied.get(3)).isEmpty());
+  }
+
+  @Test
+  void returnsNullForNestedFooterDerivedPlainUnshreddedShape(@TempDir Path 
tmp) {
+    // The nested twin of returnsNullForFooterDerivedPlainUnshreddedShape: 
descending into nested
+    // positions must not make an ordinary unshredded variant look like a 
reconstruction target.
+    HoodieSchema fileSchema = recordWithNestedVariants(
+        footerStylePlainUnshreddedSchema("nested_v"),
+        footerStylePlainUnshreddedSchema("element_v"),
+        footerStylePlainUnshreddedSchema("map_v"));
+    HoodieSchema requestedSchema = recordWithNestedVariants(
+        HoodieSchema.createVariant("nested_v", "org.apache.hudi.test", null),
+        HoodieSchema.createVariant("element_v", "org.apache.hudi.test", null),
+        HoodieSchema.createVariant("map_v", "org.apache.hudi.test", null));
+    assertNull(HoodieVariantReconstruction.create(fileSchema, requestedSchema,
+        storageWithReadingShredded(tmp, false)));
+  }
+
+  private static GenericRecord shreddedVariantRecord(Schema shreddedSchema, 
int typedValue) {
+    GenericRecord shredded = new GenericData.Record(shreddedSchema);
+    shredded.put("metadata", ByteBuffer.wrap(new byte[] {1}));
+    shredded.put("value", null);
+    shredded.put("typed_value", typedValue);
+    return shredded;
+  }
+
+  /** A record carrying a variant inside a struct, inside an array and as a 
map value. */
+  private static HoodieSchema recordWithNestedVariants(HoodieSchema 
nestedVariant,
+                                                       HoodieSchema 
elementVariant,
+                                                       HoodieSchema 
mapVariant) {
+    HoodieSchema nested = HoodieSchema.createRecord("nested_record", 
"org.apache.hudi.test", null,
+        Collections.singletonList(HoodieSchemaField.of("v", nestedVariant)));
+    return HoodieSchema.createRecord("test_nested_record", 
"org.apache.hudi.test", null, Arrays.asList(
+        HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+        HoodieSchemaField.of("nested", nested),
+        HoodieSchemaField.of("items", 
HoodieSchema.createArray(elementVariant)),
+        HoodieSchemaField.of("tags", HoodieSchema.createMap(mapVariant))));
+  }
+
+  /**
+   * The shape a shredded variant column has after the parquet footer 
MessageType is converted
+   * back to a schema: a plain record of {metadata, value, typed_value} with 
no variant logical
+   * type attached.
+   */
+  private static HoodieSchema footerStylePlainShreddedSchema() {
+    return footerStylePlainShreddedSchema("v");
+  }
+
+  private static HoodieSchema footerStylePlainShreddedSchema(String name) {
+    return HoodieSchema.createRecord(name, "org.apache.hudi.test", null, 
Arrays.asList(
+        HoodieSchemaField.of("metadata", 
HoodieSchema.create(HoodieSchemaType.BYTES)),
+        HoodieSchemaField.of("value", 
HoodieSchema.createNullable(HoodieSchemaType.BYTES)),
+        HoodieSchemaField.of("typed_value", 
HoodieSchema.createNullable(HoodieSchemaType.INT))));
+  }
+
+  /**
+   * The shape an unshredded variant column has after the parquet footer 
MessageType is converted
+   * back to a schema: a plain record of {metadata, value} with no variant 
logical type attached.
+   */
+  private static HoodieSchema footerStylePlainUnshreddedSchema() {
+    return footerStylePlainUnshreddedSchema("v");
+  }
+
+  private static HoodieSchema footerStylePlainUnshreddedSchema(String name) {
+    return HoodieSchema.createRecord(name, "org.apache.hudi.test", null, 
Arrays.asList(
+        HoodieSchemaField.of("metadata", 
HoodieSchema.create(HoodieSchemaType.BYTES)),
+        HoodieSchemaField.of("value", 
HoodieSchema.createNullable(HoodieSchemaType.BYTES))));
+  }
+
   private static HoodieSchema recordWithIdAndVariant(HoodieSchema 
variantSchema) {
     return HoodieSchema.createRecord("test_record", "org.apache.hudi.test", 
null, Arrays.asList(
         HoodieSchemaField.of("id", 
HoodieSchema.create(HoodieSchemaType.STRING)),
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 93392648107b..b942e5a234ce 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
@@ -20,6 +20,7 @@
 package org.apache.spark.sql.hudi.dml.schema
 
 import org.apache.hudi.{DataSourceReadOptions, HoodieSparkUtils}
+import org.apache.hudi.common.fs.FSUtils
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
 import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.schema.internal.HoodieSchemaException
@@ -550,6 +551,209 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
     }
   }
 
+  test("Test COW small-file merge preserves shredded VARIANT values") {
+    // The #19567 repro: the second insert bin-packs into the existing small 
file group, and the
+    // small-file merge (HoodieConcatHandle -> HoodieMergeHelper) rewrites the 
old base file
+    // through the AVRO read path (HoodieAvroParquetReader + 
HoodieVariantReconstruction), not the
+    // Spark reader context the clustering tests above exercise. Before the 
fix, the footer-derived
+    // reader schema lost the variant logical type, so the merge's 
strict-projection check failed
+    // and degenerated the requested schema to the footer schema itself; 
reconstruction had no
+    // variant column to anchor on, and the writer-schema rewrite silently 
dropped typed_value,
+    // nulling rows 1-2 after the second commit. Pinned to the AVRO record 
type: that is the leg
+    // this fix covers, and the record type picks the reader the merge uses. 
Unlike the clustering
+    // tests, small.file.limit stays at its default on purpose: the bin-pack 
is the trigger.
+    assume(HoodieSparkUtils.gteqSpark4_1, "Shredded variant base-file read 
requires Spark 4.1 or higher")
+
+    withRecordType(Seq(HoodieRecordType.AVRO))(withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = tmp.getCanonicalPath
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  v variant,
+           |  ts long
+           |) using hudi
+           | location '$tablePath'
+           | tblproperties (
+           |  primaryKey = 'id',
+           |  type = 'cow',
+           |  preCombineField = 'ts',
+           |  hoodie.parquet.variant.write.shredding.enabled = 'true',
+           |  hoodie.parquet.variant.force.shredding.schema.for.test = 'key 
string',
+           |  hoodie.index.type = 'INMEMORY'
+           | )
+       """.stripMargin)
+
+      spark.sql(s"insert into $tableName values " +
+        "(1, parse_json('{\"key\":\"value1\"}'), 1000), " +
+        "(2, parse_json('{\"key\":\"value2\"}'), 1000)")
+
+      // Pin the trigger: the first base file must be shredded, or this 
degenerates into a
+      // plain unshredded merge.
+      val firstFiles = listDataParquetFiles(tablePath)
+      assert(firstFiles.nonEmpty, "Should have at least one data parquet file 
after the first insert")
+      firstFiles.foreach { filePath =>
+        val parquetSchema = readParquetSchema(filePath)
+        val variantGroup = getFieldAsGroup(parquetSchema, "v")
+        assert(variantGroup.containsField("typed_value"),
+          s"First base file should carry typed_value. Schema:\n$variantGroup")
+      }
+
+      spark.sql(s"insert into $tableName values " +
+        "(3, parse_json('{\"key\":\"value3\"}'), 1000), " +
+        "(4, parse_json('{\"key\":\"value4\"}'), 1000)")
+
+      // Pin that the second commit went through the small-file merge: both 
parquet versions
+      // belong to one file group, no second group was created.
+      val fileGroupIds = listDataParquetFiles(tablePath)
+        .map(f => FSUtils.getFileId(new HadoopPath(f).getName)).distinct
+      assert(fileGroupIds.size == 1,
+        s"Second insert should bin-pack into the first file group via the 
small-file merge, got: $fileGroupIds")
+
+      // Rows 1 and 2 survive only if the merge carried them out of the 
shredded base file;
+      // nulls here mean the AVRO read path dropped typed_value (#19567).
+      checkAnswer(s"select id, cast(v as string), ts from $tableName order by 
id")(
+        Seq(1, "{\"key\":\"value1\"}", 1000),
+        Seq(2, "{\"key\":\"value2\"}", 1000),
+        Seq(3, "{\"key\":\"value3\"}", 1000),
+        Seq(4, "{\"key\":\"value4\"}", 1000)
+      )
+    })
+  }
+
+  test("Test COW small-file merge preserves shredded VARIANT values when the 
schema evolves") {
+    // The evolving-schema leg of the same merge. Adding a column makes the 
writer schema stop
+    // being a strict projection of the base file's, so runMerge takes the 
other branch of
+    // recordSchema = isPureProjection ? writerSchema : readerSchema. That 
branch used to hand the
+    // reader the raw footer schema, where the variant column is a plain 
record, so reconstruction
+    // could not anchor and rewriteRecordWithNewSchema copied {metadata, 
value} by name and dropped
+    // typed_value - the same silent corruption as the un-evolved case, 
reached without any
+    // schema-on-read config. Aligning the reader schema up front covers both 
branches.
+    assume(HoodieSparkUtils.gteqSpark4_1, "Shredded variant base-file read 
requires Spark 4.1 or higher")
+
+    withRecordType(Seq(HoodieRecordType.AVRO))(withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = tmp.getCanonicalPath
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  v variant,
+           |  ts long
+           |) using hudi
+           | location '$tablePath'
+           | tblproperties (
+           |  primaryKey = 'id',
+           |  type = 'cow',
+           |  preCombineField = 'ts',
+           |  hoodie.parquet.variant.write.shredding.enabled = 'true',
+           |  hoodie.parquet.variant.force.shredding.schema.for.test = 'key 
string',
+           |  hoodie.index.type = 'INMEMORY'
+           | )
+       """.stripMargin)
+
+      spark.sql(s"insert into $tableName values " +
+        "(1, parse_json('{\"key\":\"value1\"}'), 1000), " +
+        "(2, parse_json('{\"key\":\"value2\"}'), 1000)")
+
+      val firstFiles = listDataParquetFiles(tablePath)
+      assert(firstFiles.nonEmpty, "Should have at least one data parquet file 
after the first insert")
+      firstFiles.foreach { filePath =>
+        val parquetSchema = readParquetSchema(filePath)
+        val variantGroup = getFieldAsGroup(parquetSchema, "v")
+        assert(variantGroup.containsField("typed_value"),
+          s"First base file should carry typed_value. Schema:\n$variantGroup")
+      }
+
+      // The added column is what breaks the strict projection: the writer 
schema now carries a
+      // field the base file does not have.
+      spark.sql(s"alter table $tableName add columns (note string)")
+
+      spark.sql(s"insert into $tableName values " +
+        "(3, parse_json('{\"key\":\"value3\"}'), 1000, 'n3'), " +
+        "(4, parse_json('{\"key\":\"value4\"}'), 1000, 'n4')")
+
+      val fileGroupIds = listDataParquetFiles(tablePath)
+        .map(f => FSUtils.getFileId(new HadoopPath(f).getName)).distinct
+      assert(fileGroupIds.size == 1,
+        s"Second insert should bin-pack into the first file group via the 
small-file merge, got: $fileGroupIds")
+
+      // Rows 1-2 keep their variants and pick up a null for the new column; 
rows 3-4 carry it.
+      checkAnswer(s"select id, cast(v as string), ts, note from $tableName 
order by id")(
+        Seq(1, "{\"key\":\"value1\"}", 1000, null),
+        Seq(2, "{\"key\":\"value2\"}", 1000, null),
+        Seq(3, "{\"key\":\"value3\"}", 1000, "n3"),
+        Seq(4, "{\"key\":\"value4\"}", 1000, "n4")
+      )
+    })
+  }
+
+  test("Test COW small-file merge preserves unshredded VARIANT values") {
+    // Companion to the shredded small-file merge test above, with shredding 
disabled, split the
+    // same way as the clustering pair. The shredded twin only proves the 
merge stopped losing
+    // typed_value; this one guards the other direction, because 
alignShreddedVariants now runs on
+    // every HoodieMergeHelper.runMerge. An ordinary variant table writes the 
plain {metadata,
+    // value} layout, nothing should match the shredded shape, the alignment 
must be a no-op, and
+    // the bin-pack must keep round-tripping exactly as it did before the fix. 
If this goes red
+    // while the shredded twin stays green, the alignment is reaching columns 
it should not.
+    assume(HoodieSparkUtils.gteqSpark4_1, "Variant small-file merge read-back 
requires Spark 4.1 or higher")
+
+    withRecordType(Seq(HoodieRecordType.AVRO))(withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = tmp.getCanonicalPath
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  v variant,
+           |  ts long
+           |) using hudi
+           | location '$tablePath'
+           | tblproperties (
+           |  primaryKey = 'id',
+           |  type = 'cow',
+           |  preCombineField = 'ts',
+           |  hoodie.parquet.variant.write.shredding.enabled = 'false',
+           |  hoodie.index.type = 'INMEMORY'
+           | )
+       """.stripMargin)
+
+      spark.sql(s"insert into $tableName values " +
+        "(1, parse_json('{\"key\":\"value1\"}'), 1000), " +
+        "(2, parse_json('{\"key\":\"value2\"}'), 1000)")
+
+      // Pin the layout: these files must NOT carry typed_value, or this twin 
silently becomes a
+      // copy of the shredded test above and the unshredded leg of the merge 
goes uncovered.
+      val firstFiles = listDataParquetFiles(tablePath)
+      assert(firstFiles.nonEmpty, "Should have at least one data parquet file 
after the first insert")
+      firstFiles.foreach { filePath =>
+        val parquetSchema = readParquetSchema(filePath)
+        val variantGroup = getFieldAsGroup(parquetSchema, "v")
+        assert(!variantGroup.containsField("typed_value"),
+          s"Unshredded base file must not carry typed_value. 
Schema:\n$variantGroup")
+      }
+
+      spark.sql(s"insert into $tableName values " +
+        "(3, parse_json('{\"key\":\"value3\"}'), 1000), " +
+        "(4, parse_json('{\"key\":\"value4\"}'), 1000)")
+
+      // Same bin-pack pin as the shredded twin: one file group, so this 
really went through the
+      // small-file merge and not a fresh insert.
+      val fileGroupIds = listDataParquetFiles(tablePath)
+        .map(f => FSUtils.getFileId(new HadoopPath(f).getName)).distinct
+      assert(fileGroupIds.size == 1,
+        s"Second insert should bin-pack into the first file group via the 
small-file merge, got: $fileGroupIds")
+
+      checkAnswer(s"select id, cast(v as string), ts from $tableName order by 
id")(
+        Seq(1, "{\"key\":\"value1\"}", 1000),
+        Seq(2, "{\"key\":\"value2\"}", 1000),
+        Seq(3, "{\"key\":\"value3\"}", 1000),
+        Seq(4, "{\"key\":\"value4\"}", 1000)
+      )
+    })
+  }
+
   test("Test bulk_insert row-writer round-trips VARIANT") {
     assume(HoodieSparkUtils.gteqSpark4_0, "Variant type requires Spark 4.0 or 
higher")
 

Reply via email to