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 576e5b6e70e1 fix(avro): detect the two-field shredded variant shape
and harden the variant merge path (#19620)
576e5b6e70e1 is described below
commit 576e5b6e70e151f4feed7922acc07a3f3c23d88e
Author: voonhous <[email protected]>
AuthorDate: Mon Aug 17 14:21:43 2026 +0800
fix(avro): detect the two-field shredded variant shape and harden the
variant merge path (#19620)
* review(19582): guard the strip rebuild and correct the nested
reachability note
Two things the self-review turned up, both in code the round-3 commits
touched but no test or comment covered.
stripVariantShredding used to re-add untouched fields by reference and
then hand them to Schema.setFields, which throws "Field already used" for
any record of {other column + shredded variant}. That is the same defect
#18938 fixed in the sibling HoodieVariantReconstruction.create; the twin
here was missed. The recursive rewrite copies every field via withSchema
and so fixes it incidentally - now pinned by a test at the production
call site, HoodieAvroWriteSupport.generateEffectiveSchema with shredding
disabled, which is the clustering/compaction path that reaches it.
The nested comments claimed the row writer's depth recursion as the
producer of nested shredded files. That overstates reachability: the
forced-shredding hook is top-level only in BOTH write supports, so no DDL
or table property reaches depth and only a hand-authored write schema
can. Say that plainly instead, and note why the nested coverage is unit
level rather than end to end.
* test(19582): sweep the small-file merge over record type and layout
The shredded and unshredded small-file merge tests were near-identical
copies, and both were pinned to AVRO. Fold them into one sweep over
(record type, layout) and pull the two repeated pins into helpers.
What each leg is for:
- AVRO + shredded is the #19567 bug; it goes red without the
HoodieMergeHelper alignment (verified by reverting that hunk: rows 1-2
come back [1,null,1000], [2,null,1000]).
- AVRO + unshredded is the no-op guard, kept because a reviewer asked for
the twin, now without the 65-line copy.
- SPARK is new coverage. HoodieSparkParquetReader.getSchema returns a
nullable UNION rather than a RECORD, so alignShreddedVariants bails at
its RECORD/RECORD guard and that reader is untouched by the fix. The
leg passes identically with and without the fix; it is swept so that
the guard which scopes the fix to the AVRO reader is pinned rather
than merely assumed.
assertVariantLayout and assertSingleFileGroup also replace the inline
pins in the evolving-schema test; the layout pin was copy-pasted 11 times
across this file. Net -56 lines.
Verified on spark4.1/scala-2.13: 15 succeeded, 0 failed, 2 canceled (the
Spark 3.x-only tests).
* fix(avro): detect the two-field shredded variant shape, and stop
rebuilding schemas that do not change
Two things found self-reviewing #19582 after it merged.
isShreddedVariantShape demanded exactly three fields with a bytes `value`,
but HoodieSchema.Variant.determineIfShredded - the answer used whenever the
logical type survives - calls anything carrying typed_value shredded. The
shredding spec lets a writer omit `value` when every row is typed, and
because the parquet footer always strips the logical type, the shape check
is the only detector that runs on real files. So a two-field group read at
the unshredded schema and lost its payload silently: #19567 again by
another shape. Accept {metadata, typed_value} with `value` optional; the
requested-side variant anchor is what keeps plain user structs out, so this
costs no false positives, and a struct with a fourth field is still
rejected.
Second, both recursion loops copied every field into a new list and then
threw the list away when nothing matched. That is the path every
non-variant table takes, on every runMerge and every avro parquet read, and
it costs an Avro Field plus a defaultVal() lookup per field per level.
Build the list lazily, backfilling only once a replacement actually
appears. Measured on a 500-column nested schema: alignShreddedVariants
730us -> 335us per runMerge, create() 834us -> 365us per read.
Tests: the two-field shape engages and rebuilds, and a four-field struct
that merely carries typed_value stays untouched.
* review(19620): scope the nested-shredding claim to the avro path, cover
the value-less group
- The "forced shredding is top-level only in both write supports" note was
wrong for the
row path: processNestedDataType recurses into structs and
generateShreddedSchema re-reads
the DDL on every entry, so struct<v variant> plus the force-shredding
property does shred
at depth. Scoped the claim to
HoodieAvroWriteSupport.applyForcedShreddingSchema in all
three places that carried it.
- Added a value-less round-trip case in
TestHoodieVariantReconstructionRoundTrip. The
detection test in hudi-hadoop-common uses a stub provider that ignores
the shredded
schema, so nothing exercised
Spark4VariantShreddingProvider.buildVariantSchema with the
variantIdx = -1 that a {metadata, typed_value} group produces.
- Moved the stranded listDataParquetFiles javadoc back onto its method.
* review(19620): address the round-2 comments
- Scope the write-support strip test's javadoc the same way the
VariantSchemaUtils and TestHoodieVariantReconstruction comments now
are: applyForcedShreddingSchema is top-level only, so the nested
shredded schema this path strips comes from a file the ROW path (or
another engine) wrote, not from this path's own hook.
- Add the {metadata, typed_value, extra} rejection case. The four-field
struct dies at the field-count guard, so nothing reached the
no-value arm of isShreddedVariantShape; mutating its fieldCount == 2
to true left every test green. The new case survives the count guard
and pins that arm.
- Fold the CDC test's verbatim layout block into assertVariantLayout;
tablePath, shredded and leg were already in scope.
- Run hudi-spark4-common in the spark4.2 java-test part1 lane. The
module's two test classes were compiled (spark4.2.x depends on it,
and the build step uses -am) but never executed: part1 is the only
lane whose java UT filter is an exclusion list, so listing the module
there is what makes them run.
---
.github/workflows/bot.yml | 5 +-
.../hudi/common/avro/VariantSchemaUtils.java | 122 +++++++---
.../hadoop/HoodieVariantReconstruction.java | 6 +-
.../avro/TestHoodieAvroWriteSupportShredding.java | 47 ++++
.../hadoop/TestHoodieVariantReconstruction.java | 71 +++++-
.../sql/hudi/dml/schema/TestVariantDataType.scala | 260 +++++++++------------
.../TestHoodieVariantReconstructionRoundTrip.java | 65 ++++++
7 files changed, 383 insertions(+), 193 deletions(-)
diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
index 751e460ad297..08db07cd2c12 100644
--- a/.github/workflows/bot.yml
+++ b/.github/workflows/bot.yml
@@ -732,9 +732,12 @@ jobs:
# - scalaProfile: "scala-2.13"
# sparkProfile: "spark4.1"
# sparkModules: "hudi-spark-datasource/hudi-spark4.1.x"
+ # hudi-spark4-common runs here (and only here): the other spark
lanes either pin a
+ # fixed test-class list or run scala/functional profiles, so without
this entry the
+ # module's java unit tests are compiled but never executed in CI.
- scalaProfile: "scala-2.13"
sparkProfile: "spark4.2"
- sparkModules: "hudi-spark-datasource/hudi-spark4.2.x"
+ sparkModules:
"hudi-spark-datasource/hudi-spark4-common,hudi-spark-datasource/hudi-spark4.2.x"
steps:
- if: needs.changes.outputs.relevant == 'true'
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 b2155d35eb05..a656fc5de875 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
@@ -40,10 +40,14 @@ public class VariantSchemaUtils {
/**
* 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.
+ * elements and map values are stripped too; see {@link
#swapShreddedVariantFields} for when such
+ * a schema can arise. Non-variant fields and already-unshredded variants
pass through unchanged;
+ * returns {@code schema} as-is when nothing changes.
+ *
+ * <p>Every field of a rebuilt record is copied via {@code withSchema},
including the untouched
+ * ones: reusing an Avro {@code Field} still bound to the source record makes
+ * {@code Schema.setFields} throw "Field already used" (the defect #18938
fixed in the sibling
+ * HoodieVariantReconstruction).
*/
public static HoodieSchema stripVariantShredding(HoodieSchema schema) {
if (schema.getType() != HoodieSchemaType.RECORD) {
@@ -53,19 +57,24 @@ public class VariantSchemaUtils {
}
private static HoodieSchema stripRecordVariantShredding(HoodieSchema record)
{
- List<HoodieSchemaField> newFields = new ArrayList<>();
- boolean changed = false;
- for (HoodieSchemaField field : record.getFields()) {
+ List<HoodieSchemaField> fields = record.getFields();
+ // Built lazily: every schema without a shredded variant walks this
method, and copying fields
+ // only to discard them costs an Avro Field plus a defaultVal() lookup per
field per level.
+ List<HoodieSchemaField> newFields = null;
+ for (int i = 0; i < fields.size(); i++) {
+ HoodieSchemaField field = fields.get(i);
HoodieSchema fieldSchema = field.schema();
HoodieSchema replacement = stripVariantShreddingAt(fieldSchema);
- if (replacement != fieldSchema) {
- changed = true;
+ if (replacement != fieldSchema && newFields == null) {
+ newFields = copyFieldsBefore(fields, i);
+ }
+ if (newFields != null) {
+ // 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));
}
- // 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) {
+ if (newFields == null) {
return record;
}
return HoodieSchema.createRecord(
@@ -166,30 +175,44 @@ public class VariantSchemaUtils {
/**
* 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.
+ * variant position with the other side's schema. {@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.
+ *
+ * <p>The walk recurses through records, array elements and map values
because the row writer
+ * shreds at any depth its write schema asks it to
+ * ({@code HoodieRowParquetWriteSupport.processNestedDataType} recurses into
structs, array
+ * elements and map values, and {@code generateShreddedSchema} re-reads the
forced-shredding DDL
+ * on every entry, so {@code struct<v variant>} plus
+ * {@code hoodie.parquet.variant.force.shredding.schema.for.test} does shred
at depth on the ROW
+ * path). The AVRO path is the narrower one: {@code
HoodieAvroWriteSupport.applyForcedShreddingSchema}
+ * walks top-level fields only, so on that path a nested shredded column
needs a hand-authored
+ * write schema that declares {@code typed_value} below the top level.
*/
private static HoodieSchema swapShreddedVariantFields(HoodieSchema base,
HoodieSchema other, boolean baseIsFile) {
- List<HoodieSchemaField> newFields = new ArrayList<>();
- boolean changed = false;
- for (HoodieSchemaField baseField : base.getFields()) {
+ List<HoodieSchemaField> baseFields = base.getFields();
+ // Built lazily, as in stripRecordVariantShredding: alignShreddedVariants
runs on every
+ // HoodieMergeHelper.runMerge, so the overwhelmingly common case is a
schema that matches
+ // nothing and must not pay for a full field copy it will throw away.
+ List<HoodieSchemaField> newFields = null;
+ for (int i = 0; i < baseFields.size(); i++) {
+ HoodieSchemaField baseField = baseFields.get(i);
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;
+ if (replacement != baseFieldSchema && newFields == null) {
+ newFields = copyFieldsBefore(baseFields, i);
+ }
+ if (newFields != null) {
+ // 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));
}
- // 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) {
+ if (newFields == null) {
return base;
}
return HoodieSchema.createRecord(
@@ -239,16 +262,51 @@ public class VariantSchemaUtils {
return wasNullable ? HoodieSchema.createNullable(replacement) :
replacement;
}
- /** The on-disk shredded variant shape: a record of exactly {metadata:
bytes, value: [nullable] bytes, typed_value}. */
+ /**
+ * The on-disk shredded variant shape: a record of {metadata: bytes,
typed_value} plus an optional
+ * {value: [nullable] bytes}, and nothing else.
+ *
+ * <p>{@code value} is deliberately optional. The shredding spec lets a
writer omit it when every
+ * row is typed, and {@link HoodieSchema.Variant#determineIfShredded} - the
answer used whenever
+ * the logical type survives - calls anything with a {@code typed_value}
shredded regardless. Since
+ * the footer always strips the logical type, this shape check is the only
detector that runs on
+ * real files, so demanding {@code value} here made a two-field group read
at the unshredded schema
+ * and silently drop its payload: #19567 again by another shape. The
requested-side variant anchor
+ * in {@link #isShreddedVariantTarget} is what keeps plain user structs out,
so accepting the
+ * two-field form costs no false positives.
+ */
private static boolean isShreddedVariantShape(HoodieSchema schema) {
- if (schema.getType() != HoodieSchemaType.RECORD ||
schema.getFields().size() != 3) {
+ if (schema.getType() != HoodieSchemaType.RECORD) {
+ return false;
+ }
+ int fieldCount = schema.getFields().size();
+ if (fieldCount < 2 || fieldCount > 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);
+ if (!isBytesField(schema, HoodieSchema.Variant.VARIANT_METADATA_FIELD)) {
+ return false;
+ }
+ boolean hasValue =
schema.getField(HoodieSchema.Variant.VARIANT_VALUE_FIELD).isPresent();
+ // A third field that is not `value` is some other user struct, not a
variant group.
+ return hasValue
+ ? isBytesField(schema, HoodieSchema.Variant.VARIANT_VALUE_FIELD)
+ : fieldCount == 2;
+ }
+
+ /**
+ * Fresh copies of {@code fields[0, end)}, for the point a rebuild first
turns out to be needed.
+ * Copies rather than reuses because the originals are still bound to their
source record.
+ */
+ private static List<HoodieSchemaField>
copyFieldsBefore(List<HoodieSchemaField> fields, int end) {
+ List<HoodieSchemaField> copied = new ArrayList<>(fields.size());
+ for (int i = 0; i < end; i++) {
+ HoodieSchemaField field = fields.get(i);
+ copied.add(field.withSchema(field.schema()));
+ }
+ return copied;
}
private static boolean isBytesField(HoodieSchema schema, String fieldName) {
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 a3cf0e5bd237..cfc0d680d626 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
@@ -129,8 +129,10 @@ final class HoodieVariantReconstruction {
* 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}).
+ * elements and map values are all descended into, matching what the row
writer can emit; see
+ * {@code VariantSchemaUtils.swapShreddedVariantFields} for what actually
produces a nested
+ * shredded file today (the row path shreds at depth off the
forced-shredding property; the AVRO
+ * path needs a hand-authored write schema).
*/
private static Rebuilder buildRebuilder(HoodieSchema outputSchema,
HoodieSchema fileSchema) {
if (VariantSchemaUtils.isShreddedVariantTarget(fileSchema, outputSchema)) {
diff --git
a/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
b/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
index eb936ac512e4..9ec32c9da3fd 100644
---
a/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
+++
b/hudi-hadoop-common/src/test/java/org/apache/hudi/avro/TestHoodieAvroWriteSupportShredding.java
@@ -22,6 +22,7 @@ package org.apache.hudi.avro;
import org.apache.hudi.common.config.HoodieStorageConfig;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaField;
+import org.apache.hudi.common.schema.HoodieSchemaType;
import org.junit.jupiter.api.Test;
@@ -31,7 +32,10 @@ import java.util.List;
import java.util.Properties;
import java.util.stream.Collectors;
+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.assertInstanceOf;
class TestHoodieAvroWriteSupportShredding {
@@ -68,4 +72,47 @@ class TestHoodieAvroWriteSupportShredding {
.collect(Collectors.toList());
assertEquals(Arrays.asList("a", "b", "c"), shreddedFieldNames);
}
+
+ /**
+ * Disabling shredding over an already-shredded schema - the
clustering/compaction case
+ * {@link HoodieAvroWriteSupport#generateEffectiveSchema} calls out - has to
strip typed_value
+ * without tripping Avro's "Field already used". Rebuilding a record while
reusing a
+ * {@code Schema.Field} still bound to the source record throws, so any
table with a shredded
+ * variant AND at least one other column failed here. #18938 fixed exactly
that defect in the
+ * sibling HoodieVariantReconstruction and left this twin behind. Nested
variants must be
+ * stripped too, but this AVRO path's own hook is not what puts one there -
+ * {@code applyForcedShreddingSchema} walks top-level fields only. The ROW
write path shreds at
+ * any depth its forced-shredding DDL asks
(HoodieRowParquetWriteSupport.processNestedDataType),
+ * so a clustering/compaction schema read back from such a file can carry
typed_value below the
+ * top level, which is why the nested leg is pinned here at unit level.
+ */
+ @Test
+ void disablingShreddingStripsTypedValueAtEveryDepth() {
+ HoodieSchema record = HoodieSchema.createRecord(
+ "test_record", "org.apache.hudi.test", null, Arrays.asList(
+ HoodieSchemaField.of("id",
HoodieSchema.create(HoodieSchemaType.STRING)),
+ HoodieSchemaField.of("v", HoodieSchema.createVariantShredded(
+ "v", "org.apache.hudi.test", null,
HoodieSchema.create(HoodieSchemaType.INT))),
+ HoodieSchemaField.of("nested", HoodieSchema.createRecord(
+ "nested_record", "org.apache.hudi.test", null,
+ Collections.singletonList(HoodieSchemaField.of("nv",
HoodieSchema.createVariantShredded(
+ "nv", "org.apache.hudi.test", null,
HoodieSchema.create(HoodieSchemaType.INT))))))));
+
+ Properties props = new Properties();
+
props.setProperty(HoodieStorageConfig.PARQUET_VARIANT_WRITE_SHREDDING_ENABLED.key(),
"false");
+
+ HoodieSchema effective = assertDoesNotThrow(
+ () -> HoodieAvroWriteSupport.generateEffectiveSchema(record, props),
+ "stripping shredding must rebuild the record with fresh Avro fields");
+
+ assertEquals("id", effective.getFields().get(0).name(), "non-variant
fields must survive the rebuild");
+ assertUnshredded(effective.getField("v").get().schema(), "top-level
variant");
+
assertUnshredded(effective.getField("nested").get().schema().getField("nv").get().schema(),
"nested variant");
+ }
+
+ private static void assertUnshredded(HoodieSchema fieldSchema, String label)
{
+ HoodieSchema unwrapped = fieldSchema.isNullable() ?
fieldSchema.getNonNullType() : fieldSchema;
+ assertInstanceOf(HoodieSchema.Variant.class, unwrapped, label + " should
still be a variant");
+ assertFalse(((HoodieSchema.Variant) unwrapped).isShredded(), label + "
should no longer be shredded");
+ }
}
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 99399e361fea..571379740f4f 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
@@ -186,6 +186,68 @@ class TestHoodieVariantReconstruction {
storageWithReadingShredded(tmp, false)));
}
+ @Test
+ void engagesOnTwoFieldShreddedShapeWithNoValueColumn(@TempDir Path tmp) {
+ // The shredding spec lets a writer omit `value` when every row is typed,
and
+ // HoodieSchema.Variant.determineIfShredded calls anything with a
typed_value shredded. Shape
+ // detection is the only detector that runs on real files, since the
footer strips the logical
+ // type, so requiring `value` here dropped such a column's payload
silently - #19567 again by
+ // another shape. Hudi's own writer always emits three fields, so this is
about files written
+ // elsewhere.
+ HoodieSchema twoFieldShredded = HoodieSchema.createRecord("v",
"org.apache.hudi.test", null, Arrays.asList(
+ HoodieSchemaField.of("metadata",
HoodieSchema.create(HoodieSchemaType.BYTES)),
+ HoodieSchemaField.of("typed_value",
HoodieSchema.createNullable(HoodieSchemaType.INT))));
+ HoodieSchema fileSchema = recordWithIdAndVariant(twoFieldShredded);
+ 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, "A shredded group with no value column must
still engage");
+
+ GenericRecord shredded = new GenericData.Record(
+
reconstruction.intermediateSchema().getField("v").get().schema().getNonNullType().toAvroSchema());
+ shredded.put("metadata", ByteBuffer.wrap(new byte[] {1}));
+ shredded.put("typed_value", 42);
+ GenericRecord input = new
GenericData.Record(reconstruction.intermediateSchema().toAvroSchema());
+ input.put("id", "record-1");
+ input.put("v", shredded);
+
+ GenericRecord variant = (GenericRecord)
reconstruction.reconstruct(input).get(1);
+ assertEquals(ByteBuffer.wrap(new byte[] {42}), variant.get("value"));
+ }
+
+ @Test
+ void ignoresFourFieldStructThatMerelyCarriesTypedValue(@TempDir Path tmp) {
+ // The other side of relaxing the field count: a user struct that happens
to hold metadata,
+ // value and typed_value plus anything else is not a variant group and
must stay untouched.
+ // The field-count guard rejects this one before any field is inspected.
+ HoodieSchema fourField = HoodieSchema.createRecord("v",
"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)),
+ HoodieSchemaField.of("extra",
HoodieSchema.createNullable(HoodieSchemaType.STRING))));
+ assertNull(HoodieVariantReconstruction.create(
+ recordWithIdAndVariant(fourField),
recordWithIdAndVariant(HoodieSchema.createVariant()),
+ storageWithReadingShredded(tmp, false)));
+ }
+
+ @Test
+ void ignoresThreeFieldStructWhoseThirdFieldIsNotValue(@TempDir Path tmp) {
+ // Unlike the four-field case above, this one survives the field-count
guard and reaches the
+ // no-`value` arm of the shape check: three fields carrying typed_value
but no `value` must be
+ // read as a user struct, because a shredded group's only optional third
field is `value`.
+ HoodieSchema threeField = HoodieSchema.createRecord("v",
"org.apache.hudi.test", null, Arrays.asList(
+ HoodieSchemaField.of("metadata",
HoodieSchema.create(HoodieSchemaType.BYTES)),
+ HoodieSchemaField.of("typed_value",
HoodieSchema.createNullable(HoodieSchemaType.INT)),
+ HoodieSchemaField.of("extra",
HoodieSchema.createNullable(HoodieSchemaType.STRING))));
+ assertNull(HoodieVariantReconstruction.create(
+ recordWithIdAndVariant(threeField),
recordWithIdAndVariant(HoodieSchema.createVariant()),
+ storageWithReadingShredded(tmp, false)));
+ }
+
@Test
void ignoresShreddedShapeWhenRequestedColumnIsNotVariant(@TempDir Path tmp) {
// A user struct that merely has the {metadata, value, typed_value} shape
must not be
@@ -199,9 +261,12 @@ class TestHoodieVariantReconstruction {
@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
+ // The row writer shreds at any depth its write schema asks it to
+ // (HoodieRowParquetWriteSupport.processNestedDataType), so a nested
variant can reach this
+ // reader as a plain {metadata, value, typed_value} record too. On the
AVRO write path such a
+ // file needs a hand-authored write schema -
HoodieAvroWriteSupport.applyForcedShreddingSchema
+ // walks top-level fields only - which is why this is a unit test and not
an end-to-end one.
+ // 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(
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 b942e5a234ce..007fbd26ca79 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
@@ -504,19 +504,7 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
// Pin the layout the CDC reads: without this, one leg silently
degrades into a
// second copy of the other and its half of the rewrite branch goes
uncovered.
- val baseFiles = listDataParquetFiles(tablePath)
- assert(baseFiles.nonEmpty, s"[$leg] should have a base parquet file
after the insert")
- baseFiles.foreach { filePath =>
- val parquetSchema = readParquetSchema(filePath)
- val variantGroup = getFieldAsGroup(parquetSchema, "v")
- if (shredded) {
- assert(variantGroup.containsField("typed_value"),
- s"[$leg] base file should carry typed_value.
Schema:\n$variantGroup")
- } else {
- assert(!variantGroup.containsField("typed_value"),
- s"[$leg] base file must not carry typed_value.
Schema:\n$variantGroup")
- }
- }
+ assertVariantLayout(tablePath, shredded, leg)
spark.sql(s"""update $tableName set v =
parse_json('{"key":"value2"}'), ts = 1001 where id = 1""")
@@ -551,75 +539,84 @@ 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.
+ test("Test COW small-file merge preserves 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.
+ // Before the fix the footer-derived reader schema lost the variant
logical type, so the
+ // 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.
+ //
+ // Swept over both dimensions because each leg pins something different:
+ // - AVRO + shredded: the bug itself. Goes red without the
HoodieMergeHelper alignment.
+ // - AVRO + unshredded: the other direction. alignShreddedVariants runs on
every runMerge, so
+ // an ordinary variant table must round-trip exactly as it did before.
Red here while the
+ // shredded leg stays green means the alignment is reaching columns it
should not.
+ // - SPARK: HoodieSparkParquetReader.getSchema returns a nullable UNION
rather than a RECORD,
+ // so alignShreddedVariants bails at its RECORD/RECORD guard and is a
strict no-op on that
+ // reader - this leg is byte-for-byte unchanged by the fix, and Spark's
own parquet read
+ // handles the shredded column. Swept anyway because nothing else covers
it, and because
+ // that guard is what scopes the fix to the AVRO reader; if it ever
changes, this catches it.
+ //
+ // Unlike the clustering tests above, 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)")
+ Seq(HoodieRecordType.AVRO, HoodieRecordType.SPARK).foreach { recordType =>
+ Seq(true, false).foreach { shredded =>
+ withRecordType(Seq(recordType))(withTempDir { tmp =>
+ val leg = s"$recordType, shredded=$shredded"
+ val tableName = generateTableName
+ val tablePath = tmp.getCanonicalPath
+ // The forced shredding schema belongs to the shredded leg only; the
unshredded leg must
+ // reach the writer with shredding off and no forced schema.
+ val forceShreddingProp =
+ if (shredded)
"hoodie.parquet.variant.force.shredding.schema.for.test = 'key string'," else ""
+ 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 = '$shredded',
+ | $forceShreddingProp
+ | hoodie.index.type = 'INMEMORY'
+ | )
+ """.stripMargin)
- // 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 " +
+ "(1, parse_json('{\"key\":\"value1\"}'), 1000), " +
+ "(2, parse_json('{\"key\":\"value2\"}'), 1000)")
+
+ // Pin the trigger: without this the shredded leg can silently
degenerate into a plain
+ // unshredded merge, or the unshredded leg into a copy of the
shredded one.
+ assertVariantLayout(tablePath, shredded, leg)
+
+ 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.
+ assertSingleFileGroup(tablePath, leg)
+
+ // Rows 1 and 2 survive only if the merge carried them out of the
base file; nulls here
+ // mean the 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)
+ )
+ })
}
-
- 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") {
@@ -657,14 +654,7 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
"(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")
- }
+ assertVariantLayout(tablePath, shredded = true, "schema evolves")
// The added column is what breaks the strict projection: the writer
schema now carries a
// field the base file does not have.
@@ -674,10 +664,7 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
"(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")
+ assertSingleFileGroup(tablePath, "schema evolves")
// 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")(
@@ -689,71 +676,6 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
})
}
- 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")
@@ -1250,6 +1172,34 @@ class TestVariantDataType extends HoodieSparkSqlTestBase
{
})
}
+ /**
+ * Pins the on-disk layout of the `v` column across every base file. Without
it a leg meant to
+ * exercise the shredded path can silently degenerate into the unshredded
one, or the reverse,
+ * and the branch it was written for goes uncovered.
+ */
+ private def assertVariantLayout(tablePath: String, shredded: Boolean, leg:
String): Unit = {
+ val files = listDataParquetFiles(tablePath)
+ assert(files.nonEmpty, s"[$leg] should have at least one data parquet
file")
+ files.foreach { filePath =>
+ val variantGroup = getFieldAsGroup(readParquetSchema(filePath), "v")
+ if (shredded) {
+ assert(variantGroup.containsField("typed_value"),
+ s"[$leg] base file should carry typed_value. Schema:\n$variantGroup")
+ } else {
+ assert(!variantGroup.containsField("typed_value"),
+ s"[$leg] base file must not carry typed_value.
Schema:\n$variantGroup")
+ }
+ }
+ }
+
+ /** Pins that a write bin-packed into the existing file group rather than
creating a new one. */
+ private def assertSingleFileGroup(tablePath: String, leg: String): Unit = {
+ val fileGroupIds = listDataParquetFiles(tablePath)
+ .map(f => FSUtils.getFileId(new HadoopPath(f).getName)).distinct
+ assert(fileGroupIds.size == 1,
+ s"[$leg] insert should bin-pack into the first file group via the
small-file merge, got: $fileGroupIds")
+ }
+
/**
* Lists data parquet files in the table directory, excluding Hudi metadata
files.
*/
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstructionRoundTrip.java
b/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstructionRoundTrip.java
index 0f851e98fa6a..412f79541663 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstructionRoundTrip.java
+++
b/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/io/storage/hadoop/TestHoodieVariantReconstructionRoundTrip.java
@@ -43,6 +43,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Round-trip coverage for the successful {@code create} -> {@code
reconstruct} path of
@@ -104,6 +105,70 @@ class TestHoodieVariantReconstructionRoundTrip {
assertEquals(original.toJson(ZoneOffset.UTC),
rebuilt.toJson(ZoneOffset.UTC));
}
+ @Test
+ void createThenReconstructRebuildsAValueLessShreddedGroup(@TempDir Path tmp)
throws Exception {
+ // The shredding spec lets a writer omit `value` when nothing is left
over. TestHoodieVariantReconstruction
+ // pins that Hudi detects the resulting two-field group, but its stub
provider ignores the shredded
+ // schema, so this is the only place the real provider sees the shape:
buildVariantSchema assigns
+ // variantIdx = -1 there, which shifts every other ordinal and is what
ShreddingUtils.rebuild reads to
+ // decide there is no residual. Get the mapping wrong and the rebuild
reads the wrong Avro field.
+ Map<String, HoodieSchema> shreddedFields = new LinkedHashMap<>();
+ shreddedFields.put("a", HoodieSchema.create(HoodieSchemaType.STRING));
+ shreddedFields.put("b", HoodieSchema.create(HoodieSchemaType.LONG));
+ HoodieSchema.Variant shreddedVariant =
HoodieSchema.createVariantShreddedObject(shreddedFields);
+ HoodieSchema.Variant unshreddedVariant = HoodieSchema.createVariant();
+
+ // Shred with the real provider first, then drop the (null) top-level
residual, which yields exactly
+ // what a writer that omits `value` would have put on disk.
+ Spark4VariantShreddingProvider provider = new
Spark4VariantShreddingProvider();
+ Variant original = VariantBuilder.parseJson("{\"a\":\"x\",\"b\":5}",
false);
+ GenericRecord unshreddedV = new
GenericData.Record(unshreddedVariant.getAvroSchema());
+ unshreddedV.put(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
ByteBuffer.wrap(original.getMetadata()));
+ unshreddedV.put(HoodieSchema.Variant.VARIANT_VALUE_FIELD,
ByteBuffer.wrap(original.getValue()));
+ GenericRecord shreddedV =
+ provider.shredVariantRecord(unshreddedV,
shreddedVariant.getAvroSchema(), shreddedVariant);
+ assertNull(shreddedV.get(HoodieSchema.Variant.VARIANT_VALUE_FIELD),
+ "both fields matched the shredding schema, so there is no residual and
dropping `value` loses nothing");
+
+ // The file side is a plain {metadata, typed_value} record: a real footer
conversion loses the variant
+ // logical type, so this is also what shape detection has to recognize.
+ HoodieSchema valueLessShredded = HoodieSchema.createRecord("v",
"org.apache.hudi.test", null, Arrays.asList(
+ HoodieSchemaField.of(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
HoodieSchema.create(HoodieSchemaType.BYTES)),
+ HoodieSchemaField.of(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD,
+ shreddedVariant.getTypedValueField().get())));
+ HoodieSchema fileSchema = HoodieSchema.createRecord("r",
"org.apache.hudi.test", null, Arrays.asList(
+ HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.LONG)),
+ HoodieSchemaField.of("v", valueLessShredded)));
+ HoodieSchema requestedSchema = HoodieSchema.createRecord("r",
"org.apache.hudi.test", null, Arrays.asList(
+ HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.LONG)),
+ HoodieSchemaField.of("v", unshreddedVariant)));
+
+ HoodieStorage storage = HoodieTestUtils.getStorage(tmp.toString()); //
allow.reading.shredded defaults true
+ HoodieVariantReconstruction reconstruction =
+ HoodieVariantReconstruction.create(fileSchema, requestedSchema,
storage);
+ assertNotNull(reconstruction, "a shredded group with no value column must
still engage");
+
+ GenericRecord valueLessV = new GenericData.Record(
+
reconstruction.intermediateSchema().getAvroSchema().getField("v").schema());
+ valueLessV.put(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
+ shreddedV.get(HoodieSchema.Variant.VARIANT_METADATA_FIELD));
+ valueLessV.put(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD,
+ shreddedV.get(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD));
+
+ GenericRecord input = new
GenericData.Record(reconstruction.intermediateSchema().getAvroSchema());
+ input.put("id", 7L);
+ input.put("v", valueLessV);
+
+ IndexedRecord out = reconstruction.reconstruct(input);
+
+ assertEquals(7L, out.get(0));
+ GenericRecord rebuiltV = (GenericRecord) out.get(1);
+ Variant rebuilt = new Variant(
+ toBytes(rebuiltV.get(HoodieSchema.Variant.VARIANT_VALUE_FIELD)),
+ toBytes(rebuiltV.get(HoodieSchema.Variant.VARIANT_METADATA_FIELD)));
+ assertEquals(original.toJson(ZoneOffset.UTC),
rebuilt.toJson(ZoneOffset.UTC));
+ }
+
private static byte[] toBytes(Object byteBuffer) {
ByteBuffer buf = ((ByteBuffer) byteBuffer).duplicate();
byte[] out = new byte[buf.remaining()];