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 9a30ecd60e15 fix(spark): preserve the Avro fixed-size decimal width in 
the Spark row write support (#19512)
9a30ecd60e15 is described below

commit 9a30ecd60e1572fca61a32af00ea7bc27deef611
Author: Y Ethan Guo <[email protected]>
AuthorDate: Wed Aug 5 20:32:58 2026 -0700

    fix(spark): preserve the Avro fixed-size decimal width in the Spark row 
write support (#19512)
    
    * fix(spark): preserve the Avro fixed-size decimal width in the Spark row 
write support
    
    The Spark row writer sized decimal FIXED_LEN_BYTE_ARRAY columns from 
Decimal.minBytesForPrecision(), discarding an Avro fixed(N) size wider than the 
precision-minimal width and diverging from the Avro write path. Honor the 
declared fixed size from the resolved HoodieSchema at both the schema converter 
and the value writer, resolving the padding buffer once per column.
    
    * Guard against an Avro fixed size smaller than the precision-minimal 
decimal width
    
    Fail fast with a diagnosable message if a decimal fixed(N) declares fewer 
bytes than minBytesForPrecision, instead of an opaque negative-length 
Arrays.fill later.
    
    * Unit-test decimalFixedLen directly for hudi-spark-client coverage
    
    Make decimalFixedLen package-private and cover its branches (min-width 
fallback and honored Avro fixed size) from hudi-spark-client's own test module, 
where the full write support cannot be constructed.
    
    * Extract decimal sign-extension padding into a testable static helper
    
    Move the fixed-length padding out of the makeWriter lambda into 
padDecimalToFixedLength so it can be unit-tested directly from 
hudi-spark-client (covering the exact-width, positive-pad, and negative 
sign-extension cases), where the full write support cannot be constructed.
    
    * Trim redundant Javadocs on the decimal helpers
    
    Drop the padDecimalToFixedLength Javadoc and reduce decimalFixedLen's to a 
single line.
    
    * Rename decimalFixedLen to resolveDecimalByteLength and use assertFalse 
for the clustering check
    
    Address review nits: resolveDecimalByteLength reads more clearly at the 
call sites than decimalFixedLen, and assertFalse(isEmpty()) is more natural 
than assertTrue(!isEmpty()).
---
 .../storage/row/HoodieRowParquetWriteSupport.java  |  63 ++++++---
 .../row/TestHoodieRowParquetWriteSupport.java      |  35 +++++
 .../row/TestHoodieInternalRowParquetWriter.java    |  36 +++++
 .../TestHoodieSparkMergeOnReadTableCompaction.java | 155 +++++++++++++++++++++
 4 files changed, 268 insertions(+), 21 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java
index 42d80b7d279a..c14a75d314d6 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowParquetWriteSupport.java
@@ -482,6 +482,34 @@ public class HoodieRowParquetWriteSupport extends 
WriteSupport<InternalRow> {
     }
   }
 
+  /**
+   * Fixed-length byte width for a decimal column: honor the declared Avro 
{@code fixed} size when
+   * present (it may be wider than the precision-minimal width), otherwise the 
precision-minimal width.
+   */
+  static int resolveDecimalByteLength(HoodieSchema resolvedSchema, int 
precision) {
+    if (resolvedSchema instanceof HoodieSchema.Decimal) {
+      HoodieSchema.Decimal decimalSchema = (HoodieSchema.Decimal) 
resolvedSchema;
+      if (decimalSchema.isFixed()) {
+        int fixedSize = decimalSchema.getFixedSize();
+        ValidationUtils.checkArgument(fixedSize >= 
Decimal.minBytesForPrecision()[precision],
+            () -> String.format("Avro fixed size %s is too small for decimal 
precision %s (need >= %s bytes)",
+                fixedSize, precision, 
Decimal.minBytesForPrecision()[precision]));
+        return fixedSize;
+      }
+    }
+    return Decimal.minBytesForPrecision()[precision];
+  }
+
+  static byte[] padDecimalToFixedLength(byte[] unscaledBytes, int numBytes, 
byte[] paddingBuffer) {
+    if (unscaledBytes.length == numBytes) {
+      return unscaledBytes;
+    }
+    byte signByte = (unscaledBytes[0] < 0) ? (byte) -1 : (byte) 0;
+    Arrays.fill(paddingBuffer, 0, numBytes - unscaledBytes.length, signByte);
+    System.arraycopy(unscaledBytes, 0, paddingBuffer, numBytes - 
unscaledBytes.length, unscaledBytes.length);
+    return paddingBuffer;
+  }
+
   private ValueWriter makeWriter(HoodieSchema schema, DataType dataType) {
     HoodieSchema resolvedSchema = schema == null ? null : 
schema.getNonNullType();
 
@@ -550,28 +578,21 @@ public class HoodieRowParquetWriteSupport extends 
WriteSupport<InternalRow> {
         consumeGroup(() -> variantWriter.accept(row, ordinal));
       };
     } else if (dataType instanceof DecimalType) {
+      int precision = ((DecimalType) dataType).precision();
+      ValidationUtils.checkArgument(precision <= DecimalType.MAX_PRECISION(),
+          () -> String.format("Decimal precision %s exceeds max precision %s", 
precision, DecimalType.MAX_PRECISION()));
+      int scale = ((DecimalType) dataType).scale();
+      // Honor the declared Avro `fixed` size so the 
row/bulk-insert/clustering/compaction write
+      // path preserves the schema width instead of narrowing to the 
precision-minimal one.
+      int numBytes = resolveDecimalByteLength(resolvedSchema, precision);
+      // Padding buffer resolved once per column, not per record: reuse the 
shared decimalBuffer when
+      // it is wide enough, otherwise allocate one dedicated buffer here (an 
over-allocated Avro fixed
+      // size can exceed the shared buffer's capacity).
+      byte[] paddingBuffer = numBytes <= decimalBuffer.length ? decimalBuffer 
: new byte[numBytes];
       return (row, ordinal) -> {
-        int precision = ((DecimalType) dataType).precision();
-        ValidationUtils.checkArgument(precision <= DecimalType.MAX_PRECISION(),
-            () -> String.format("Decimal precision %s exceeds max precision 
%s", precision, DecimalType.MAX_PRECISION()));
-        int scale = ((DecimalType) dataType).scale();
         byte[] bytes = row.getDecimal(ordinal, precision, 
scale).toJavaBigDecimal().unscaledValue().toByteArray();
-        int numBytes = Decimal.minBytesForPrecision()[precision];
-        byte[] fixedLengthBytes;
-        if (bytes.length == numBytes) {
-          // If the length of the underlying byte array of the unscaled 
`BigInteger` happens to be
-          // `numBytes`, just reuse it, so that we don't bother copying it to 
`decimalBuffer`.
-          fixedLengthBytes = bytes;
-        } else {
-          // Otherwise, the length must be less than `numBytes`.  In this case 
we copy contents of
-          // the underlying bytes with padding sign bytes to `decimalBuffer` 
to form the result
-          // fixed-length byte array.
-          byte signByte = (bytes[0] < 0) ? (byte) -1 : (byte) 0;
-          Arrays.fill(decimalBuffer, 0, numBytes - bytes.length, signByte);
-          System.arraycopy(bytes, 0, decimalBuffer, numBytes - bytes.length, 
bytes.length);
-          fixedLengthBytes = decimalBuffer;
-        }
-        recordConsumer.addBinary(Binary.fromReusedByteArray(fixedLengthBytes, 
0, numBytes));
+        recordConsumer.addBinary(Binary.fromReusedByteArray(
+            padDecimalToFixedLength(bytes, numBytes, paddingBuffer), 0, 
numBytes));
       };
     } else if (dataType instanceof ArrayType
             && resolvedSchema != null
@@ -830,7 +851,7 @@ public class HoodieRowParquetWriteSupport extends 
WriteSupport<InternalRow> {
       return Types
           .primitive(FIXED_LEN_BYTE_ARRAY, repetition)
           .as(LogicalTypeAnnotation.decimalType(scale, precision))
-          .length(Decimal.minBytesForPrecision()[precision])
+          .length(resolveDecimalByteLength(resolvedSchema, precision))
           .named(structField.name());
     } else if (dataType instanceof ArrayType
             && resolvedSchema != null
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java
index 0c39e99bdf89..6ec4a4cd2300 100644
--- 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/row/TestHoodieRowParquetWriteSupport.java
@@ -18,16 +18,21 @@
 
 package org.apache.hudi.io.storage.row;
 
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaType;
 import org.apache.hudi.testutils.HoodieClientTestBase;
 
+import org.apache.spark.sql.types.Decimal;
 import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
 import java.util.List;
 import java.util.TimeZone;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
 
 /**
  * Coverage for {@link 
HoodieRowParquetWriteSupport#resolveSessionLocalTimeZone()}.
@@ -42,6 +47,36 @@ class TestHoodieRowParquetWriteSupport extends 
HoodieClientTestBase {
 
   private static final String SESSION_LOCAL_TIME_ZONE_KEY = 
"spark.sql.session.timeZone";
 
+  @Test
+  void testResolveDecimalByteLength() {
+    int minWidth = Decimal.minBytesForPrecision()[20];
+    // A non-decimal schema falls back to the precision-minimal width.
+    assertEquals(minWidth,
+        
HoodieRowParquetWriteSupport.resolveDecimalByteLength(HoodieSchema.create(HoodieSchemaType.STRING),
 20));
+    // A bytes-backed decimal (no declared fixed size) also falls back to the 
minimum.
+    assertEquals(minWidth,
+        
HoodieRowParquetWriteSupport.resolveDecimalByteLength(HoodieSchema.createDecimal(20,
 2), 20));
+    // An Avro fixed decimal wider than the minimum is honored.
+    assertEquals(10,
+        HoodieRowParquetWriteSupport.resolveDecimalByteLength(
+            HoodieSchema.createDecimal("dec", null, null, 20, 2, 10), 20));
+  }
+
+  @Test
+  void testPadDecimalToFixedLength() {
+    byte[] buffer = new byte[16];
+    // Already the full width: returned as-is, no copy into the buffer.
+    byte[] exact = new byte[] {1, 2, 3, 4};
+    assertSame(exact, 
HoodieRowParquetWriteSupport.padDecimalToFixedLength(exact, 4, buffer));
+    // Positive magnitude: left-padded with zero sign bytes.
+    byte[] positive = HoodieRowParquetWriteSupport.padDecimalToFixedLength(new 
byte[] {0x12, 0x34}, 4, buffer);
+    assertArrayEquals(new byte[] {0, 0, 0x12, 0x34}, Arrays.copyOf(positive, 
4));
+    // Negative magnitude: left-padded with 0xFF sign bytes.
+    byte[] negative = HoodieRowParquetWriteSupport.padDecimalToFixedLength(
+        new byte[] {(byte) 0xFF, (byte) 0x80}, 4, buffer);
+    assertArrayEquals(new byte[] {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 
(byte) 0x80}, Arrays.copyOf(negative, 4));
+  }
+
   @Test
   void testResolveSessionLocalTimeZoneWithoutOverride() {
     String expected = TimeZone.getDefault().getID();
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java
index c6719dac403b..04fd0e98324c 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/storage/row/TestHoodieInternalRowParquetWriter.java
@@ -36,12 +36,16 @@ import org.apache.hudi.testutils.SparkDatasetTestUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.parquet.hadoop.metadata.CompressionCodecName;
 import org.apache.parquet.hadoop.metadata.FileMetaData;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.sql.types.StructType;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
@@ -126,6 +130,38 @@ public class TestHoodieInternalRowParquetWriter extends 
HoodieSparkClientTestHar
     });
   }
 
+  @Test
+  void testDecimalFixedLenWidthFromAvroSchema() {
+    // The row-writer sizes decimal FIXED_LEN columns from the Avro schema: an 
Avro fixed(10) for
+    // decimal(20,2) keeps its declared width (10, wider than the 
precision-minimal 9), while a bytes
+    // decimal keeps the precision-minimal width (9).
+    assertEquals(10, decimalParquetTypeLength(decimalRecordSchema(
+        
"{\"type\":\"fixed\",\"name\":\"dec_fixed\",\"size\":10,\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}")),
+        "Avro fixed(10) decimal must stay FIXED_LEN_BYTE_ARRAY(10)");
+    assertEquals(9, decimalParquetTypeLength(decimalRecordSchema(
+        
"{\"type\":\"bytes\",\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}")),
+        "bytes decimal keeps the precision-minimal FIXED_LEN width (9)");
+  }
+
+  private static String decimalRecordSchema(String decType) {
+    return 
"{\"type\":\"record\",\"name\":\"rec\",\"fields\":[{\"name\":\"dec\",\"type\":" 
+ decType + "}]}";
+  }
+
+  private int decimalParquetTypeLength(String avroSchemaJson) {
+    StructType structType = new StructType().add("dec", 
DataTypes.createDecimalType(20, 2), false);
+    HoodieWriteConfig config = HoodieWriteConfig.newBuilder()
+        .withPath(basePath)
+        .withSchema(avroSchemaJson)
+        .build();
+    HoodieRowParquetWriteSupport writeSupport = 
HoodieRowParquetWriteSupport.getHoodieRowParquetWriteSupport(
+        storageConf.unwrap(), structType, Option.empty(), config);
+    MessageType parquetSchema = 
writeSupport.init(writeSupport.getHadoopConf()).getSchema();
+    PrimitiveType dec = parquetSchema.getType("dec").asPrimitiveType();
+    assertEquals(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, 
dec.getPrimitiveTypeName(),
+        "decimal must be encoded as FIXED_LEN_BYTE_ARRAY");
+    return dec.getTypeLength();
+  }
+
   private HoodieRowParquetWriteSupport 
getWriteSupport(HoodieWriteConfig.Builder writeConfigBuilder, Configuration 
hadoopConf, boolean parquetWriteLegacyFormatEnabled) {
     
writeConfigBuilder.withStorageConfig(HoodieStorageConfig.newBuilder().parquetWriteLegacyFormat(String.valueOf(parquetWriteLegacyFormatEnabled)).build());
     HoodieWriteConfig writeConfig = writeConfigBuilder.build();
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java
index be856ae3bb2c..9a190185e98b 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/functional/TestHoodieSparkMergeOnReadTableCompaction.java
@@ -28,19 +28,27 @@ import org.apache.hudi.common.config.HoodieMetadataConfig;
 import org.apache.hudi.common.config.HoodieStorageConfig;
 import org.apache.hudi.common.fs.FSUtils;
 import org.apache.hudi.common.model.DefaultHoodieRecordPayload;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieAvroRecord;
+import org.apache.hudi.common.model.HoodieBaseFile;
 import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
 import org.apache.hudi.common.model.HoodieKey;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.model.HoodieWriteStat;
+import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
 import org.apache.hudi.common.model.PartialUpdateAvroPayload;
 import org.apache.hudi.common.model.WriteConcurrencyMode;
+import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.TableSchemaResolver;
 import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
 import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
 import org.apache.hudi.common.util.CompactionUtils;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.ParquetUtils;
 import org.apache.hudi.config.HoodieCleanConfig;
+import org.apache.hudi.config.HoodieClusteringConfig;
 import org.apache.hudi.config.HoodieCompactionConfig;
 import org.apache.hudi.config.HoodieIndexConfig;
 import org.apache.hudi.config.HoodieLayoutConfig;
@@ -52,6 +60,8 @@ import org.apache.hudi.index.HoodieIndex;
 import org.apache.hudi.metadata.HoodieTableMetadata;
 import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.storage.StoragePathInfo;
+import org.apache.hudi.table.HoodieSparkTable;
+import org.apache.hudi.table.HoodieTable;
 import org.apache.hudi.table.action.HoodieWriteMetadata;
 import org.apache.hudi.table.action.commit.SparkBucketIndexPartitioner;
 import org.apache.hudi.table.action.rollback.RollbackUtils;
@@ -59,17 +69,28 @@ import org.apache.hudi.table.storage.HoodieStorageLayout;
 import org.apache.hudi.testutils.HoodieMergeOnReadTestUtils;
 import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
 
+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.GenericFixed;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
 import org.apache.spark.api.java.JavaRDD;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
+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.CsvSource;
 import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.IOException;
+import java.math.BigDecimal;
 import java.nio.file.Paths;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
@@ -81,6 +102,7 @@ import static 
org.apache.hudi.common.table.HoodieTableMetaClient.METAFOLDER_NAME
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
 import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -442,4 +464,137 @@ public class TestHoodieSparkMergeOnReadTableCompaction 
extends SparkClientFuncti
         client.commitStats(instant, writeStats, Option.empty(), 
metaClient.getCommitActionType());
     assertTrue(committed);
   }
+
+  // Avro fixed(10) decimal(20,2). 10 is wider than the precision-minimal 
width (9 for precision
+  // 20), so Spark's own DecimalType->Avro conversion would emit fixed(9); the 
declared 10 only
+  // survives if the write path honors the Avro fixed size. Do not derive this 
schema from a
+  // DataFrame, which would drop the fixed size.
+  private static final String FIXED10_DECIMAL_SCHEMA =
+      "{\"type\":\"record\",\"name\":\"decimalRec\",\"fields\":["
+          + "{\"name\":\"_row_key\",\"type\":\"string\"},"
+          + "{\"name\":\"partition_path\",\"type\":\"string\"},"
+          + "{\"name\":\"ts\",\"type\":\"long\"},"
+          + 
"{\"name\":\"dec\",\"type\":{\"type\":\"fixed\",\"name\":\"decFixed\",\"size\":10,"
+          + "\"logicalType\":\"decimal\",\"precision\":20,\"scale\":2}}]}";
+
+  private static final String DECIMAL_PARTITION = "p1";
+  private static final int EXPECTED_DECIMAL_FIXED_LEN = 10;
+
+  @Test
+  void testDecimalFixedWidthPreservedAfterCompactionAndClustering() throws 
Exception {
+    Properties props = getPropertiesForKeyGen(true);
+    Properties rowWriterProps = new Properties();
+    rowWriterProps.put("hoodie.datasource.write.row.writer.enable", "true");
+    HoodieWriteConfig config = HoodieWriteConfig.newBuilder()
+        .forTable("test-decimal-fixed")
+        .withPath(basePath())
+        .withSchema(FIXED10_DECIMAL_SCHEMA)
+        .withParallelism(2, 2)
+        .withPreCombineField("ts")
+        .withProperties(rowWriterProps)
+        .withCompactionConfig(HoodieCompactionConfig.newBuilder()
+            .withMaxNumDeltaCommitsBeforeCompaction(1)
+            .compactionSmallFileSize(0)
+            .withInlineCompaction(false)
+            .build())
+        .withClusteringConfig(HoodieClusteringConfig.newBuilder()
+            .withClusteringMaxNumGroups(10)
+            .withClusteringTargetPartitions(0)
+            .withInlineClustering(false)
+            .withInlineClusteringNumCommits(1)
+            .build())
+        .build();
+    props.putAll(config.getProps());
+
+    metaClient = getHoodieMetaClient(HoodieTableType.MERGE_ON_READ, props);
+    client = getHoodieWriteClient(config);
+
+    // two insert commits (small-file size 0 forces a fresh file group each) 
create two base-file
+    // groups, both written via the Avro path at fixed(10)
+    String instant1 = WriteClientTestUtils.createNewInstantTime();
+    writeData(instant1, buildDecimalRecords(0, 10, 1L, new 
BigDecimal("123456789.12")), true);
+    String instant2 = WriteClientTestUtils.createNewInstantTime();
+    writeData(instant2, buildDecimalRecords(10, 10, 1L, new 
BigDecimal("223456789.34")), true);
+    // update every key so both groups accumulate log files for compaction to 
merge
+    String instant3 = WriteClientTestUtils.createNewInstantTime();
+    writeData(instant3, buildDecimalRecords(0, 20, 2L, new 
BigDecimal("323456789.56")), true);
+
+    // precondition: both file groups must carry log files, else compaction is 
a no-op and would not
+    // exercise the row-writer merge path
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieTable hoodieTable = HoodieSparkTable.create(config, context(), 
metaClient);
+    hoodieTable.getHoodieView().sync();
+    List<FileSlice> latestSlices =
+        
hoodieTable.getHoodieView().getLatestFileSlices(DECIMAL_PARTITION).collect(Collectors.toList());
+    assertEquals(2, latestSlices.size(), "expected two file groups before 
compaction");
+    assertTrue(latestSlices.stream().allMatch(slice -> 
slice.getLogFiles().findAny().isPresent()),
+        "each file group must have log files for compaction to merge");
+    HoodieSchema tableSchema = new 
TableSchemaResolver(metaClient).getTableSchema(false);
+
+    // compaction rewrites both base files through the Spark record type
+    String compactionInstant = (String) 
client.scheduleCompaction(Option.empty()).get();
+    HoodieWriteMetadata compactionResult = client.compact(compactionInstant);
+    client.commitCompaction(compactionInstant, compactionResult, 
Option.empty());
+    
assertTrue(metaClient.reloadActiveTimeline().filterCompletedInstants().containsInstant(compactionInstant));
+    List<StoragePath> compactedBaseFiles = latestBaseFilePaths(config, 
DECIMAL_PARTITION);
+    assertEquals(2, compactedBaseFiles.size(), "expected two compacted base 
files");
+    for (StoragePath path : compactedBaseFiles) {
+      assertDecimalFixedLen(path, EXPECTED_DECIMAL_FIXED_LEN);
+    }
+    assertEquals(tableSchema,
+        new 
TableSchemaResolver(HoodieTableMetaClient.reload(metaClient)).getTableSchema(false),
+        "table schema in commit metadata must not change after compaction");
+
+    // clustering rewrites the two compacted groups through the same Spark 
row-writer path
+    String clusteringInstant = (String) 
client.scheduleClustering(Option.empty()).get();
+    HoodieWriteMetadata<JavaRDD<WriteStatus>> clusterMetadata = 
client.cluster(clusteringInstant, true);
+    List<HoodieWriteStat> clusterStats = clusterMetadata.getWriteStats().get();
+    assertFalse(clusterStats.isEmpty(), "clustering should write at least one 
base file");
+    for (HoodieWriteStat stat : clusterStats) {
+      assertDecimalFixedLen(new StoragePath(metaClient.getBasePath(), 
stat.getPath()),
+          EXPECTED_DECIMAL_FIXED_LEN);
+    }
+    assertEquals(tableSchema,
+        new 
TableSchemaResolver(HoodieTableMetaClient.reload(metaClient)).getTableSchema(false),
+        "table schema in commit metadata must not change after clustering");
+  }
+
+  private List<HoodieRecord> buildDecimalRecords(int startKey, int count, long 
ts, BigDecimal decValue) {
+    Schema schema = new Schema.Parser().parse(FIXED10_DECIMAL_SCHEMA);
+    Schema decSchema = schema.getField("dec").schema();
+    Conversions.DecimalConversion decimalConversion = new 
Conversions.DecimalConversion();
+    LogicalTypes.Decimal decimalType = LogicalTypes.decimal(20, 2);
+    BigDecimal scaledValue = decValue.setScale(2);
+    List<HoodieRecord> records = new ArrayList<>();
+    for (int i = 0; i < count; i++) {
+      String key = "key_" + (startKey + i);
+      GenericRecord rec = new GenericData.Record(schema);
+      rec.put("_row_key", key);
+      rec.put("partition_path", DECIMAL_PARTITION);
+      rec.put("ts", ts);
+      GenericFixed fixed = decimalConversion.toFixed(scaledValue, decSchema, 
decimalType);
+      rec.put("dec", fixed);
+      records.add(new HoodieAvroRecord<>(new HoodieKey(key, DECIMAL_PARTITION),
+          new OverwriteWithLatestAvroPayload(rec, ts)));
+    }
+    return records;
+  }
+
+  private List<StoragePath> latestBaseFilePaths(HoodieWriteConfig config, 
String partition) {
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    HoodieTable hoodieTable = HoodieSparkTable.create(config, context(), 
metaClient);
+    hoodieTable.getHoodieView().sync();
+    return hoodieTable.getBaseFileOnlyView().getLatestBaseFiles(partition)
+        .map(HoodieBaseFile::getStoragePath).collect(Collectors.toList());
+  }
+
+  private void assertDecimalFixedLen(StoragePath baseFilePath, int 
expectedLen) {
+    MessageType parquetSchema = ParquetUtils.readMetadata(hoodieStorage(), 
baseFilePath)
+        .getFileMetaData().getSchema();
+    PrimitiveType decType = parquetSchema.getType("dec").asPrimitiveType();
+    assertEquals(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, 
decType.getPrimitiveTypeName(),
+        "decimal must be encoded as FIXED_LEN_BYTE_ARRAY");
+    assertEquals(expectedLen, decType.getTypeLength(),
+        "Avro fixed(10) decimal(20,2) must stay FIXED_LEN_BYTE_ARRAY(10), not 
narrow to 9");
+  }
 }

Reply via email to