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 c0ed52483fb2 fix(spark): read shredded variant leaves like Spark 4
(#20332)
c0ed52483fb2 is described below
commit c0ed52483fb22e57c57a9c7b211b9bcb4919cf5b
Author: voonhous <[email protected]>
AuthorDate: Tue Oct 27 23:26:34 2026 +0800
fix(spark): read shredded variant leaves like Spark 4 (#20332)
* fix(spark): match Spark 4 shredded variant reads
Hudi rebuilt shredded variants from the footer's Avro conversion, which
drops DECIMAL on INT32/INT64, the unsigned flag, JSON and NANOS. Files
written by other engines therefore read DECIMAL on INT32/INT64 as the
unscaled integer, failed on plain FLBA and TIMESTAMP(MILLIS), and read
TIME and TIMESTAMP(NANOS) as integers where Spark 4 rejects the file.
Read each typed_value leaf from its Parquet type instead, as Spark 4's
ParquetToSparkSchemaConverter and SparkShreddingUtils.buildVariantSchema
do:
- HoodieAvroParquetReader passes the file's MessageType to
HoodieVariantReconstruction, which prepares one rebuilder per shredded
variant column at file open (records, LIST encodings, map values).
- VariantShreddingProvider.rebuildVariantRecord becomes prepareRebuild,
so the layout is validated once per column instead of per record.
- Spark4VariantShreddingProvider maps leaves like Spark (unsigned ints,
JSON as string, millis scaled to micros, unscaled decimals) and
rejects TIME, TIMESTAMP(NANOS), FLOAT16, UUID, INTERVAL and INT96 at
open. Code made dead by the new path is removed.
Addresses the read side of #19442.
* docs(spark): cross-reference variant list-element rules
The planner and the Spark 4 provider each resolve a parquet LIST
element under a different converter's rules and on different
groups. Say so on both so a change to one points at the other.
---
.../hudi/common/avro/VariantShreddingProvider.java | 52 +-
.../io/storage/hadoop/HoodieAvroParquetReader.java | 16 +-
.../hadoop/HoodieVariantReconstruction.java | 211 +++++---
.../avro/TestHoodieAvroWriteSupportShredding.java | 3 +-
.../hadoop/TestHoodieVariantReconstruction.java | 103 ++--
.../variant/Spark4VariantShreddingProvider.java | 529 +++++++++++++++++----
.../TestHoodieVariantReconstructionRoundTrip.java | 129 ++++-
.../TestSpark4VariantShreddingProvider.java | 264 +++++++++-
8 files changed, 1066 insertions(+), 241 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantShreddingProvider.java
b/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantShreddingProvider.java
index c77c62ed92e0..65ff26d15187 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantShreddingProvider.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/avro/VariantShreddingProvider.java
@@ -23,9 +23,10 @@ import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericRecord;
+import org.apache.parquet.schema.GroupType;
/**
- * Interface for shredding variant values at write time.
+ * Interface for shredding variant values at write time and reconstructing
them at read time.
* <p>
* Implementations parse variant binary data (value + metadata bytes) and
produce
* a shredded {@link GenericRecord} with typed_value columns populated
according
@@ -65,24 +66,39 @@ public interface VariantShreddingProvider {
HoodieSchema.Variant variantSchema);
/**
- * Reconstruct an unshredded variant GenericRecord from a shredded one (the
inverse of
- * {@link #shredVariantRecord}).
+ * Prepares the reconstruction of one shredded variant column of a parquet
file (the inverse of
+ * {@link #shredVariantRecord}), validating its shredding layout once, when
the file is opened.
* <p>
* Used on the read path: records read from an already-shredded base file
(compaction/clustering)
- * arrive with {@code typed_value} populated. This rebuilds the full variant
binary so the record
- * presents the standard unshredded {@code {metadata, value}} shape before
it reaches the
- * merger/writer.
+ * arrive with {@code typed_value} populated. The returned rebuilder turns
each such record back
+ * into the standard unshredded {@code {metadata, value}} shape before it
reaches the merger/writer.
+ * <p>
+ * The layout comes from the file's parquet type rather than from the Avro
schema the record is read
+ * at: converting the parquet footer to Avro loses encodings (unsigned and
decimal INT32/INT64, JSON,
+ * nanosecond timestamps) that decide how a {@code typed_value} must be read.
*
- * @param shreddedVariant GenericRecord with {value, metadata, typed_value}
read from a shredded base file
- * @param shreddedSchema the Avro schema of {@code shreddedVariant}
(carries typed_value)
- * @param unshreddedSchema target Avro schema with {value: ByteBuffer,
metadata: ByteBuffer}
- * @return a GenericRecord conforming to {@code unshreddedSchema} with the
full reconstructed
- * variant binary in {@code value}, or {@code null} when {@code
shreddedVariant} is null
- * @throws org.apache.hudi.exception.HoodieException if {@code
shreddedVariant} is missing its
- * required {@code metadata} field (a malformed shredded base file)
+ * @param columnPath dotted path of the variant column, for error
messages
+ * @param parquetVariantType the variant column's parquet group, as it sits
in the file
+ * @param unshreddedSchema target Avro schema with {value: ByteBuffer,
metadata: ByteBuffer}
+ * @return a rebuilder for records of this column
+ * @throws org.apache.hudi.exception.HoodieException if the column's
shredding layout cannot be read
*/
- GenericRecord rebuildVariantRecord(
- GenericRecord shreddedVariant,
- Schema shreddedSchema,
- Schema unshreddedSchema);
-}
\ No newline at end of file
+ VariantRebuilder prepareRebuild(String columnPath, GroupType
parquetVariantType, Schema unshreddedSchema);
+
+ /**
+ * Rebuilds the records of one shredded variant column, as prepared by
{@link #prepareRebuild}.
+ */
+ interface VariantRebuilder {
+
+ /**
+ * Rebuilds the full variant binary of one record of the column.
+ *
+ * @param shreddedVariant GenericRecord with {value, metadata,
typed_value} read from a shredded base file
+ * @return a GenericRecord conforming to the unshredded schema with the
full reconstructed variant
+ * binary in {@code value}, or {@code null} when {@code
shreddedVariant} is null
+ * @throws org.apache.hudi.exception.HoodieException if {@code
shreddedVariant} is missing its
+ * required {@code metadata} field (a malformed shredded base file)
+ */
+ GenericRecord rebuild(GenericRecord shreddedVariant);
+ }
+}
diff --git
a/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroParquetReader.java
b/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroParquetReader.java
index 118e7c63856b..257801c13db0 100644
---
a/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroParquetReader.java
+++
b/hudi-hadoop-common/src/main/java/org/apache/hudi/io/storage/hadoop/HoodieAvroParquetReader.java
@@ -52,6 +52,7 @@ import org.apache.parquet.avro.HoodieAvroParquetReaderBuilder;
import org.apache.parquet.hadoop.ParquetInputFormat;
import org.apache.parquet.hadoop.ParquetReader;
import org.apache.parquet.hadoop.util.HadoopInputFile;
+import org.apache.parquet.schema.MessageType;
import java.io.IOException;
import java.util.ArrayList;
@@ -72,6 +73,7 @@ public class HoodieAvroParquetReader extends
HoodieAvroFileReader {
private final HoodieStorage storage;
private final FileFormatUtils parquetUtils;
private final List<ParquetReaderIterator> readerIterators = new
ArrayList<>();
+ private Option<MessageType> fileParquetSchema = Option.empty();
private Option<HoodieSchema> fileSchema = Option.empty();
public HoodieAvroParquetReader(HoodieStorage storage, StoragePath path) {
@@ -127,12 +129,22 @@ public class HoodieAvroParquetReader extends
HoodieAvroFileReader {
// Lazy initialization with caching: read schema from parquet file footer
on first call,
// then cache it in fileSchema to avoid repeated I/O on subsequent calls
return fileSchema.orElseGet(() -> {
- HoodieSchema schema = parquetUtils.readSchema(storage, path);
+ // Same conversion as ParquetUtils.readSchema, from the footer cached
for the variant reconstruction.
+ HoodieSchema schema =
getAvroSchemaConverter(storage.getConf().unwrapAs(Configuration.class))
+ .convert(getParquetSchema());
fileSchema = Option.ofNullable(schema);
return schema;
});
}
+ private MessageType getParquetSchema() {
+ return fileParquetSchema.orElseGet(() -> {
+ MessageType messageType = ParquetUtils.readFileMetadataOnly(storage,
path).getFileMetaData().getSchema();
+ fileParquetSchema = Option.of(messageType);
+ return messageType;
+ });
+ }
+
@Override
public void close() {
readerIterators.forEach(ParquetReaderIterator::close);
@@ -190,7 +202,7 @@ public class HoodieAvroParquetReader extends
HoodieAvroFileReader {
Configuration hadoopConf =
storage.getConf().unwrapCopyAs(Configuration.class);
// If the on-disk file shreds variant columns, read them in their shredded
(typed_value-bearing)
// shape and reconstruct the unshredded variant per-record below (#18931).
null when not needed.
- HoodieVariantReconstruction reconstruction =
HoodieVariantReconstruction.create(getSchema(), schema, storage);
+ HoodieVariantReconstruction reconstruction =
HoodieVariantReconstruction.create(getSchema(), getParquetSchema(), schema,
storage);
HoodieSchema readSchema = reconstruction == null ? schema :
reconstruction.intermediateSchema();
HoodieSchema repairedFileSchema =
HoodieSchemaRepair.repairLogicalTypes(getSchema(), readSchema);
Option<HoodieSchema> promotedSchema = Option.empty();
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 75b64a8afa56..64adb787624c 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,7 +35,12 @@ import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.generic.IndexedRecord;
+import org.apache.parquet.avro.AvroSchemaConverter;
+import org.apache.parquet.schema.GroupType;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.Type;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -47,7 +52,7 @@ import java.util.Map;
* <p>parquet-avro does not understand variant shredding, so a shredded
variant column comes back as
* a raw {@code {metadata, value, typed_value}} record. This reads such
columns at their shredded
* (typed_value-bearing) schema, then rebuilds the standard unshredded {@code
{metadata, value}}
- * variant via {@link VariantShreddingProvider#rebuildVariantRecord} before
records reach the
+ * variant via {@link VariantShreddingProvider#prepareRebuild} before records
reach the
* merger/writer. The Spark/InternalRow read path reconstructs natively and
does not use this.
*
* <p>See https://github.com/apache/hudi/issues/18931.
@@ -55,13 +60,10 @@ import java.util.Map;
final class HoodieVariantReconstruction {
private final HoodieSchema intermediateSchema;
- private final VariantShreddingProvider provider;
private final Rebuilder rootRebuilder;
- private HoodieVariantReconstruction(HoodieSchema intermediateSchema,
- VariantShreddingProvider provider,
Rebuilder rootRebuilder) {
+ private HoodieVariantReconstruction(HoodieSchema intermediateSchema,
Rebuilder rootRebuilder) {
this.intermediateSchema = intermediateSchema;
- this.provider = provider;
this.rootRebuilder = rootRebuilder;
}
@@ -78,15 +80,27 @@ final class HoodieVariantReconstruction {
* none is needed (the file has no shredded variant columns). Throws when
the file has shredded
* variant columns to reconstruct but reading shredded variants is disabled,
or no provider is
* available: either way, reading at the unshredded schema would silently
drop the typed_value payload.
+ * Also throws when a shredded variant column uses a typed_value encoding
that cannot be read, so the
+ * failure happens when the file is opened rather than on the first affected
record.
+ *
+ * @param fileSchema the file schema, converted from {@code
fileParquetSchema}
+ * @param fileParquetSchema the file's parquet schema, which carries the
typed_value encodings the
+ * Avro conversion loses
+ * @param requestedSchema the schema the records are requested at
+ * @param storage the storage the file is read with
*/
- static HoodieVariantReconstruction create(HoodieSchema fileSchema,
HoodieSchema requestedSchema, HoodieStorage storage) {
+ static HoodieVariantReconstruction create(HoodieSchema fileSchema,
MessageType fileParquetSchema,
+ HoodieSchema requestedSchema,
HoodieStorage storage) {
if (requestedSchema.getType() != HoodieSchemaType.RECORD ||
fileSchema.getType() != HoodieSchemaType.RECORD) {
return null;
}
// Records leave this reader unshredded; output field order matches the
requested/intermediate order.
HoodieSchema outputSchema =
VariantSchemaUtils.stripVariantShredding(requestedSchema);
- Rebuilder rootRebuilder = buildRebuilder(outputSchema, fileSchema);
+ // The list layout rule must match the one the file schema was converted
with (see
+ // HoodieAvroParquetReader.tryOverrideDefaultConfigs).
+ Planner planner = new
Planner(storage.getConf().getBoolean(AvroSchemaConverter.ADD_LIST_ELEMENT_RECORDS,
true));
+ Rebuilder rootRebuilder = planner.plan(outputSchema, fileSchema,
fileParquetSchema, null);
if (rootRebuilder == null) {
// No shredded variant columns in the file: nothing to reconstruct,
regardless of the flag.
return null;
@@ -110,9 +124,12 @@ final class HoodieVariantReconstruction {
+ HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key()
+ " or add a provider implementation (e.g. the Spark variant module)
to the classpath.");
}
+ for (VariantColumnRebuilder variantColumn : planner.variantColumns) {
+ variantColumn.prepare(provider);
+ }
return new HoodieVariantReconstruction(
- VariantSchemaUtils.toShreddedReadSchema(requestedSchema, fileSchema),
provider, rootRebuilder);
+ VariantSchemaUtils.toShreddedReadSchema(requestedSchema, fileSchema),
rootRebuilder);
}
/**
@@ -120,80 +137,142 @@ final class HoodieVariantReconstruction {
* a record conforming to the unshredded output schema.
*/
IndexedRecord reconstruct(IndexedRecord in) {
- return (IndexedRecord) rootRebuilder.rebuild(in, provider);
+ return (IndexedRecord) rootRebuilder.rebuild(in);
}
- /**
- * 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, matching what the writers
can emit; see
- * {@code VariantSchemaUtils.swapShreddedVariantFields} for what actually
produces a nested
- * shredded file today (both the row and the AVRO write path shred variant
record members at any
- * depth off the forced-shredding property; a variant that is directly an
array element or a map
- * value shreds on neither path unless the write schema itself declares
typed_value there).
- */
- 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;
+ /** Plans the rebuild of a file, collecting its shredded variant columns for
preparation. */
+ private static final class Planner {
+ // Mirrors the file schema conversion's ADD_LIST_ELEMENT_RECORDS, which
decides the list element.
+ private final boolean assumeRepeatedIsListElement;
+ private final List<VariantColumnRebuilder> variantColumns = new
ArrayList<>();
+
+ private Planner(boolean assumeRepeatedIsListElement) {
+ this.assumeRepeatedIsListElement = assumeRepeatedIsListElement;
}
- 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;
+
+ /**
+ * 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, and the parquet type it was converted from, are 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, matching what the writers can emit; see
+ * {@code VariantSchemaUtils.swapShreddedVariantFields} for what actually
produces a nested
+ * shredded file today (both the row and the AVRO write path shred variant
record members at any
+ * depth off the forced-shredding property; a variant that is directly an
array element or a map
+ * value shreds on neither path unless the write schema itself declares
typed_value there).
+ */
+ private Rebuilder plan(HoodieSchema outputSchema, HoodieSchema fileSchema,
Type parquetType, String path) {
+ if (VariantSchemaUtils.isShreddedVariantTarget(fileSchema,
outputSchema)) {
+ if (parquetType == null || parquetType.isPrimitive()) {
+ throw new IllegalStateException("Shredded variant column '" + path
+ + "' has no matching parquet group in the file schema: " +
parquetType);
}
- return anyTarget ? new RecordRebuilder(output.getAvroSchema(),
fieldRebuilders) : null;
+ VariantColumnRebuilder variantColumn = new VariantColumnRebuilder(
+ path, parquetType.asGroupType(),
outputSchema.getNonNullType().getAvroSchema());
+ variantColumns.add(variantColumn);
+ return variantColumn;
+ }
+ HoodieSchema output = outputSchema.getNonNullType();
+ HoodieSchema file = fileSchema.getNonNullType();
+ if (output.getType() != file.getType() || parquetType == null ||
parquetType.isPrimitive()) {
+ return null;
}
- case ARRAY: {
- Rebuilder elementRebuilder = buildRebuilder(output.getElementType(),
file.getElementType());
- return elementRebuilder == null ? null : new
ArrayRebuilder(output.getAvroSchema(), elementRebuilder);
+ GroupType group = parquetType.asGroupType();
+ 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()
+ ? plan(outputField.schema(), fileField.get().schema(),
childType(group, outputField.name()),
+ path == null ? outputField.name() : path + "." +
outputField.name())
+ : null;
+ anyTarget |= fieldRebuilders[i] != null;
+ }
+ return anyTarget ? new RecordRebuilder(output.getAvroSchema(),
fieldRebuilders) : null;
+ }
+ case ARRAY: {
+ Rebuilder elementRebuilder = plan(output.getElementType(),
file.getElementType(),
+ listElementType(group), path + ".element");
+ return elementRebuilder == null ? null : new
ArrayRebuilder(output.getAvroSchema(), elementRebuilder);
+ }
+ case MAP: {
+ Rebuilder valueRebuilder = plan(output.getValueType(),
file.getValueType(),
+ mapValueType(group), path + ".value");
+ return valueRebuilder == null ? null : new
MapRebuilder(valueRebuilder);
+ }
+ default:
+ return null;
}
- case MAP: {
- Rebuilder valueRebuilder = buildRebuilder(output.getValueType(),
file.getValueType());
- return valueRebuilder == null ? null : new
MapRebuilder(valueRebuilder);
+ }
+
+ /**
+ * The element type of a parquet LIST group, by the same rules the file
schema's Avro conversion
+ * applied (AvroSchemaConverter.isElementType): the repeated field itself
in the legacy 2-level
+ * layouts, otherwise the single field of the repeated group (3-level
layout). Applies only to
+ * lists around a variant column;
Spark4VariantShreddingProvider.listElementType reads the
+ * typed_value list inside one under Spark's rules, so the two are not
meant to agree.
+ */
+ private Type listElementType(GroupType list) {
+ if (list.getFieldCount() != 1) {
+ return null;
}
- default:
+ Type repeated = list.getType(0);
+ boolean repeatedIsElement = repeated.isPrimitive()
+ || repeated.asGroupType().getFieldCount() > 1
+ ||
repeated.asGroupType().getType(0).isRepetition(Type.Repetition.REPEATED)
+ || repeated.getName().equals("array")
+ || repeated.getName().equals(list.getName() + "_tuple")
+ || assumeRepeatedIsListElement;
+ return repeatedIsElement ? repeated : repeated.asGroupType().getType(0);
+ }
+
+ /** The value type of a parquet MAP group: the second field of its
repeated key_value group. */
+ private static Type mapValueType(GroupType map) {
+ if (map.getFieldCount() != 1 || map.getType(0).isPrimitive()) {
return null;
+ }
+ GroupType keyValue = map.getType(0).asGroupType();
+ return keyValue.getFieldCount() == 2 ? keyValue.getType(1) : null;
+ }
+
+ private static Type childType(GroupType group, String name) {
+ return group.containsField(name) ? group.getType(name) : null;
}
}
/** Rebuilds one value read at the file's shape into its unshredded output
shape. */
private interface Rebuilder {
- Object rebuild(Object value, VariantShreddingProvider provider);
+ Object rebuild(Object value);
}
- private static final class VariantRebuilder implements Rebuilder {
- private final Schema shreddedSchema;
+ private static final class VariantColumnRebuilder implements Rebuilder {
+ private final String path;
+ private final GroupType parquetType;
private final Schema unshreddedSchema;
+ // Set by prepare() once the provider is loaded, before any record is
rebuilt.
+ private VariantShreddingProvider.VariantRebuilder rebuilder;
- private VariantRebuilder(Schema shreddedSchema, Schema unshreddedSchema) {
- this.shreddedSchema = shreddedSchema;
+ private VariantColumnRebuilder(String path, GroupType parquetType, Schema
unshreddedSchema) {
+ this.path = path;
+ this.parquetType = parquetType;
this.unshreddedSchema = unshreddedSchema;
}
+ private void prepare(VariantShreddingProvider provider) {
+ rebuilder = provider.prepareRebuild(path, parquetType, unshreddedSchema);
+ }
+
@Override
- public Object rebuild(Object value, VariantShreddingProvider provider) {
+ public Object rebuild(Object value) {
// A null variant passes through unchanged.
- return value instanceof GenericRecord
- ? provider.rebuildVariantRecord((GenericRecord) value,
shreddedSchema, unshreddedSchema)
- : value;
+ return value instanceof GenericRecord ?
rebuilder.rebuild((GenericRecord) value) : value;
}
}
@@ -208,7 +287,7 @@ final class HoodieVariantReconstruction {
}
@Override
- public Object rebuild(Object value, VariantShreddingProvider provider) {
+ public Object rebuild(Object value) {
if (!(value instanceof IndexedRecord)) {
return value;
}
@@ -216,7 +295,7 @@ final class HoodieVariantReconstruction {
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));
+ out.put(i, fieldRebuilders[i] == null ? fieldValue :
fieldRebuilders[i].rebuild(fieldValue));
}
return out;
}
@@ -232,14 +311,14 @@ final class HoodieVariantReconstruction {
}
@Override
- public Object rebuild(Object value, VariantShreddingProvider provider) {
+ public Object rebuild(Object value) {
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));
+ out.add(elementRebuilder.rebuild(element));
}
return out;
}
@@ -253,14 +332,14 @@ final class HoodieVariantReconstruction {
}
@Override
- public Object rebuild(Object value, VariantShreddingProvider provider) {
+ public Object rebuild(Object value) {
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));
+ out.put(entry.getKey(), valueRebuilder.rebuild(entry.getValue()));
}
return out;
}
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 4c35a5300d6d..9e31357aa264 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
@@ -677,8 +677,7 @@ class TestHoodieAvroWriteSupportShredding {
}
@Override
- public GenericRecord rebuildVariantRecord(GenericRecord shreddedVariant,
Schema shreddedSchema,
- Schema unshreddedSchema) {
+ public VariantRebuilder prepareRebuild(String columnPath, GroupType
parquetVariantType, Schema unshreddedSchema) {
throw new UnsupportedOperationException(
"the round trip reads raw parquet; the read half is
TestHoodieVariantReconstruction's");
}
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 99d1cf45f2fa..4cd1af7d9397 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
@@ -32,17 +32,26 @@ import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.generic.IndexedRecord;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.parquet.avro.AvroSchemaConverter;
+import org.apache.parquet.avro.AvroWriteSupport;
+import org.apache.parquet.schema.GroupType;
+import org.apache.parquet.schema.MessageType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import static
org.apache.parquet.avro.HoodieAvroParquetSchemaConverter.getAvroSchemaConverter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -56,6 +65,15 @@ class TestHoodieVariantReconstruction {
Collections.singletonList(HoodieSchemaField.of("v", variantSchema)));
}
+ /** Creates the reconstruction with the parquet schema the file schema would
have been read from. */
+ private static HoodieVariantReconstruction create(HoodieSchema fileSchema,
HoodieSchema requestedSchema,
+ HoodieStorage storage) {
+ MessageType fileParquetSchema = fileSchema.getType() ==
HoodieSchemaType.RECORD
+ ?
getAvroSchemaConverter(storage.getConf().unwrapAs(Configuration.class)).convert(fileSchema)
+ : null;
+ return HoodieVariantReconstruction.create(fileSchema, fileParquetSchema,
requestedSchema, storage);
+ }
+
private static HoodieStorage storageWithReadingShredded(Path tmp, boolean
enabled) {
HoodieStorage storage = HoodieTestUtils.getStorage(tmp.toString());
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_ALLOW_READING_SHREDDED.key(),
@@ -68,29 +86,33 @@ class TestHoodieVariantReconstruction {
HoodieSchema fileSchema = recordWithVariant(
HoodieSchema.createVariantShredded(HoodieSchema.create(HoodieSchemaType.INT)));
HoodieSchema requestedSchema =
recordWithVariant(HoodieSchema.createVariant());
+ HoodieStorage storage = storageWithReadingShredded(tmp, false);
+
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key(),
+ TestVariantShreddingProvider.class.getName());
+ TestVariantShreddingProvider.PREPARED.clear();
- HoodieException ex = assertThrows(HoodieException.class, () ->
- HoodieVariantReconstruction.create(fileSchema, requestedSchema,
- storageWithReadingShredded(tmp, false)));
+ HoodieException ex = assertThrows(HoodieException.class, () ->
create(fileSchema, requestedSchema, storage));
// The flag check must run after shredded-column detection: dropping
typed_value silently would
// corrupt rows whose payload lives there, so a disabled read of a
shredded file fails fast.
assertTrue(ex.getMessage().contains("reading shredded variants is
disabled"), ex.getMessage());
+ // And before any column is prepared, so a provider's own layout errors
cannot mask it.
+ assertTrue(TestVariantShreddingProvider.PREPARED.isEmpty(),
TestVariantShreddingProvider.PREPARED::toString);
}
@Test
void returnsNullWhenNoShreddedColumnEvenIfReadingDisabled(@TempDir Path tmp)
{
HoodieSchema schema = recordWithVariant(HoodieSchema.createVariant());
// No shredded variant column to reconstruct: nothing to do regardless of
the flag.
- assertNull(HoodieVariantReconstruction.create(schema, schema,
+ assertNull(create(schema, schema,
storageWithReadingShredded(tmp, false)));
}
@Test
void returnsNullForNonRecordSchemas(@TempDir Path tmp) {
HoodieStorage storage = storageWithReadingShredded(tmp, true);
- assertNull(HoodieVariantReconstruction.create(
+ assertNull(create(
HoodieSchema.create(HoodieSchemaType.STRING),
recordWithVariant(HoodieSchema.createVariant()), storage));
- assertNull(HoodieVariantReconstruction.create(
+ assertNull(create(
recordWithVariant(HoodieSchema.createVariant()),
HoodieSchema.create(HoodieSchemaType.STRING), storage));
}
@@ -101,7 +123,7 @@ class TestHoodieVariantReconstruction {
HoodieSchema requestedSchema =
recordWithVariant(HoodieSchema.createVariant());
HoodieException exception = assertThrows(HoodieException.class, () ->
- HoodieVariantReconstruction.create(fileSchema, requestedSchema,
+ create(fileSchema, requestedSchema,
storageWithReadingShredded(tmp, true)));
assertTrue(exception.getMessage().contains("no VariantShreddingProvider is
available"));
}
@@ -115,7 +137,7 @@ class TestHoodieVariantReconstruction {
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key(),
TestVariantShreddingProvider.class.getName());
- HoodieVariantReconstruction reconstruction =
HoodieVariantReconstruction.create(
+ HoodieVariantReconstruction reconstruction = create(
fileSchema, requestedSchema, storage);
assertNotNull(reconstruction);
assertTrue(reconstruction.intermediateSchema().getField("v").get().schema().getNonNullType()
@@ -154,7 +176,7 @@ class TestHoodieVariantReconstruction {
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key(),
TestVariantShreddingProvider.class.getName());
- HoodieVariantReconstruction reconstruction =
HoodieVariantReconstruction.create(
+ HoodieVariantReconstruction reconstruction = create(
fileSchema, requestedSchema, storage);
assertNotNull(reconstruction, "Plain-record shredded shape with a variant
requested column must engage");
@@ -182,7 +204,7 @@ class TestHoodieVariantReconstruction {
// shredded reading disabled.
HoodieSchema fileSchema =
recordWithIdAndVariant(footerStylePlainUnshreddedSchema());
HoodieSchema requestedSchema =
recordWithIdAndVariant(HoodieSchema.createVariant());
- assertNull(HoodieVariantReconstruction.create(fileSchema, requestedSchema,
+ assertNull(create(fileSchema, requestedSchema,
storageWithReadingShredded(tmp, false)));
}
@@ -203,7 +225,7 @@ class TestHoodieVariantReconstruction {
storage.getConf().set(HoodieStorageConfig.PARQUET_VARIANT_SHREDDING_PROVIDER_CLASS.key(),
TestVariantShreddingProvider.class.getName());
- HoodieVariantReconstruction reconstruction =
HoodieVariantReconstruction.create(
+ HoodieVariantReconstruction reconstruction = create(
fileSchema, requestedSchema, storage);
assertNotNull(reconstruction, "A shredded group with no value column must
still engage");
@@ -229,7 +251,7 @@ class TestHoodieVariantReconstruction {
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(
+ assertNull(create(
recordWithIdAndVariant(fourField),
recordWithIdAndVariant(HoodieSchema.createVariant()),
storageWithReadingShredded(tmp, false)));
}
@@ -243,7 +265,7 @@ class TestHoodieVariantReconstruction {
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(
+ assertNull(create(
recordWithIdAndVariant(threeField),
recordWithIdAndVariant(HoodieSchema.createVariant()),
storageWithReadingShredded(tmp, false)));
}
@@ -255,12 +277,13 @@ class TestHoodieVariantReconstruction {
// 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,
+ assertNull(create(fileSchema, requestedSchema,
storageWithReadingShredded(tmp, false)));
}
- @Test
- void reconstructsShreddedVariantsNestedInRecordsArraysAndMaps(@TempDir Path
tmp) {
+ @ParameterizedTest(name = "writeOldListStructure={0}")
+ @ValueSource(booleans = {true, false})
+ void reconstructsShreddedVariantsNestedInRecordsArraysAndMaps(boolean
writeOldListStructure, @TempDir Path tmp) {
// Both write paths shred at any depth their write schema asks them to
// (HoodieRowParquetWriteSupport.processNestedDataType,
VariantSchemaUtils.applyForcedShredding),
// so a nested variant can reach this reader as a plain {metadata, value,
typed_value} record too.
@@ -281,11 +304,32 @@ class TestHoodieVariantReconstruction {
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);
+ // The file's lists are either the legacy 2-level `repeated group array`
or the 3-level
+ // `repeated group list { element }`; HoodieAvroParquetReader converts the
footer of both with
+ // ADD_LIST_ELEMENT_RECORDS=false (tryOverrideDefaultConfigs).
+ storage.getConf().set(AvroWriteSupport.WRITE_OLD_LIST_STRUCTURE,
Boolean.toString(writeOldListStructure));
+ storage.getConf().set(AvroSchemaConverter.ADD_LIST_ELEMENT_RECORDS,
"false");
+ MessageType fileParquetSchema =
+
getAvroSchemaConverter(storage.getConf().unwrapAs(Configuration.class)).convert(fileSchema);
+ GroupType itemsRepeated =
fileParquetSchema.getType("items").asGroupType().getType(0).asGroupType();
+ assertEquals(writeOldListStructure ? "array" : "list",
itemsRepeated.getName());
+ TestVariantShreddingProvider.PREPARED.clear();
+
+ HoodieVariantReconstruction reconstruction =
+ HoodieVariantReconstruction.create(fileSchema, fileParquetSchema,
requestedSchema, storage);
assertNotNull(reconstruction, "A variant nested below the top level must
engage reconstruction");
+ // Each variant column is prepared once, from its own parquet group: the
struct member, the list
+ // element (the repeated group itself in the 2-level layout, its only
field in the 3-level one) and
+ // the map value.
+ Map<String, GroupType> expectedGroups = new LinkedHashMap<>();
+ expectedGroups.put("nested.v",
fileParquetSchema.getType("nested").asGroupType().getType("v").asGroupType());
+ expectedGroups.put("items.element",
+ writeOldListStructure ? itemsRepeated :
itemsRepeated.getType(0).asGroupType());
+ expectedGroups.put("tags.value",
+
fileParquetSchema.getType("tags").asGroupType().getType(0).asGroupType().getType(1).asGroupType());
+ assertEquals(expectedGroups, TestVariantShreddingProvider.PREPARED);
+
// 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();
@@ -342,7 +386,7 @@ class TestHoodieVariantReconstruction {
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,
+ assertNull(create(fileSchema, requestedSchema,
storageWithReadingShredded(tmp, false)));
}
@@ -404,6 +448,9 @@ class TestHoodieVariantReconstruction {
}
public static class TestVariantShreddingProvider implements
VariantShreddingProvider {
+ // The provider is instantiated by class name, so what it is prepared with
is recorded statically.
+ static final Map<String, GroupType> PREPARED = new LinkedHashMap<>();
+
@Override
public GenericRecord shredVariantRecord(
GenericRecord unshreddedVariant, Schema shreddedSchema,
HoodieSchema.Variant variantSchema) {
@@ -412,13 +459,15 @@ class TestHoodieVariantReconstruction {
}
@Override
- public GenericRecord rebuildVariantRecord(
- GenericRecord shreddedVariant, Schema shreddedSchema, Schema
unshreddedSchema) {
- GenericRecord rebuilt = new GenericData.Record(unshreddedSchema);
- rebuilt.put("metadata", shreddedVariant.get("metadata"));
- rebuilt.put("value", ByteBuffer.wrap(new byte[] {
- ((Number) shreddedVariant.get("typed_value")).byteValue()}));
- return rebuilt;
+ public VariantRebuilder prepareRebuild(String columnPath, GroupType
parquetVariantType, Schema unshreddedSchema) {
+ PREPARED.put(columnPath, parquetVariantType);
+ return shreddedVariant -> {
+ GenericRecord rebuilt = new GenericData.Record(unshreddedSchema);
+ rebuilt.put("metadata", shreddedVariant.get("metadata"));
+ rebuilt.put("value", ByteBuffer.wrap(new byte[] {
+ ((Number) shreddedVariant.get("typed_value")).byteValue()}));
+ return rebuilt;
+ };
}
}
}
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/main/java/org/apache/hudi/variant/Spark4VariantShreddingProvider.java
b/hudi-spark-datasource/hudi-spark4-common/src/main/java/org/apache/hudi/variant/Spark4VariantShreddingProvider.java
index 63a6e9d17588..9df38cd39e44 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/main/java/org/apache/hudi/variant/Spark4VariantShreddingProvider.java
+++
b/hudi-spark-datasource/hudi-spark4-common/src/main/java/org/apache/hudi/variant/Spark4VariantShreddingProvider.java
@@ -23,13 +23,16 @@ import org.apache.hudi.common.avro.VariantShreddingProvider;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.exception.HoodieException;
-import org.apache.avro.Conversions;
import org.apache.avro.LogicalType;
import org.apache.avro.LogicalTypes;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericFixed;
import org.apache.avro.generic.GenericRecord;
+import org.apache.parquet.schema.GroupType;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.Type;
import org.apache.spark.types.variant.ShreddingUtils;
import org.apache.spark.types.variant.Variant;
import org.apache.spark.types.variant.VariantSchema;
@@ -38,11 +41,15 @@ import
org.apache.spark.types.variant.VariantShreddingWriter.ShreddedResult;
import
org.apache.spark.types.variant.VariantShreddingWriter.ShreddedResultBuilder;
import java.math.BigDecimal;
+import java.math.BigInteger;
import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.UUID;
import static org.apache.hudi.common.avro.HoodieAvroUtils.unwrapNullable;
@@ -63,6 +70,11 @@ import static
org.apache.hudi.common.avro.HoodieAvroUtils.unwrapNullable;
*/
public class Spark4VariantShreddingProvider implements
VariantShreddingProvider {
+ // Spark's Decimal.MAX_INT_DIGITS, Decimal.MAX_LONG_DIGITS and
DecimalType.MAX_PRECISION.
+ private static final int MAX_INT_DIGITS = 9;
+ private static final int MAX_LONG_DIGITS = 18;
+ private static final int MAX_DECIMAL_PRECISION = 38;
+
@Override
public GenericRecord shredVariantRecord(
GenericRecord unshreddedVariant,
@@ -94,27 +106,27 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
@Override
- public GenericRecord rebuildVariantRecord(
- GenericRecord shreddedVariant,
- Schema shreddedSchema,
- Schema unshreddedSchema) {
+ public VariantRebuilder prepareRebuild(String columnPath, GroupType
parquetVariantType, Schema unshreddedSchema) {
+ Map<VariantSchema, LeafDecoding> decodings = new IdentityHashMap<>();
+ VariantSchema sparkSchema = buildReadVariantSchema(parquetVariantType,
true, columnPath, columnPath, decodings);
+ return shreddedVariant -> rebuildVariantRecord(shreddedVariant,
sparkSchema, decodings, unshreddedSchema);
+ }
+ private static GenericRecord rebuildVariantRecord(GenericRecord
shreddedVariant, VariantSchema sparkSchema,
+ Map<VariantSchema,
LeafDecoding> decodings,
+ Schema unshreddedSchema) {
if (shreddedVariant == null) {
return null;
}
- ByteBuffer metadataBuf = (ByteBuffer)
shreddedVariant.get(HoodieSchema.Variant.VARIANT_METADATA_FIELD);
- if (metadataBuf == null) {
+ if (shreddedVariant.get(HoodieSchema.Variant.VARIANT_METADATA_FIELD) ==
null) {
// metadata is REQUIRED in the shredded schema, so a null here means the
base file is malformed;
// fail fast rather than silently nulling out the variant column.
throw new HoodieException("Cannot reconstruct variant: shredded record
is missing the required '"
+ HoodieSchema.Variant.VARIANT_METADATA_FIELD + "' field; the base
file is malformed.");
}
- // Reuse the same VariantSchema index assignment as the write path (no
builder needed on read).
- VariantSchema sparkSchema = buildVariantSchema(shreddedSchema, true, null);
-
// Delegate to Spark's reconstruction algorithm (inverse of castShredded).
- Variant variant = ShreddingUtils.rebuild(new
AvroVariantRow(shreddedVariant, sparkSchema), sparkSchema);
+ Variant variant = ShreddingUtils.rebuild(new
AvroVariantRow(shreddedVariant, sparkSchema, decodings), sparkSchema);
GenericRecord out = new GenericData.Record(unshreddedSchema);
out.put(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
ByteBuffer.wrap(variant.getMetadata()));
@@ -123,7 +135,7 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
/**
- * Builds a {@link VariantSchema} from an Avro {@link Schema} representing a
+ * Builds the write-side {@link VariantSchema} from an Avro {@link Schema}
representing a
* shredded variant structure ({@code value}, {@code metadata}, {@code
typed_value}).
*
* <p>This method also registers the Avro schema mapping in the builder so
that
@@ -176,9 +188,9 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
break;
default:
- // Scalar shredding. On write (non-null builder), reject leaf types
Spark 4 cannot shred,
- // as SparkShreddingUtils.buildVariantSchema does with
invalidVariantShreddingSchema.
- if (builder != null && !isSparkShreddableScalar(tvSchema)) {
+ // Scalar shredding. Reject leaf types Spark 4 cannot shred, as
+ // SparkShreddingUtils.buildVariantSchema does with
invalidVariantShreddingSchema.
+ if (!isSparkShreddableScalar(tvSchema)) {
LogicalType logicalType = tvSchema.getLogicalType();
String typeName = tvSchema.getType().getName()
+ (logicalType == null ? "" : " (" + logicalType.getName() +
")");
@@ -193,13 +205,7 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
VariantSchema result = new VariantSchema(
typedIdx, variantIdx, topLevelMetadataIdx, numFields,
scalarSchema, objectSchema, arraySchema);
-
- // The read (rebuild) path passes a null builder: it needs the
VariantSchema indices but no
- // Avro-schema registration (registration only feeds write-side result
construction).
- if (builder != null) {
- builder.registerSchema(result, avroSchema);
- }
-
+ builder.registerSchema(result, avroSchema);
return result;
}
@@ -239,8 +245,8 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
/**
- * Maps an Avro {@link Schema} type (potentially with logical type
annotations)
- * to a {@link VariantSchema.ScalarType}.
+ * Maps a scalar {@code typed_value} Avro {@link Schema} that passed
+ * {@link #isSparkShreddableScalar} to a {@link VariantSchema.ScalarType}.
*/
private VariantSchema.ScalarType avroTypeToScalarType(Schema schema) {
LogicalType logicalType = schema.getLogicalType();
@@ -261,16 +267,6 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
if ("local-timestamp-micros".equals(name)) {
return new VariantSchema.TimestampNTZType();
}
- // The Variant binary spec stores timestamps in microseconds, so a
millisecond-precision
- // typed_value cannot represent a variant timestamp. Map it to no scalar
type rather than the
- // micros TimestampType, which would silently scale the value by 1000x.
(The write path
- // rejects such a leaf before reaching here.)
- if ("timestamp-millis".equals(name) ||
"local-timestamp-millis".equals(name)) {
- return null;
- }
- if ("uuid".equals(name)) {
- return new VariantSchema.UuidType();
- }
}
switch (schema.getType()) {
@@ -288,13 +284,346 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
return new VariantSchema.StringType();
case BYTES:
return new VariantSchema.BinaryType();
- case FIXED:
- return new VariantSchema.BinaryType();
default:
- return null;
+ throw new IllegalStateException("Unexpected shreddable typed_value
type: " + schema);
+ }
+ }
+
+ /**
+ * How the Avro value parquet-avro reads for a scalar {@code typed_value}
leaf turns into the value
+ * Spark's {@link ShreddingUtils#rebuild} expects. The file schema's Avro
conversion drops these
+ * encodings, so they are taken from the leaf's parquet type, as
ParquetRowConverter does.
+ */
+ enum LeafDecoding {
+ /** Boolean, Integer, Long, Float, Double, CharSequence, or bytes
(ByteBuffer / GenericFixed), as is. */
+ DIRECT,
+ /** INT(32, false): the Integer is unsigned, widened to long. */
+ UNSIGNED_INT32,
+ /** INT(64, false): the Long is unsigned, read as DECIMAL(20, 0). */
+ UNSIGNED_INT64,
+ /** TIMESTAMP(MILLIS): the Long is in milliseconds, scaled to micros. */
+ TIMESTAMP_MILLIS,
+ /** DECIMAL on INT32 / INT64: the Integer / Long is the unscaled value. */
+ DECIMAL_UNSCALED,
+ /** DECIMAL on BINARY / FIXED_LEN_BYTE_ARRAY: big-endian two's-complement
unscaled bytes. */
+ DECIMAL_BYTES,
+ /** BINARY (JSON): UTF-8 bytes, read as a string. */
+ JSON
+ }
+
+ /** A scalar {@code typed_value} leaf: its Spark scalar type and how its
Avro value is decoded. */
+ private static final class ScalarLeaf {
+ private final VariantSchema.ScalarType type;
+ private final LeafDecoding decoding;
+
+ private ScalarLeaf(VariantSchema.ScalarType type, LeafDecoding decoding) {
+ this.type = type;
+ this.decoding = decoding;
+ }
+ }
+
+ /**
+ * Builds the read-side {@link VariantSchema} of a shredded variant group
from its parquet type, the
+ * way Spark 4 does when it opens a file:
SparkShreddingUtils.buildVariantSchema over the type that
+ * ParquetToSparkSchemaConverter derives with ignoreVariantAnnotation =
true. Ordinals are the parquet
+ * field positions, as in Spark. Records each scalar leaf's {@link
LeafDecoding} in {@code decodings},
+ * and throws for layouts and leaf encodings Spark 4 rejects.
+ */
+ private static VariantSchema buildReadVariantSchema(GroupType group, boolean
topLevel, String columnPath,
+ String groupPath,
Map<VariantSchema, LeafDecoding> decodings) {
+ if (group.getFieldCount() == 0) {
+ throw invalidShreddingLayout(columnPath, groupPath, group);
+ }
+ int typedIdx = -1;
+ int variantIdx = -1;
+ int topLevelMetadataIdx = -1;
+ ScalarLeaf scalarLeaf = null;
+ VariantSchema.ObjectField[] objectSchema = null;
+ VariantSchema arraySchema = null;
+
+ for (int i = 0; i < group.getFieldCount(); i++) {
+ Type field = group.getType(i);
+ String fieldPath = groupPath + "." + field.getName();
+ switch (field.getName()) {
+ case HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD:
+ if (typedIdx != -1) {
+ throw invalidShreddingLayout(columnPath, groupPath, group);
+ }
+ typedIdx = i;
+ if (field.isPrimitive()) {
+ scalarLeaf = toScalarLeaf(field.asPrimitiveType(), columnPath,
fieldPath);
+ } else if (field.getLogicalTypeAnnotation() instanceof
LogicalTypeAnnotation.ListLogicalTypeAnnotation) {
+ Type element = listElementType(field.asGroupType());
+ if (!isStructGroup(element)) {
+ throw invalidShreddingLayout(columnPath, fieldPath, field);
+ }
+ arraySchema = buildReadVariantSchema(
+ element.asGroupType(), false, columnPath, fieldPath + "." +
element.getName(), decodings);
+ } else if (isStructGroup(field)) {
+ objectSchema = buildReadObjectSchema(field.asGroupType(),
columnPath, fieldPath, decodings);
+ } else {
+ throw invalidShreddingLayout(columnPath, fieldPath, field);
+ }
+ break;
+ case HoodieSchema.Variant.VARIANT_VALUE_FIELD:
+ if (variantIdx != -1 || !isSparkBinary(field)) {
+ throw invalidShreddingLayout(columnPath, groupPath, group);
+ }
+ variantIdx = i;
+ break;
+ case HoodieSchema.Variant.VARIANT_METADATA_FIELD:
+ if (topLevelMetadataIdx != -1 || !isSparkBinary(field)) {
+ throw invalidShreddingLayout(columnPath, groupPath, group);
+ }
+ topLevelMetadataIdx = i;
+ break;
+ default:
+ throw invalidShreddingLayout(columnPath, groupPath, group);
+ }
+ }
+ if (topLevel != (topLevelMetadataIdx >= 0)) {
+ throw invalidShreddingLayout(columnPath, groupPath, group);
+ }
+
+ VariantSchema result = new VariantSchema(typedIdx, variantIdx,
topLevelMetadataIdx, group.getFieldCount(),
+ scalarLeaf == null ? null : scalarLeaf.type, objectSchema,
arraySchema);
+ if (scalarLeaf != null) {
+ decodings.put(result, scalarLeaf.decoding);
+ }
+ return result;
+ }
+
+ /** An object {@code typed_value}: a non-empty group of uniquely named
shredded field groups. */
+ private static VariantSchema.ObjectField[] buildReadObjectSchema(GroupType
typedValue, String columnPath,
+ String
typedValuePath,
+
Map<VariantSchema, LeafDecoding> decodings) {
+ if (typedValue.getFieldCount() == 0) {
+ throw invalidShreddingLayout(columnPath, typedValuePath, typedValue);
+ }
+ VariantSchema.ObjectField[] fields = new
VariantSchema.ObjectField[typedValue.getFieldCount()];
+ Set<String> names = new HashSet<>();
+ for (int i = 0; i < fields.length; i++) {
+ Type field = typedValue.getType(i);
+ if (!names.add(field.getName()) || !isStructGroup(field)) {
+ throw invalidShreddingLayout(columnPath, typedValuePath, typedValue);
+ }
+ fields[i] = new VariantSchema.ObjectField(field.getName(),
buildReadVariantSchema(
+ field.asGroupType(), false, columnPath, typedValuePath + "." +
field.getName(), decodings));
+ }
+ return fields;
+ }
+
+ /**
+ * Maps a scalar {@code typed_value} leaf's parquet type to the Spark scalar
type, mirroring
+ * ParquetToSparkSchemaConverter.convertPrimitiveField at its defaults
(binaryAsString = false,
+ * inferTimestampNTZ = true, nanosAsLong = false) followed by
SparkShreddingUtils.buildVariantSchema,
+ * and to the matching {@link LeafDecoding}. INT96 is rejected although
Spark reads it as a timestamp:
+ * Hudi's Avro reader does not support it.
+ */
+ private static ScalarLeaf toScalarLeaf(PrimitiveType leaf, String
columnPath, String leafPath) {
+ // Leaf annotations are matched against a whitelist, so the UNKNOWN
annotation (Spark's NullType,
+ // not a shreddable scalar) is rejected without naming its class, which
parquet < 1.16 lacks.
+ LogicalTypeAnnotation annotation = leaf.getLogicalTypeAnnotation();
+ switch (leaf.getPrimitiveTypeName()) {
+ case BOOLEAN:
+ if (annotation != null) {
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ return new ScalarLeaf(new VariantSchema.BooleanType(),
LeafDecoding.DIRECT);
+ case FLOAT:
+ if (annotation != null) {
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ return new ScalarLeaf(new VariantSchema.FloatType(),
LeafDecoding.DIRECT);
+ case DOUBLE:
+ if (annotation != null) {
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ return new ScalarLeaf(new VariantSchema.DoubleType(),
LeafDecoding.DIRECT);
+ case INT32:
+ return toInt32Leaf(leaf, annotation, columnPath, leafPath);
+ case INT64:
+ return toInt64Leaf(leaf, annotation, columnPath, leafPath);
+ case BINARY:
+ if (annotation == null || annotation instanceof
LogicalTypeAnnotation.BsonLogicalTypeAnnotation) {
+ return new ScalarLeaf(new VariantSchema.BinaryType(),
LeafDecoding.DIRECT);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.StringLogicalTypeAnnotation
+ || annotation instanceof
LogicalTypeAnnotation.EnumLogicalTypeAnnotation) {
+ return new ScalarLeaf(new VariantSchema.StringType(),
LeafDecoding.DIRECT);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.JsonLogicalTypeAnnotation) {
+ return new ScalarLeaf(new VariantSchema.StringType(),
LeafDecoding.JSON);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ return toDecimalLeaf(leaf, MAX_DECIMAL_PRECISION,
LeafDecoding.DECIMAL_BYTES, columnPath, leafPath);
+ }
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ case FIXED_LEN_BYTE_ARRAY:
+ if (annotation == null) {
+ return new ScalarLeaf(new VariantSchema.BinaryType(),
LeafDecoding.DIRECT);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ return toDecimalLeaf(leaf,
maxPrecisionForBytes(leaf.getTypeLength()), LeafDecoding.DECIMAL_BYTES,
+ columnPath, leafPath);
+ }
+ // UUID, FLOAT16, INTERVAL: Spark rejects them.
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ case INT96:
+ throw new HoodieException("Cannot read shredded variant column '" +
columnPath + "': typed_value '"
+ + leafPath + "' has parquet type '" + leaf + "'. INT96 typed_value
is not supported by Hudi's Avro reader.");
+ default:
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ }
+
+ private static ScalarLeaf toInt32Leaf(PrimitiveType leaf,
LogicalTypeAnnotation annotation,
+ String columnPath, String leafPath) {
+ if (annotation == null) {
+ return new ScalarLeaf(new
VariantSchema.IntegralType(VariantSchema.IntegralSize.INT),
LeafDecoding.DIRECT);
+ }
+ if (annotation instanceof LogicalTypeAnnotation.IntLogicalTypeAnnotation) {
+ LogicalTypeAnnotation.IntLogicalTypeAnnotation intType =
(LogicalTypeAnnotation.IntLogicalTypeAnnotation) annotation;
+ // Unsigned ints widen to the next signed size, as in Spark.
+ switch (intType.getBitWidth()) {
+ case 8:
+ return new ScalarLeaf(new
VariantSchema.IntegralType(intType.isSigned()
+ ? VariantSchema.IntegralSize.BYTE :
VariantSchema.IntegralSize.SHORT), LeafDecoding.DIRECT);
+ case 16:
+ return new ScalarLeaf(new
VariantSchema.IntegralType(intType.isSigned()
+ ? VariantSchema.IntegralSize.SHORT :
VariantSchema.IntegralSize.INT), LeafDecoding.DIRECT);
+ case 32:
+ return intType.isSigned()
+ ? new ScalarLeaf(new
VariantSchema.IntegralType(VariantSchema.IntegralSize.INT), LeafDecoding.DIRECT)
+ : new ScalarLeaf(new
VariantSchema.IntegralType(VariantSchema.IntegralSize.LONG),
LeafDecoding.UNSIGNED_INT32);
+ default:
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ }
+ if (annotation instanceof LogicalTypeAnnotation.DateLogicalTypeAnnotation)
{
+ return new ScalarLeaf(new VariantSchema.DateType(), LeafDecoding.DIRECT);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ return toDecimalLeaf(leaf, MAX_INT_DIGITS,
LeafDecoding.DECIMAL_UNSCALED, columnPath, leafPath);
+ }
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+
+ private static ScalarLeaf toInt64Leaf(PrimitiveType leaf,
LogicalTypeAnnotation annotation,
+ String columnPath, String leafPath) {
+ if (annotation == null) {
+ return new ScalarLeaf(new
VariantSchema.IntegralType(VariantSchema.IntegralSize.LONG),
LeafDecoding.DIRECT);
+ }
+ if (annotation instanceof LogicalTypeAnnotation.IntLogicalTypeAnnotation) {
+ LogicalTypeAnnotation.IntLogicalTypeAnnotation intType =
(LogicalTypeAnnotation.IntLogicalTypeAnnotation) annotation;
+ if (intType.getBitWidth() != 64) {
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ // DECIMAL(20, 0) holds the largest unsigned long, as in Spark.
+ return intType.isSigned()
+ ? new ScalarLeaf(new
VariantSchema.IntegralType(VariantSchema.IntegralSize.LONG),
LeafDecoding.DIRECT)
+ : new ScalarLeaf(new VariantSchema.DecimalType(20, 0),
LeafDecoding.UNSIGNED_INT64);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ return toDecimalLeaf(leaf, MAX_LONG_DIGITS,
LeafDecoding.DECIMAL_UNSCALED, columnPath, leafPath);
+ }
+ if (annotation instanceof
LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) {
+ LogicalTypeAnnotation.TimestampLogicalTypeAnnotation timestamp =
+ (LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) annotation;
+ LeafDecoding decoding;
+ switch (timestamp.getUnit()) {
+ case MICROS:
+ decoding = LeafDecoding.DIRECT;
+ break;
+ case MILLIS:
+ decoding = LeafDecoding.TIMESTAMP_MILLIS;
+ break;
+ default:
+ // NANOS: Spark reads it only as a plain long under its legacy
nanosAsLong flag.
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ return new ScalarLeaf(timestamp.isAdjustedToUTC()
+ ? new VariantSchema.TimestampType() : new
VariantSchema.TimestampNTZType(), decoding);
+ }
+ // TIME included: Spark reads TIME(MICROS, local) as TimeType, which is
not a shreddable scalar.
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+
+ /** A decimal leaf whose precision must fit its physical type, as
ParquetToSparkSchemaConverter checks. */
+ private static ScalarLeaf toDecimalLeaf(PrimitiveType leaf, int
maxPrecision, LeafDecoding decoding,
+ String columnPath, String leafPath) {
+ LogicalTypeAnnotation.DecimalLogicalTypeAnnotation decimal =
+ (LogicalTypeAnnotation.DecimalLogicalTypeAnnotation)
leaf.getLogicalTypeAnnotation();
+ int precision = decimal.getPrecision();
+ int scale = decimal.getScale();
+ if (precision < 1 || precision > maxPrecision || precision >
MAX_DECIMAL_PRECISION
+ || scale < 0 || scale > precision) {
+ throw unsupportedLeaf(columnPath, leafPath, leaf);
+ }
+ return new ScalarLeaf(new VariantSchema.DecimalType(precision, scale),
decoding);
+ }
+
+ /** Spark's Decimal.maxPrecisionForBytes: the digits a signed {@code
numBytes}-byte integer always holds. */
+ private static int maxPrecisionForBytes(int numBytes) {
+ return (int) Math.round(Math.floor(Math.log10(Math.pow(2, 8 * numBytes -
1) - 1)));
+ }
+
+ /**
+ * The element type of a parquet LIST group under Spark's list rules
+ * (ParquetToSparkSchemaConverter.isElementType), or null for a malformed
list. Applies only to the
+ * typed_value list inside a variant; HoodieVariantReconstruction's planner
walks the lists around
+ * a variant column under parquet-avro's rules instead, so the two are not
meant to agree.
+ */
+ private static Type listElementType(GroupType list) {
+ if (list.getFieldCount() != 1 ||
!list.getType(0).isRepetition(Type.Repetition.REPEATED)) {
+ return null;
+ }
+ Type repeated = list.getType(0);
+ boolean repeatedIsElement = repeated.isPrimitive()
+ || repeated.asGroupType().getFieldCount() > 1
+ || repeated.getName().equals("array")
+ || repeated.getName().equals(list.getName() + "_tuple");
+ return repeatedIsElement ? repeated : repeated.asGroupType().getType(0);
+ }
+
+ /** Whether Spark reads {@code type} as a struct: a group that is neither a
LIST nor a MAP. */
+ private static boolean isStructGroup(Type type) {
+ if (type == null || type.isPrimitive()) {
+ return false;
+ }
+ LogicalTypeAnnotation annotation = type.getLogicalTypeAnnotation();
+ return !(annotation instanceof
LogicalTypeAnnotation.ListLogicalTypeAnnotation
+ || annotation instanceof LogicalTypeAnnotation.MapLogicalTypeAnnotation
+ || annotation instanceof
LogicalTypeAnnotation.MapKeyValueTypeAnnotation);
+ }
+
+ /** Whether Spark reads {@code type} as BinaryType, which the value and
metadata fields must be. */
+ private static boolean isSparkBinary(Type type) {
+ if (!type.isPrimitive()) {
+ return false;
+ }
+ LogicalTypeAnnotation annotation = type.getLogicalTypeAnnotation();
+ switch (type.asPrimitiveType().getPrimitiveTypeName()) {
+ case BINARY:
+ return annotation == null || annotation instanceof
LogicalTypeAnnotation.BsonLogicalTypeAnnotation;
+ case FIXED_LEN_BYTE_ARRAY:
+ return annotation == null;
+ default:
+ return false;
}
}
+ private static HoodieException unsupportedLeaf(String columnPath, String
leafPath, PrimitiveType leaf) {
+ return new HoodieException("Cannot read shredded variant column '" +
columnPath + "': typed_value '"
+ + leafPath + "' has parquet type '" + leaf + "', which Spark 4 does
not support reading as a shredded "
+ + "variant either.");
+ }
+
+ private static HoodieException invalidShreddingLayout(String columnPath,
String path, Type type) {
+ return new HoodieException("Cannot read shredded variant column '" +
columnPath + "': '" + path
+ + "' is not a valid shredded variant layout (" + type + "); Spark 4
rejects it too.");
+ }
+
private static byte[] toByteArray(ByteBuffer buffer) {
if (buffer.hasArray() && buffer.position() == 0
&& buffer.arrayOffset() == 0
@@ -384,8 +713,7 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
if (scalarValue != null) {
- Schema tvSchema = unwrapNullable(tvField.schema());
- record.put(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD,
convertScalarToAvro(scalarValue, tvSchema));
+ record.put(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD,
convertScalarToAvro(scalarValue));
} else if (objectFields != null) {
Schema tvSchema = unwrapNullable(tvField.schema());
GenericRecord tvRecord = new GenericData.Record(tvSchema);
@@ -410,38 +738,11 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
/**
* Converts a scalar value from Spark's variant representation to an
Avro-compatible type.
- * Handles type widening (Byte/Short to Int/Long) and binary wrapping.
+ * avroTypeToScalarType only emits scalar types whose Spark values
(Boolean, Integer, Long, Float,
+ * Double, String, BigDecimal) Avro takes as is, except binary, which Avro
wants as a ByteBuffer.
*/
- private static Object convertScalarToAvro(Object value, Schema avroSchema)
{
- if (value instanceof byte[]) {
- return ByteBuffer.wrap((byte[]) value);
- }
- if (value instanceof UUID) {
- return value.toString();
- }
- // Widen integer types to match Avro schema expectations
- if (avroSchema.getType() == Schema.Type.INT) {
- if (value instanceof Byte) {
- return ((Byte) value).intValue();
- }
- if (value instanceof Short) {
- return ((Short) value).intValue();
- }
- }
- if (avroSchema.getType() == Schema.Type.LONG) {
- if (value instanceof Byte) {
- return ((Byte) value).longValue();
- }
- if (value instanceof Short) {
- return ((Short) value).longValue();
- }
- if (value instanceof Integer) {
- return ((Integer) value).longValue();
- }
- }
- // BigDecimal, Boolean, String, Integer, Long, Float, Double
- // are directly compatible with Avro's type system
- return value;
+ private static Object convertScalarToAvro(Object value) {
+ return value instanceof byte[] ? ByteBuffer.wrap((byte[]) value) : value;
}
}
@@ -562,15 +863,18 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
/**
* A shredded variant struct {@code {value, [metadata], typed_value}}. Maps
the Spark
* {@link VariantSchema} ordinals (variantIdx / topLevelMetadataIdx /
typedIdx) back to the named
- * Avro fields, and reads {@code typed_value} for scalar/object/array
reconstruction.
+ * Avro fields, and reads {@code typed_value} for scalar/object/array
reconstruction, decoding a
+ * scalar per its {@link LeafDecoding}.
*/
static final class AvroVariantRow extends BaseAvroShreddedRow {
private final GenericRecord record;
private final VariantSchema schema;
+ private final Map<VariantSchema, LeafDecoding> decodings;
- AvroVariantRow(GenericRecord record, VariantSchema schema) {
+ AvroVariantRow(GenericRecord record, VariantSchema schema,
Map<VariantSchema, LeafDecoding> decodings) {
this.record = record;
this.schema = schema;
+ this.decodings = decodings;
}
private String fieldNameFor(int ordinal) {
@@ -591,7 +895,7 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
@Override public byte[] getBinary(int ordinal) {
- return toByteArray((ByteBuffer) record.get(fieldNameFor(ordinal)));
+ return bytesOf(record.get(fieldNameFor(ordinal)));
}
// The scalar/struct/array getters below ignore `ordinal` and read
typed_value directly via
@@ -602,6 +906,10 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
return record.get(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD);
}
+ private LeafDecoding decoding() {
+ return decodings.get(schema);
+ }
+
@Override public boolean getBoolean(int ordinal) {
return (Boolean) typedValue();
}
@@ -619,7 +927,16 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
@Override public long getLong(int ordinal) {
- return ((Number) typedValue()).longValue();
+ Object value = typedValue();
+ switch (decoding()) {
+ case UNSIGNED_INT32:
+ return Integer.toUnsignedLong((Integer) value);
+ case TIMESTAMP_MILLIS:
+ // Spark's DateTimeUtils.millisToMicros.
+ return Math.multiplyExact((Long) value, 1000L);
+ default:
+ return ((Number) value).longValue();
+ }
}
@Override public float getFloat(int ordinal) {
@@ -631,37 +948,50 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
@Override public String getString(int ordinal) {
- return typedValue().toString();
- }
-
- @Override public UUID getUuid(int ordinal) {
- return UUID.fromString(typedValue().toString());
+ Object value = typedValue();
+ return decoding() == LeafDecoding.JSON
+ ? new String(bytesOf(value), StandardCharsets.UTF_8)
+ : value.toString();
}
@Override public BigDecimal getDecimal(int ordinal, int precision, int
scale) {
Object value = typedValue();
- if (value instanceof BigDecimal) {
- return (BigDecimal) value;
- }
- Schema tvSchema =
unwrapNullable(record.getSchema().getField(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD).schema());
- Conversions.DecimalConversion conversion = new
Conversions.DecimalConversion();
- if (value instanceof ByteBuffer) {
- return conversion.fromBytes((ByteBuffer) value, tvSchema,
tvSchema.getLogicalType());
- }
- if (value instanceof GenericFixed) {
- return conversion.fromFixed((GenericFixed) value, tvSchema,
tvSchema.getLogicalType());
+ switch (decoding()) {
+ case UNSIGNED_INT64:
+ return new BigDecimal(Long.toUnsignedString((Long) value));
+ case DECIMAL_UNSCALED:
+ return BigDecimal.valueOf(((Number) value).longValue(), scale);
+ case DECIMAL_BYTES:
+ return value instanceof BigDecimal
+ ? (BigDecimal) value
+ : new BigDecimal(new BigInteger(bytesOf(value)), scale);
+ default:
+ throw new IllegalStateException("Unexpected decimal decoding: " +
decoding());
}
- throw new IllegalStateException("Unexpected decimal representation: " +
value);
}
@Override public ShreddingUtils.ShreddedRow getStruct(int ordinal, int
numFields) {
// Object shredding: typed_value is a record whose fields are the
shredded object fields.
- return new AvroObjectRow((GenericRecord) typedValue(), schema);
+ return new AvroObjectRow((GenericRecord) typedValue(), schema,
decodings);
}
@Override public ShreddingUtils.ShreddedRow getArray(int ordinal) {
- return new AvroArrayRow((List<?>) typedValue(), schema.arraySchema);
+ return new AvroArrayRow((List<?>) typedValue(), schema.arraySchema,
decodings);
+ }
+ }
+
+ /**
+ * The bytes of a binary Avro value: a ByteBuffer for parquet BINARY, a
GenericFixed for
+ * FIXED_LEN_BYTE_ARRAY.
+ */
+ private static byte[] bytesOf(Object value) {
+ if (value instanceof ByteBuffer) {
+ return toByteArray((ByteBuffer) value);
+ }
+ if (value instanceof GenericFixed) {
+ return ((GenericFixed) value).bytes();
}
+ throw new IllegalStateException("Unexpected binary representation: " +
value);
}
/**
@@ -671,10 +1001,13 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
static final class AvroObjectRow extends BaseAvroShreddedRow {
private final GenericRecord typedValueRecord;
private final VariantSchema parentSchema;
+ private final Map<VariantSchema, LeafDecoding> decodings;
- AvroObjectRow(GenericRecord typedValueRecord, VariantSchema parentSchema) {
+ AvroObjectRow(GenericRecord typedValueRecord, VariantSchema parentSchema,
+ Map<VariantSchema, LeafDecoding> decodings) {
this.typedValueRecord = typedValueRecord;
this.parentSchema = parentSchema;
+ this.decodings = decodings;
}
@Override public boolean isNullAt(int ordinal) {
@@ -683,7 +1016,7 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
@Override public ShreddingUtils.ShreddedRow getStruct(int ordinal, int
numFields) {
VariantSchema.ObjectField field = parentSchema.objectSchema[ordinal];
- return new AvroVariantRow((GenericRecord)
typedValueRecord.get(field.fieldName), field.schema);
+ return new AvroVariantRow((GenericRecord)
typedValueRecord.get(field.fieldName), field.schema, decodings);
}
}
@@ -694,10 +1027,12 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
static final class AvroArrayRow extends BaseAvroShreddedRow {
private final List<?> elements;
private final VariantSchema elementSchema;
+ private final Map<VariantSchema, LeafDecoding> decodings;
- AvroArrayRow(List<?> elements, VariantSchema elementSchema) {
+ AvroArrayRow(List<?> elements, VariantSchema elementSchema,
Map<VariantSchema, LeafDecoding> decodings) {
this.elements = elements;
this.elementSchema = elementSchema;
+ this.decodings = decodings;
}
@Override public int numElements() {
@@ -709,7 +1044,7 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
}
@Override public ShreddingUtils.ShreddedRow getStruct(int ordinal, int
numFields) {
- return new AvroVariantRow((GenericRecord) elements.get(ordinal),
elementSchema);
+ return new AvroVariantRow((GenericRecord) elements.get(ordinal),
elementSchema, decodings);
}
}
-}
\ No newline at end of file
+}
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 3f86fe893524..b8b1d2d177de 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
@@ -30,6 +30,7 @@ import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.collection.ClosableIterator;
import org.apache.hudi.core.io.storage.HoodieAvroBootstrapFileReader;
import org.apache.hudi.core.io.storage.HoodieFileReader;
+import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.storage.HoodieStorage;
import org.apache.hudi.storage.StoragePath;
import org.apache.hudi.variant.Spark4VariantShreddingProvider;
@@ -37,19 +38,34 @@ import
org.apache.hudi.variant.Spark4VariantShreddingProvider;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.generic.IndexedRecord;
+import org.apache.hadoop.conf.Configuration;
import org.apache.parquet.avro.AvroParquetWriter;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.io.api.Binary;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
import org.apache.spark.types.variant.Variant;
import org.apache.spark.types.variant.VariantBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import java.io.IOException;
+import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.time.ZoneOffset;
import java.util.Arrays;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import static
org.apache.parquet.avro.HoodieAvroParquetSchemaConverter.getAvroSchemaConverter;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -68,6 +84,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* InternalRow reader), so this is the only place the alignment is exercised
end to end.
* The null/throw guards of {@code create} are covered by {@code
TestHoodieVariantReconstruction}
* (hudi-hadoop-common); the provider's shred/rebuild in isolation by {@code
TestSpark4VariantShreddingProvider}.
+ * Files with typed_value encodings Hudi's writer never produces are read
through {@link HoodieAvroParquetReader},
+ * which hands the reconstruction the footer's parquet type.
*/
class TestHoodieVariantReconstructionRoundTrip {
@@ -89,7 +107,9 @@ class TestHoodieVariantReconstructionRoundTrip {
HoodieStorage storage = HoodieTestUtils.getStorage(tmp.toString()); //
allow.reading.shredded defaults true
HoodieVariantReconstruction reconstruction =
- HoodieVariantReconstruction.create(fileSchema, requestedSchema,
storage);
+ HoodieVariantReconstruction.create(fileSchema,
+
getAvroSchemaConverter(storage.getConf().unwrapAs(Configuration.class)).convert(fileSchema),
+ requestedSchema, storage);
assertNotNull(reconstruction, "create should build a reconstruction for a
shredded variant column");
// Build the input the parquet-avro reader would produce: a record at the
intermediate (shredded)
@@ -120,8 +140,8 @@ class TestHoodieVariantReconstructionRoundTrip {
@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
+ // pins that Hudi detects the resulting two-field group, but its stub
provider ignores the parquet
+ // group, so this is the only place the real provider sees the shape:
buildReadVariantSchema 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<>();
@@ -157,7 +177,9 @@ class TestHoodieVariantReconstructionRoundTrip {
HoodieStorage storage = HoodieTestUtils.getStorage(tmp.toString()); //
allow.reading.shredded defaults true
HoodieVariantReconstruction reconstruction =
- HoodieVariantReconstruction.create(fileSchema, requestedSchema,
storage);
+ HoodieVariantReconstruction.create(fileSchema,
+
getAvroSchemaConverter(storage.getConf().unwrapAs(Configuration.class)).convert(fileSchema),
+ requestedSchema, storage);
assertNotNull(reconstruction, "a shredded group with no value column must
still engage");
GenericRecord valueLessV = new GenericData.Record(
@@ -237,6 +259,105 @@ class TestHoodieVariantReconstructionRoundTrip {
reader.close();
}
+ private static final String VARIANT_TEST_NAMESPACE = "org.apache.hudi.test";
+
+ private static Variant scalar(Consumer<VariantBuilder> append) {
+ VariantBuilder builder = new VariantBuilder(false);
+ append.accept(builder);
+ return builder.result();
+ }
+
+ /** A required shredded variant group {@code column} with the given optional
typed_value declaration. */
+ private static String shreddedGroup(String column, String typedValue) {
+ return " required group " + column + " { required binary metadata;
optional binary value; optional "
+ + typedValue + "; }";
+ }
+
+ private static StoragePath writeParquet(Path tmp, MessageType schema, Group
row) throws IOException {
+ Path file = tmp.resolve("shredded.parquet");
+ try (ParquetWriter<Group> writer =
+ ExampleParquetWriter.builder(new
org.apache.hadoop.fs.Path(file.toString())).withType(schema).build()) {
+ writer.write(row);
+ }
+ return new StoragePath(file.toUri().toString());
+ }
+
+ @Test
+ void readerRebuildsTypedValueEncodingsTheFooterConversionLoses(@TempDir Path
tmp) throws Exception {
+ // A file from another writer whose typed_value leaves the footer's Avro
conversion reads as a plain
+ // int, long, fixed and bytes. The reader decodes each leaf by its parquet
type, as Spark 4 does.
+ String json = "{\"a\":[1,2]}";
+ byte[] bytes = {1, 2, 3, (byte) 0xFF};
+ long millis = 1_700_000_000_123L;
+ MessageType fileSchema = MessageTypeParser.parseMessageType("message r {"
+ + shreddedGroup("v_dec", "int32 typed_value (DECIMAL(9,2))")
+ + shreddedGroup("v_ts", "int64 typed_value (TIMESTAMP(MILLIS,true))")
+ + shreddedGroup("v_fixed", "fixed_len_byte_array(4) typed_value")
+ + shreddedGroup("v_u64", "int64 typed_value (INTEGER(64,false))")
+ + shreddedGroup("v_json", "binary typed_value (JSON)") + " }");
+ Binary metadata =
Binary.fromConstantByteArray(scalar(VariantBuilder::appendNull).getMetadata());
+ Group row = new SimpleGroupFactory(fileSchema).newGroup();
+ row.addGroup("v_dec").append(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
metadata)
+ .append(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD, 150);
+ row.addGroup("v_ts").append(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
metadata)
+ .append(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD, millis);
+
row.addGroup("v_fixed").append(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
metadata)
+ .append(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD,
Binary.fromConstantByteArray(bytes));
+ row.addGroup("v_u64").append(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
metadata)
+ .append(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD, -1L);
+ row.addGroup("v_json").append(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
metadata)
+ .append(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD, json);
+
+ Map<String, Variant> expected = new LinkedHashMap<>();
+ expected.put("v_dec", scalar(b -> b.appendDecimal(new
BigDecimal("1.50"))));
+ expected.put("v_ts", scalar(b -> b.appendTimestamp(millis * 1000)));
+ expected.put("v_fixed", scalar(b -> b.appendBinary(bytes)));
+ expected.put("v_u64", scalar(b -> b.appendDecimal(new
BigDecimal("18446744073709551615"))));
+ expected.put("v_json", scalar(b -> b.appendString(json)));
+ HoodieSchema requestedSchema = HoodieSchema.createRecord("r",
VARIANT_TEST_NAMESPACE, null,
+ expected.keySet().stream()
+ .map(name -> HoodieSchemaField.of(name,
HoodieSchema.createVariant(name, VARIANT_TEST_NAMESPACE, null)))
+ .collect(Collectors.toList()));
+
+ HoodieStorage storage = HoodieTestUtils.getStorage(tmp.toString());
+ try (HoodieAvroParquetReader reader = new HoodieAvroParquetReader(storage,
writeParquet(tmp, fileSchema, row));
+ ClosableIterator<IndexedRecord> records =
reader.getIndexedRecordIterator(requestedSchema, requestedSchema)) {
+ GenericRecord out = (GenericRecord) records.next();
+ for (Map.Entry<String, Variant> column : expected.entrySet()) {
+ GenericRecord rebuiltV = (GenericRecord) out.get(column.getKey());
+ Variant rebuilt = new Variant(
+ toBytes(rebuiltV.get(HoodieSchema.Variant.VARIANT_VALUE_FIELD)),
+
toBytes(rebuiltV.get(HoodieSchema.Variant.VARIANT_METADATA_FIELD)));
+ assertEquals(column.getValue().toJson(ZoneOffset.UTC),
rebuilt.toJson(ZoneOffset.UTC), column.getKey());
+ // The binary also pins the variant type (e.g. decimal vs long), which
the json can hide.
+ assertArrayEquals(column.getValue().getValue(), rebuilt.getValue(),
column.getKey());
+ }
+ }
+ }
+
+ @Test
+ void readerFailsOnOpenForTypedValueEncodingSparkRejects(@TempDir Path tmp)
throws Exception {
+ // Spark 4 rejects a TIMESTAMP(NANOS) typed_value when it opens the file,
whatever the rows hold. So
+ // does the reader, here with every typed_value null and the variant kept
in the residual value.
+ MessageType fileSchema = MessageTypeParser.parseMessageType("message r {"
+ + shreddedGroup("v", "int64 typed_value (TIMESTAMP(NANOS,true))") + "
}");
+ Variant variant = scalar(b -> b.appendLong(7));
+ Group row = new SimpleGroupFactory(fileSchema).newGroup();
+ row.addGroup("v")
+ .append(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
Binary.fromConstantByteArray(variant.getMetadata()))
+ .append(HoodieSchema.Variant.VARIANT_VALUE_FIELD,
Binary.fromConstantByteArray(variant.getValue()));
+ HoodieSchema requestedSchema = HoodieSchema.createRecord("r",
VARIANT_TEST_NAMESPACE, null,
+ Collections.singletonList(HoodieSchemaField.of("v",
HoodieSchema.createVariant())));
+
+ HoodieStorage storage = HoodieTestUtils.getStorage(tmp.toString());
+ try (HoodieAvroParquetReader reader = new HoodieAvroParquetReader(storage,
writeParquet(tmp, fileSchema, row))) {
+ HoodieException e = assertThrows(HoodieException.class,
+ () -> reader.getIndexedRecordIterator(requestedSchema,
requestedSchema));
+ assertTrue(e.getMessage().contains("column 'v'") &&
e.getMessage().contains("TIMESTAMP(NANOS,true)"),
+ e.getMessage());
+ }
+ }
+
private static byte[] toBytes(Object byteBuffer) {
ByteBuffer buf = ((ByteBuffer) byteBuffer).duplicate();
byte[] out = new byte[buf.remaining()];
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
b/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
index 70f893d3c06d..2a4cfe787299 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
+++
b/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
@@ -20,6 +20,7 @@
package org.apache.hudi.variant;
import org.apache.hudi.common.avro.ConvertingGenericData;
+import org.apache.hudi.common.avro.VariantShreddingProvider;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.schema.HoodieSchemaType;
@@ -30,6 +31,16 @@ import org.apache.avro.LogicalTypes;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
+import org.apache.avro.util.Utf8;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.parquet.avro.AvroSchemaConverterWithTimestampNTZ;
+import org.apache.parquet.schema.GroupType;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName;
+import org.apache.parquet.schema.Type;
import org.apache.spark.types.variant.Variant;
import org.apache.spark.types.variant.VariantBuilder;
import org.junit.jupiter.api.Test;
@@ -41,14 +52,36 @@ import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.time.ZoneOffset;
import java.util.Arrays;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.hudi.common.avro.HoodieAvroUtils.unwrapNullable;
import static
org.apache.hudi.common.schema.HoodieSchema.Variant.VARIANT_METADATA_FIELD;
import static
org.apache.hudi.common.schema.HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD;
import static
org.apache.hudi.common.schema.HoodieSchema.Variant.VARIANT_VALUE_FIELD;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit.MICROS;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit.MILLIS;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit.NANOS;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.bsonType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.decimalType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.enumType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.float16Type;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.intType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.intervalType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.jsonType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.timeType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.timestampType;
+import static org.apache.parquet.schema.LogicalTypeAnnotation.uuidType;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
+import static
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT96;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -58,12 +91,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Round-trip and behavior-pinning coverage for {@link
Spark4VariantShreddingProvider}: shred an
* unshredded variant, then reconstruct it, asserting both the intermediate
shredded schema/values and
- * the reconstructed variant. This exercises {@code shredVariantRecord},
{@code rebuildVariantRecord},
- * {@code avroTypeToScalarType}, {@code convertScalarToAvro}, and the
+ * the reconstructed variant. This exercises {@code shredVariantRecord},
{@code avroTypeToScalarType},
+ * {@code convertScalarToAvro}, {@code prepareRebuild} (the read schema built
from the file's parquet
+ * type, with each scalar leaf's {@code LeafDecoding}), and the
* {@code AvroVariantRow}/{@code AvroObjectRow}/{@code AvroArrayRow} accessors
across every reachable
* scalar leaf type, object/array shapes, partial (residual) shredding, and
the null/error guards - the
* AVRO read-path reconstruction that the Spark MOR SQL test cannot reach
(Spark compaction reads base
- * files via the InternalRow reader, not HoodieAvroParquetReader).
+ * files via the InternalRow reader, not HoodieAvroParquetReader). Parquet
encodings Hudi's writer never
+ * produces are read straight from a parquet group and pinned against what
Spark 4's reader returns.
*/
class TestSpark4VariantShreddingProvider {
@@ -87,9 +122,19 @@ class TestSpark4VariantShreddingProvider {
return record;
}
+ /** Prepares the rebuild of {@code shredded} from the parquet group it is
written as. */
+ private VariantShreddingProvider.VariantRebuilder
rebuilder(HoodieSchema.Variant shredded) {
+ HoodieSchema record = HoodieSchema.createRecord("r",
"org.apache.hudi.test", null,
+ Collections.singletonList(HoodieSchemaField.of("v", shredded)));
+ GroupType parquetType = new
AvroSchemaConverterWithTimestampNTZ().convert(record).getType("v").asGroupType();
+ return provider.prepareRebuild("v", parquetType, unshreddedSchema);
+ }
+
private Variant rebuild(GenericRecord shreddedRecord, HoodieSchema.Variant
shredded) {
- GenericRecord rebuilt =
- provider.rebuildVariantRecord(shreddedRecord,
shredded.getAvroSchema(), unshreddedSchema);
+ return toVariant(rebuilder(shredded).rebuild(shreddedRecord));
+ }
+
+ private static Variant toVariant(GenericRecord rebuilt) {
return new Variant(toBytes(rebuilt.get(VARIANT_VALUE_FIELD)),
toBytes(rebuilt.get(VARIANT_METADATA_FIELD)));
}
@@ -162,14 +207,15 @@ class TestSpark4VariantShreddingProvider {
}
//
---------------------------------------------------------------------------
- // Scalar leaf types: one per reachable branch of avroTypeToScalarType /
convertScalarToAvro
- // (the Byte/Short widening arms in convertScalarToAvro are unreachable:
Avro has no byte/short).
+ // Scalar leaf types: one per branch of avroTypeToScalarType /
convertScalarToAvro, each read back
+ // from the parquet type Hudi's writer gives it.
//
---------------------------------------------------------------------------
@Test
void intLeafShredsToInteger() {
// An integral variant shreds into an INT typed_value as a boxed Integer
(Avro has no byte/short,
- // so avroTypeToScalarType only ever emits IntegralSize.INT here).
+ // so the write side only ever emits IntegralSize.INT here; the narrower
parquet INT(8) / INT(16)
+ // leaves only come from other writers, see
rebuildsLeafFromItsParquetEncodingLikeSpark).
assertScalarShredsTo(scalar(b -> b.appendLong(1000)),
HoodieSchema.create(HoodieSchemaType.INT), 1000);
}
@@ -207,19 +253,6 @@ class TestSpark4VariantShreddingProvider {
HoodieSchema.create(HoodieSchemaType.BYTES), ByteBuffer.wrap(payload));
}
- @Test
- void rebuildUuidFromStringLeaf() {
- // A uuid typed_value is rejected on write (see
rejectsTypedValueLeafSparkCannotShred) but stays
- // readable, so the shredded record is built directly rather than through
shred().
- UUID uuid = UUID.fromString("12345678-1234-1234-1234-123456789abc");
- Variant original = scalar(b -> b.appendUuid(uuid));
- HoodieSchema.Variant shredded = shreddedLeaf(HoodieSchema.createUUID());
- GenericRecord shreddedRecord = new
GenericData.Record(shredded.getAvroSchema());
- shreddedRecord.put(VARIANT_METADATA_FIELD,
ByteBuffer.wrap(original.getMetadata()));
- shreddedRecord.put(VARIANT_TYPED_VALUE_FIELD, uuid.toString());
- assertEquals(original.toJson(ZoneOffset.UTC), rebuild(shreddedRecord,
shredded).toJson(ZoneOffset.UTC));
- }
-
@Test
void dateShredsToDaysSinceEpoch() {
assertScalarShredsTo(scalar(b -> b.appendDate(19000)),
HoodieSchema.createDate(), 19000);
@@ -415,7 +448,188 @@ class TestSpark4VariantShreddingProvider {
GenericRecord shreddedRecord = shred(scalar(b -> b.appendDecimal(new
BigDecimal("1.00"))), shredded);
shreddedRecord.put(VARIANT_TYPED_VALUE_FIELD, "not-a-decimal");
assertThrows(IllegalStateException.class,
- () -> provider.rebuildVariantRecord(shreddedRecord,
shredded.getAvroSchema(), unshreddedSchema));
+ () -> rebuilder(shredded).rebuild(shreddedRecord));
+ }
+
+ //
---------------------------------------------------------------------------
+ // Reading a shredded file: each typed_value leaf is decoded by its parquet
type, as Spark 4's own
+ // reader does. The footer's Avro conversion drops the encodings that matter
here (unsigned and
+ // decimal INT32 / INT64, JSON, millisecond timestamps), so these leaves are
built straight from a
+ // parquet group, fed the value parquet-avro reads for them, and compared
with Spark's variant.
+ //
---------------------------------------------------------------------------
+
+ private static final byte[] EMPTY_METADATA =
scalar(VariantBuilder::appendNull).getMetadata();
+
+ /** A typed_value leaf, built directly because Types refuses some encodings
under test (e.g. DECIMAL(10, 0) on INT32). */
+ private static PrimitiveType leaf(PrimitiveTypeName type, int length,
LogicalTypeAnnotation annotation) {
+ return new PrimitiveType(Type.Repetition.OPTIONAL, type, length,
VARIANT_TYPED_VALUE_FIELD)
+ .withLogicalTypeAnnotation(annotation);
+ }
+
+ private static PrimitiveType leaf(PrimitiveTypeName type,
LogicalTypeAnnotation annotation) {
+ return leaf(type, 0, annotation);
+ }
+
+ /** A top-level shredded variant column {@code v} with a scalar typed_value.
*/
+ private static GroupType shreddedColumn(PrimitiveType typedValue) {
+ return new GroupType(Type.Repetition.OPTIONAL, "v",
+ new PrimitiveType(Type.Repetition.REQUIRED, BINARY,
VARIANT_METADATA_FIELD),
+ new PrimitiveType(Type.Repetition.OPTIONAL, BINARY,
VARIANT_VALUE_FIELD),
+ typedValue);
+ }
+
+ /** The variant column {@code v} declared by a parquet schema snippet. */
+ private static GroupType column(String group) {
+ return MessageTypeParser.parseMessageType("message r { " + group + "
}").getType("v").asGroupType();
+ }
+
+ /** The Avro schema HoodieAvroParquetReader reads {@code column} at: its
footer conversion, lists unwrapped. */
+ private static Schema avroReadSchema(GroupType column) {
+ Configuration conf = new Configuration(false);
+
conf.setBoolean(AvroSchemaConverterWithTimestampNTZ.ADD_LIST_ELEMENT_RECORDS,
false);
+ Schema record = new AvroSchemaConverterWithTimestampNTZ(conf).convert(new
MessageType("r", column)).toAvroSchema();
+ return unwrapNullable(record.getField(column.getName()).schema());
+ }
+
+ private static Schema fieldSchema(Schema record, String field) {
+ return unwrapNullable(record.getField(field).schema());
+ }
+
+ private static GenericRecord record(Schema schema, String field, Object
value) {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put(field, value);
+ return record;
+ }
+
+ private static void assertSameVariant(Variant expected, Variant actual) {
+ assertEquals(expected.toJson(ZoneOffset.UTC),
actual.toJson(ZoneOffset.UTC));
+ // The binary also pins the variant type, which the json can hide.
+ assertArrayEquals(expected.getValue(), actual.getValue(), "variant type or
encoding differs");
+ }
+
+ private static Stream<Arguments> typedValueEncodingsSparkReads() {
+ byte[] bytes = {1, 2, 3, (byte) 0xFF};
+ String json = "{\"a\":[1,2]}";
+ long millis = 1_700_000_000_123L;
+ BigDecimal negative = new BigDecimal("-123.45");
+ Schema fixed5 = Schema.createFixed(VARIANT_TYPED_VALUE_FIELD, null, null,
5);
+ return Stream.of(
+ // Narrow and unsigned ints widen to the next signed size; INT(64,
false) becomes DECIMAL(20, 0).
+ Arguments.of(leaf(INT32, intType(8, true)), -5, scalar(b ->
b.appendLong(-5))),
+ Arguments.of(leaf(INT32, intType(16, false)), 60000, scalar(b ->
b.appendLong(60000))),
+ Arguments.of(leaf(INT32, intType(32, false)), -1, scalar(b ->
b.appendLong(4294967295L))),
+ Arguments.of(leaf(INT64, intType(64, false)), -1L,
+ scalar(b -> b.appendDecimal(new
BigDecimal("18446744073709551615")))),
+ // A decimal on INT32 / INT64 arrives as its unscaled value.
+ Arguments.of(leaf(INT32, decimalType(2, 9)), 150, scalar(b ->
b.appendDecimal(new BigDecimal("1.50")))),
+ Arguments.of(leaf(INT32, decimalType(2, 9)), -12345, scalar(b ->
b.appendDecimal(negative))),
+ Arguments.of(leaf(INT64, decimalType(4, 18)), 123456789012345678L,
+ scalar(b -> b.appendDecimal(new
BigDecimal("12345678901234.5678")))),
+ Arguments.of(leaf(FIXED_LEN_BYTE_ARRAY, 5, decimalType(2, 10)),
+ new Conversions.DecimalConversion().toFixed(negative, fixed5,
LogicalTypes.decimal(10, 2)),
+ scalar(b -> b.appendDecimal(negative))),
+ // Millisecond timestamps scale to micros and keep the UTC / local
distinction.
+ Arguments.of(leaf(INT64, timestampType(true, MILLIS)), millis,
scalar(b -> b.appendTimestamp(millis * 1000))),
+ Arguments.of(leaf(INT64, timestampType(false, MILLIS)), millis,
scalar(b -> b.appendTimestampNtz(millis * 1000))),
+ // JSON and ENUM read as strings; BSON and a plain
FIXED_LEN_BYTE_ARRAY as binary.
+ Arguments.of(leaf(BINARY, jsonType()),
ByteBuffer.wrap(json.getBytes(UTF_8)), scalar(b -> b.appendString(json))),
+ Arguments.of(leaf(BINARY, enumType()), new Utf8("A"), scalar(b ->
b.appendString("A"))),
+ Arguments.of(leaf(BINARY, bsonType()), ByteBuffer.wrap(bytes),
scalar(b -> b.appendBinary(bytes))),
+ Arguments.of(leaf(FIXED_LEN_BYTE_ARRAY, 4, null),
+ new
GenericData.Fixed(Schema.createFixed(VARIANT_TYPED_VALUE_FIELD, null, null, 4),
bytes),
+ scalar(b -> b.appendBinary(bytes))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("typedValueEncodingsSparkReads")
+ void rebuildsLeafFromItsParquetEncodingLikeSpark(PrimitiveType leaf, Object
typedValue, Variant expected) {
+ GroupType column = shreddedColumn(leaf);
+ GenericRecord shreddedRecord = record(avroReadSchema(column),
VARIANT_TYPED_VALUE_FIELD, typedValue);
+ shreddedRecord.put(VARIANT_METADATA_FIELD,
ByteBuffer.wrap(EMPTY_METADATA));
+ assertSameVariant(expected, toVariant(provider.prepareRebuild("v", column,
unshreddedSchema).rebuild(shreddedRecord)));
+ }
+
+ private static Stream<Arguments> typedValueEncodingsSparkRejects() {
+ String sparkRejects = "which Spark 4 does not support reading";
+ return Stream.of(
+ Arguments.of(leaf(INT32, timeType(true, MILLIS)), sparkRejects),
+ Arguments.of(leaf(INT64, timeType(true, MICROS)), sparkRejects),
+ Arguments.of(leaf(INT64, timeType(false, MICROS)), sparkRejects),
+ Arguments.of(leaf(INT64, timeType(true, NANOS)), sparkRejects),
+ Arguments.of(leaf(INT64, timestampType(true, NANOS)), sparkRejects),
+ Arguments.of(leaf(INT64, timestampType(false, NANOS)), sparkRejects),
+ Arguments.of(leaf(FIXED_LEN_BYTE_ARRAY, 2, float16Type()),
sparkRejects),
+ Arguments.of(leaf(FIXED_LEN_BYTE_ARRAY, 16, uuidType()), sparkRejects),
+ Arguments.of(leaf(FIXED_LEN_BYTE_ARRAY, 12, intervalType()),
sparkRejects),
+ Arguments.of(leaf(INT32, decimalType(0, 10)), sparkRejects),
+ Arguments.of(leaf(INT64, decimalType(0, 19)), sparkRejects),
+ Arguments.of(leaf(INT96, null), "INT96 typed_value is not supported by
Hudi's Avro reader"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("typedValueEncodingsSparkRejects")
+ void rejectsLeafSparkCannotReadWhenPrepared(PrimitiveType leaf, String
reason) {
+ // Fails when the file is opened, before any record, naming the column and
the parquet type.
+ HoodieException e = assertThrows(HoodieException.class,
+ () -> provider.prepareRebuild("v", shreddedColumn(leaf),
unshreddedSchema));
+ String message = e.getMessage();
+ assertTrue(message.contains("column 'v'") && message.contains("'" + leaf +
"'") && message.contains(reason), message);
+ }
+
+ @Test
+ void rebuildsAndRejectsNestedLeavesByTheirParquetEncoding() throws Exception
{
+ // DECIMAL(9, 2) on INT32 as an object field and as the element of a
3-level list.
+ GroupType column = column("optional group v { required binary metadata;
optional binary value;"
+ + " optional group typed_value {"
+ + " required group price { optional binary value; optional int32
typed_value (DECIMAL(9,2)); }"
+ + " required group history { optional binary value; optional group
typed_value (LIST) {"
+ + " repeated group list { required group element {"
+ + " optional binary value; optional int32 typed_value
(DECIMAL(9,2)); } } } } } }");
+ Variant expected =
VariantBuilder.parseJson("{\"price\":1.50,\"history\":[1.25,-0.75]}", false);
+
+ Schema v = avroReadSchema(column);
+ Schema typedValue = fieldSchema(v, VARIANT_TYPED_VALUE_FIELD);
+ Schema history = fieldSchema(typedValue, "history");
+ Schema element = unwrapNullable(fieldSchema(history,
VARIANT_TYPED_VALUE_FIELD).getElementType());
+ GenericRecord typedValueRecord = new GenericData.Record(typedValue);
+ typedValueRecord.put("price", record(fieldSchema(typedValue, "price"),
VARIANT_TYPED_VALUE_FIELD, 150));
+ typedValueRecord.put("history", record(history, VARIANT_TYPED_VALUE_FIELD,
Arrays.asList(
+ record(element, VARIANT_TYPED_VALUE_FIELD, 125), record(element,
VARIANT_TYPED_VALUE_FIELD, -75))));
+ GenericRecord shreddedRecord = record(v, VARIANT_TYPED_VALUE_FIELD,
typedValueRecord);
+ shreddedRecord.put(VARIANT_METADATA_FIELD,
ByteBuffer.wrap(expected.getMetadata()));
+ assertSameVariant(expected, toVariant(provider.prepareRebuild("v", column,
unshreddedSchema).rebuild(shreddedRecord)));
+
+ // A leaf Spark rejects below an object field: the error carries the
column's and the leaf's paths.
+ GroupType rejected = column("optional group v { required binary metadata;
optional binary value;"
+ + " optional group typed_value {"
+ + " required group t { optional binary value; optional int64
typed_value (TIME(MICROS,true)); } } }");
+ HoodieException e = assertThrows(HoodieException.class,
+ () -> provider.prepareRebuild("s.v", rejected, unshreddedSchema));
+ assertTrue(e.getMessage().contains("column 's.v'")
+ && e.getMessage().contains("typed_value
's.v.typed_value.t.typed_value'"), e.getMessage());
+ }
+
+ private static Stream<Arguments> invalidShreddingLayouts() {
+ String header = "optional group v { required binary metadata; optional
binary value;";
+ return Stream.of(
+ Arguments.of("unknown field in an object field group", header
+ + " optional group typed_value { required group a {"
+ + " optional binary value; optional int32 typed_value; optional
binary extra; } } }", "v.typed_value.a"),
+ Arguments.of("MAP typed_value", header
+ + " optional group typed_value (MAP) { repeated group key_value {
required binary key (STRING);"
+ + " required group value { optional binary value; optional int32
typed_value; } } } }", "v.typed_value"),
+ Arguments.of("LIST typed_value of non-struct elements", header
+ + " optional group typed_value (LIST) { repeated group list {
optional int32 element; } } }", "v.typed_value"),
+ Arguments.of("no top-level metadata",
+ "optional group v { optional binary value; optional int32
typed_value; }", "v"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidShreddingLayouts")
+ void rejectsInvalidShreddingLayoutWhenPrepared(String layout, String group,
String offendingPath) {
+ HoodieException e = assertThrows(HoodieException.class,
+ () -> provider.prepareRebuild("v", column(group), unshreddedSchema));
+ assertTrue(e.getMessage().contains("'" + offendingPath + "' is not a valid
shredded variant layout"), e.getMessage());
}
//
---------------------------------------------------------------------------
@@ -439,9 +653,9 @@ class TestSpark4VariantShreddingProvider {
@Test
void rebuildReturnsNullForNullRecord() {
// Coverage of a defensive guard only: the sole production caller
(HoodieVariantReconstruction)
- // already checks instanceof GenericRecord before calling
rebuildVariantRecord.
+ // already checks instanceof GenericRecord before calling the rebuilder.
HoodieSchema.Variant shredded =
shreddedLeaf(HoodieSchema.create(HoodieSchemaType.LONG));
- assertNull(provider.rebuildVariantRecord(null, shredded.getAvroSchema(),
unshreddedSchema));
+ assertNull(rebuilder(shredded).rebuild(null));
}
@Test
@@ -450,7 +664,7 @@ class TestSpark4VariantShreddingProvider {
GenericRecord shreddedRecord = shred(scalar(b -> b.appendLong(7)),
shredded);
shreddedRecord.put(VARIANT_METADATA_FIELD, null);
assertThrows(HoodieException.class,
- () -> provider.rebuildVariantRecord(shreddedRecord,
shredded.getAvroSchema(), unshreddedSchema));
+ () -> rebuilder(shredded).rebuild(shreddedRecord));
}
private static byte[] toBytes(Object byteBuffer) {