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 5dee63857193 fix(spark): reject variant leaves Spark 4 cannot shred
(#20326)
5dee63857193 is described below
commit 5dee63857193d19e5a61110ed6bbbbffe7955094
Author: voonhous <[email protected]>
AuthorDate: Thu Oct 15 19:29:53 2026 +0800
fix(spark): reject variant leaves Spark 4 cannot shred (#20326)
* fix(spark): decline shredding into fixed leaves
Spark4VariantShreddingProvider mapped an Avro fixed typed_value leaf
to Spark's BinaryType, so shredding handed parquet-avro a ByteBuffer
where a GenericFixed is required (ClassCastException at write) and the
fixed size was never checked. A uuid logical type on fixed(16) mapped
to UuidType, which converts to a String that a fixed cannot hold
(UnresolvedUnionException).
Spark's VariantSchema has no fixed-length binary type, so decline to
shred both leaves: avroTypeToScalarType returns null and the value
stays in the residual, as it already does for millisecond timestamps.
Decimal on fixed is unaffected because the decimal logical-type check
runs first.
Addresses the write side of #19442; the read-side plain FLBA case
is left open.
* fix(spark): reject unshreddable variant leaves
Match Spark 4's own variant shredding on the write path. Spark's
SparkShreddingUtils.buildVariantSchema only accepts Boolean, Byte,
Short, Int, Long, Float, Double, String, Binary, Date, Timestamp,
TimestampNTZ and Decimal scalars and throws
invalidVariantShreddingSchema for anything else; its inference keeps
UUIDs in the residual.
Spark4VariantShreddingProvider now throws a HoodieException when a
scalar typed_value is not the Avro equivalent of one of those types:
plain fixed, uuid on string or fixed, millisecond and nanosecond
timestamps, time types, enum and map. Previously these broke the write,
were silently declined, mapped time to an integer, or (uuid on string)
read back as a plain string.
The check is write-only. avroTypeToScalarType goes back to its master
mapping because the read path shares it, so read behavior is unchanged.
Hudi's shredding DDL and inference never produce these leaf types.
* refactor(spark): hoist shreddable scalar type list
Keep the error-message list of supported typed_value types in a
constant next to isSparkShreddableScalar so the two do not drift.
---
.../variant/Spark4VariantShreddingProvider.java | 51 ++++++++++++--
.../TestSpark4VariantShreddingProvider.java | 77 ++++++++++++++++------
2 files changed, 104 insertions(+), 24 deletions(-)
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 2c1e5f36df63..63a6e9d17588 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
@@ -176,7 +176,15 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
break;
default:
- // Scalar shredding
+ // 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)) {
+ LogicalType logicalType = tvSchema.getLogicalType();
+ String typeName = tvSchema.getType().getName()
+ + (logicalType == null ? "" : " (" + logicalType.getName() +
")");
+ throw new HoodieException("Variant shredding schema has a
typed_value of type " + typeName
+ + ", which Spark 4 cannot shred. Supported typed_value types:
" + SPARK_SHREDDABLE_SCALAR_TYPES + ".");
+ }
scalarSchema = avroTypeToScalarType(tvSchema);
break;
}
@@ -195,6 +203,41 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
return result;
}
+ /**
+ * The scalar {@code typed_value} types {@link #isSparkShreddableScalar}
accepts, for error messages.
+ * Keep in sync with that switch.
+ */
+ private static final String SPARK_SHREDDABLE_SCALAR_TYPES = "boolean, int,
long, float, double, string, bytes, "
+ + "decimal, date, timestamp-micros, local-timestamp-micros";
+
+ /**
+ * Whether a scalar {@code typed_value} is the Avro equivalent of a scalar
type Spark 4's
+ * {@code SparkShreddingUtils.buildVariantSchema} accepts.
+ */
+ private static boolean isSparkShreddableScalar(Schema schema) {
+ LogicalType logicalType = schema.getLogicalType();
+ String logicalName = logicalType == null ? null : logicalType.getName();
+ switch (schema.getType()) {
+ case BOOLEAN:
+ case FLOAT:
+ case DOUBLE:
+ case STRING: // a uuid string is not allowed
+ return logicalName == null;
+ case INT:
+ return logicalName == null || "date".equals(logicalName);
+ case LONG:
+ return logicalName == null
+ || "timestamp-micros".equals(logicalName)
+ || "local-timestamp-micros".equals(logicalName);
+ case BYTES:
+ return logicalName == null || "decimal".equals(logicalName);
+ case FIXED:
+ return "decimal".equals(logicalName);
+ default:
+ return false;
+ }
+ }
+
/**
* Maps an Avro {@link Schema} type (potentially with logical type
annotations)
* to a {@link VariantSchema.ScalarType}.
@@ -219,9 +262,9 @@ public class Spark4VariantShreddingProvider implements
VariantShreddingProvider
return new VariantSchema.TimestampNTZType();
}
// The Variant binary spec stores timestamps in microseconds, so a
millisecond-precision
- // typed_value cannot represent a variant timestamp. Decline to shred it
as a scalar (the value
- // stays in the residual unshredded binary) rather than mapping it to
the micros TimestampType,
- // which would silently scale the value by 1000x.
+ // 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;
}
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 edbf9f270420..70f893d3c06d 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
@@ -26,12 +26,16 @@ import org.apache.hudi.common.schema.HoodieSchemaType;
import org.apache.hudi.exception.HoodieException;
import org.apache.avro.Conversions;
+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.spark.types.variant.Variant;
import org.apache.spark.types.variant.VariantBuilder;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
@@ -40,6 +44,7 @@ import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
+import java.util.stream.Stream;
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;
@@ -203,9 +208,16 @@ class TestSpark4VariantShreddingProvider {
}
@Test
- void uuidShredsToString() {
+ 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");
- assertScalarShredsTo(scalar(b -> b.appendUuid(uuid)),
HoodieSchema.createUUID(), uuid.toString());
+ 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
@@ -228,6 +240,9 @@ class TestSpark4VariantShreddingProvider {
@Test
void decimalLeafShredsToBigDecimal() throws Exception {
assertScalarShredsTo("123.45", HoodieSchema.createDecimal(10, 2), new
BigDecimal("123.45"));
+ // Decimal-on-fixed still shreds: a fixed typed_value is rejected unless
it carries a decimal.
+ assertScalarShredsTo("123.45", HoodieSchema.createDecimal("dec_fixed",
"org.apache.hudi.test", null, 10, 2, 8),
+ new BigDecimal("123.45"));
}
@Test
@@ -243,16 +258,6 @@ class TestSpark4VariantShreddingProvider {
// "Decline to shred" fallbacks: value stays in the residual binary.
//
---------------------------------------------------------------------------
- @Test
- void microsTimestampIsNotShreddedIntoMillisLeaf() {
- // A millisecond-precision typed_value cannot represent a micros variant
timestamp, so
- // avroTypeToScalarType returns null and the value is left unshredded in
the residual.
- assertStaysInResidual(scalar(b ->
b.appendTimestamp(1_700_000_000_000_000L)),
- HoodieSchema.createTimestampMillis());
- assertStaysInResidual(scalar(b ->
b.appendTimestampNtz(1_700_000_000_000_000L)),
- HoodieSchema.createLocalTimestampMillis());
- }
-
@Test
void lossyDecimalIsNotShredded() {
// decimal(10,2) cannot represent 123.456 exactly, so the value must fall
to the residual.
@@ -260,20 +265,52 @@ class TestSpark4VariantShreddingProvider {
HoodieSchema.createDecimal(10, 2));
}
- @Test
- void enumLeafIsNotShredded() {
- // avroTypeToScalarType has no mapping for ENUM (the default: branch), so
the value declines
- // to the residual.
- assertStaysInResidual(scalar(b -> b.appendString("A")),
- HoodieSchema.createEnum("e", "org.apache.hudi.test", null,
Arrays.asList("A", "B")));
- }
-
@Test
void noTypedValueSchemaRoundTrips() throws Exception {
// A shredded schema without typed_value keeps the whole variant in the
residual value.
assertRoundTrips("{\"a\":1}", HoodieSchema.createVariantShredded(null));
}
+ //
---------------------------------------------------------------------------
+ // Leaf types Spark 4 cannot shred: rejected on write, as Spark's own
+ // SparkShreddingUtils.buildVariantSchema rejects them (GH 19442).
+ //
---------------------------------------------------------------------------
+
+ private static Stream<Arguments> typedValueLeavesSparkCannotShred() {
+ UUID uuid = UUID.fromString("12345678-1234-1234-1234-123456789abc");
+ long micros = 1_700_000_000_000_000L;
+ return Stream.of(
+ Arguments.of("fixed", HoodieSchema.createFixed("fixed4",
"org.apache.hudi.test", null, 4),
+ scalar(b -> b.appendBinary(new byte[] {1, 2, 3, 4}))),
+ Arguments.of("uuid on fixed", HoodieSchema.fromAvroSchema(
+ LogicalTypes.uuid().addToSchema(Schema.createFixed("uuid_fixed",
null, "org.apache.hudi.test", 16))),
+ scalar(b -> b.appendUuid(uuid))),
+ Arguments.of("uuid on string", HoodieSchema.createUUID(), scalar(b ->
b.appendUuid(uuid))),
+ Arguments.of("timestamp-millis", HoodieSchema.createTimestampMillis(),
+ scalar(b -> b.appendTimestamp(micros))),
+ Arguments.of("local-timestamp-millis",
HoodieSchema.createLocalTimestampMillis(),
+ scalar(b -> b.appendTimestampNtz(micros))),
+ Arguments.of("timestamp-nanos", HoodieSchema.fromAvroSchema(
+
LogicalTypes.timestampNanos().addToSchema(Schema.create(Schema.Type.LONG))),
+ scalar(b -> b.appendTimestamp(micros))),
+ Arguments.of("local-timestamp-nanos", HoodieSchema.fromAvroSchema(
+
LogicalTypes.localTimestampNanos().addToSchema(Schema.create(Schema.Type.LONG))),
+ scalar(b -> b.appendTimestampNtz(micros))),
+ Arguments.of("time-millis", HoodieSchema.createTimeMillis(), scalar(b
-> b.appendLong(1000))),
+ Arguments.of("time-micros", HoodieSchema.createTimeMicros(), scalar(b
-> b.appendLong(1000))),
+ Arguments.of("enum", HoodieSchema.createEnum("e",
"org.apache.hudi.test", null, Arrays.asList("A", "B")),
+ scalar(b -> b.appendString("A"))),
+ Arguments.of("map",
HoodieSchema.createMap(HoodieSchema.create(HoodieSchemaType.LONG)),
+ scalar(b -> b.appendLong(1))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("typedValueLeavesSparkCannotShred")
+ void rejectsTypedValueLeafSparkCannotShred(String leaf, HoodieSchema
typedValue, Variant variant) {
+ HoodieException e = assertThrows(HoodieException.class, () ->
shred(variant, shreddedLeaf(typedValue)));
+ assertTrue(e.getMessage().contains("Spark 4 cannot shred"), "unexpected
message: " + e.getMessage());
+ }
+
//
---------------------------------------------------------------------------
// Object and array shapes, including partial (residual) object shredding.
//
---------------------------------------------------------------------------