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 2f8a7252c383 perf(trino): cache decimal Avro schema in 
HudiAvroSerializer instead …  (#19483)
2f8a7252c383 is described below

commit 2f8a7252c38379f50b8b970f2d841975ddf090a9
Author: voonhous <[email protected]>
AuthorDate: Mon Aug 3 23:23:58 2026 +0800

    perf(trino): cache decimal Avro schema in HudiAvroSerializer instead …  
(#19483)
    
    * perf(trino): cache decimal Avro schema in HudiAvroSerializer instead of 
parsing per value
    
    AvroDecimalConverter built and JSON-parsed an Avro schema for every
    decimal cell on the record read path. Cache the schemas by (precision,
    scale) and build them with LogicalTypes on miss. Also cache
    buildRecordInPage field positions per record schema instead of doing a
    name lookup per record per column, and make writeRow's anonymous-field
    name fallback lazy.
    
    Fixes #19361
    
    * address review: replace bit-packed decimal cache key with precision * 100 
+ scale
---
 .../trino/plugin/hudi/util/HudiAvroSerializer.java |  53 ++++++--
 .../plugin/hudi/util/TestHudiAvroSerializer.java   | 148 +++++++++++++++++++++
 2 files changed, 190 insertions(+), 11 deletions(-)

diff --git 
a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java 
b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
index 0d9bc9f5978b..fe7ddfe42515 100644
--- a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
+++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
@@ -42,6 +42,7 @@ import io.trino.spi.type.Type;
 import io.trino.spi.type.VarbinaryType;
 import io.trino.spi.type.VarcharType;
 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;
@@ -59,6 +60,7 @@ import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.base.Verify.verify;
@@ -118,6 +120,11 @@ public class HudiAvroSerializer
     // constructor, which maps page channel i to record position 
channelToFieldPosition[i].
     private final Schema schema;
     private final int[] channelToFieldPosition;
+    // Single-entry cache for buildRecordInPage: all records of a split share 
one schema instance,
+    // so an identity check makes the per-record, per-column field-name lookup 
a one-time cost.
+    // Prefilled (hidden/synthesized) columns are not fields of the record 
schema; they get -1.
+    private Schema positionsCacheSchema;
+    private int[] positionsCache;
 
     public HudiAvroSerializer(List<HiveColumnHandle> columnHandles, 
PrefilledColumnValues prefilledColumnValues)
     {
@@ -170,21 +177,36 @@ public class HudiAvroSerializer
     public void buildRecordInPage(PageBuilder pageBuilder, IndexedRecord 
record)
     {
         pageBuilder.declarePosition();
-        int blockSeq = 0;
-        for (int channel = 0; channel < columnTypes.size(); channel++, 
blockSeq++) {
-            BlockBuilder output = pageBuilder.getBlockBuilder(blockSeq);
-            HiveColumnHandle columnHandle = columnHandles.get(channel);
-            if (prefilledColumnValues.isPrefilled(columnHandle)) {
-                prefilledColumnValues.appendTo(columnHandle, output);
+        // Record may not be projected, get field positions from its own schema
+        int[] fieldPositions = fieldPositionsFor(record.getSchema());
+        for (int channel = 0; channel < columnTypes.size(); channel++) {
+            BlockBuilder output = pageBuilder.getBlockBuilder(channel);
+            int fieldPosition = fieldPositions[channel];
+            if (fieldPosition < 0) {
+                prefilledColumnValues.appendTo(columnHandles.get(channel), 
output);
             }
             else {
-                // Record may not be projected, get index from it
-                int fieldPosInSchema = 
getFieldFromSchema(columnHandle.getName(), record.getSchema()).pos();
-                appendTo(columnTypes.get(channel), 
record.get(fieldPosInSchema), output);
+                appendTo(columnTypes.get(channel), record.get(fieldPosition), 
output);
             }
         }
     }
 
+    private int[] fieldPositionsFor(Schema recordSchema)
+    {
+        if (positionsCacheSchema != recordSchema) {
+            int[] positions = new int[columnHandles.size()];
+            for (int channel = 0; channel < columnHandles.size(); channel++) {
+                HiveColumnHandle columnHandle = columnHandles.get(channel);
+                positions[channel] = 
prefilledColumnValues.isPrefilled(columnHandle)
+                        ? -1
+                        : getFieldFromSchema(columnHandle.getName(), 
recordSchema).pos();
+            }
+            positionsCache = positions;
+            positionsCacheSchema = recordSchema;
+        }
+        return positionsCache;
+    }
+
     public static void appendTo(Type type, Object value, BlockBuilder output)
     {
         if (value == null) {
@@ -493,7 +515,9 @@ public class HudiAvroSerializer
         output.buildEntry(fieldBuilders -> {
             for (int index = 0; index < fields.size(); index++) {
                 RowType.Field field = fields.get(index);
-                appendTo(field.getType(), 
record.get(field.getName().orElse("field" + index)), fieldBuilders.get(index));
+                int fieldIndex = index;
+                String fieldName = field.getName().orElseGet(() -> "field" + 
fieldIndex);
+                appendTo(field.getType(), record.get(fieldName), 
fieldBuilders.get(index));
             }
         });
     }
@@ -524,10 +548,17 @@ public class HudiAvroSerializer
     static class AvroDecimalConverter
     {
         private static final Conversions.DecimalConversion 
AVRO_DECIMAL_CONVERSION = new Conversions.DecimalConversion();
+        // convert() runs once per decimal cell on the record read path, and 
building a Schema costs
+        // orders of magnitude more than the conversion itself. The 
(precision, scale) space is tiny
+        // and fixed per column, so cache the schemas globally.
+        private static final Map<Integer, Schema> DECIMAL_SCHEMAS = new 
ConcurrentHashMap<>();
 
         BigDecimal convert(int precision, int scale, byte[] bytes)
         {
-            Schema schema = new 
Schema.Parser().parse(format("{\"type\":\"bytes\",\"logicalType\":\"decimal\",\"precision\":%d,\"scale\":%d}",
 precision, scale));
+            // The key is unique because precision and scale are at most 38
+            Schema schema = DECIMAL_SCHEMAS.computeIfAbsent(
+                    precision * 100 + scale,
+                    key -> LogicalTypes.decimal(precision, 
scale).addToSchema(Schema.create(Schema.Type.BYTES)));
             return AVRO_DECIMAL_CONVERSION.fromBytes(ByteBuffer.wrap(bytes), 
schema, schema.getLogicalType());
         }
     }
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestHudiAvroSerializer.java
 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestHudiAvroSerializer.java
new file mode 100644
index 000000000000..116d50d115e8
--- /dev/null
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestHudiAvroSerializer.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.trino.plugin.hudi.util;
+
+import io.trino.metastore.HiveType;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.HivePartitionKey;
+import io.trino.plugin.hudi.HudiSplit;
+import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.spi.Page;
+import io.trino.spi.PageBuilder;
+import io.trino.spi.SplitWeight;
+import io.trino.spi.block.Block;
+import io.trino.spi.block.BlockBuilder;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.type.DecimalType;
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaBuilder;
+import org.apache.avro.generic.GenericData;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Optional;
+
+import static io.trino.spi.type.BigintType.BIGINT;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.spi.type.VarcharType.VARCHAR;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TestHudiAvroSerializer
+{
+    @Test
+    public void testDecimalConverter()
+    {
+        HudiAvroSerializer.AvroDecimalConverter converter = new 
HudiAvroSerializer.AvroDecimalConverter();
+
+        assertThat(converter.convert(10, 2, 
unscaledBytes("123.45"))).isEqualTo(new BigDecimal("123.45"));
+        // Same (precision, scale) again: served from the cached schema
+        assertThat(converter.convert(10, 2, 
unscaledBytes("-0.07"))).isEqualTo(new BigDecimal("-0.07"));
+        // Same precision, different scale, and vice versa: must not collide 
in the cache
+        assertThat(converter.convert(10, 4, 
unscaledBytes("123.4567"))).isEqualTo(new BigDecimal("123.4567"));
+        assertThat(converter.convert(18, 2, 
unscaledBytes("9999999999999999.99"))).isEqualTo(new 
BigDecimal("9999999999999999.99"));
+        assertThat(converter.convert(5, 0, unscaledBytes("42"))).isEqualTo(new 
BigDecimal("42"));
+    }
+
+    @Test
+    public void testAppendShortDecimalFromAvroFixed()
+    {
+        DecimalType type = DecimalType.createDecimalType(10, 2);
+        byte[] bytes = unscaledBytes("123.45");
+        GenericData.Fixed fixed = new 
GenericData.Fixed(Schema.createFixed("fix", null, null, bytes.length), bytes);
+
+        BlockBuilder blockBuilder = type.createBlockBuilder(null, 1);
+        HudiAvroSerializer.appendTo(type, fixed, blockBuilder);
+        Block block = blockBuilder.build();
+
+        assertThat(type.getLong(block, 0)).isEqualTo(12345L);
+    }
+
+    @Test
+    public void testBuildRecordInPage()
+    {
+        // Schema field order (b, a) deliberately differs from projection 
order (a, b, pk_int),
+        // so correct output proves positions are resolved from the record's 
schema.
+        Schema schema = recordSchema("rec1");
+        HudiAvroSerializer serializer = new 
HudiAvroSerializer(projectedColumns(), prefilledValues());
+        PageBuilder pageBuilder = new PageBuilder(List.of(BIGINT, VARCHAR, 
INTEGER));
+
+        serializer.buildRecordInPage(pageBuilder, record(schema, 1L, "one"));
+        // Second record with the same schema instance exercises the cached 
field positions
+        serializer.buildRecordInPage(pageBuilder, record(schema, 2L, "two"));
+        // A schema instance with the opposite field order must invalidate the 
cache; reusing the
+        // stale positions would swap the a and b values
+        serializer.buildRecordInPage(pageBuilder, 
record(reversedRecordSchema("rec2"), 3L, "three"));
+
+        Page page = pageBuilder.build();
+        assertThat(page.getPositionCount()).isEqualTo(3);
+        for (int position = 0; position < 3; position++) {
+            assertThat(BIGINT.getLong(page.getBlock(0), 
position)).isEqualTo(position + 1);
+            assertThat(INTEGER.getInt(page.getBlock(2), 
position)).isEqualTo(42);
+        }
+        assertThat(VARCHAR.getSlice(page.getBlock(1), 
0).toStringUtf8()).isEqualTo("one");
+        assertThat(VARCHAR.getSlice(page.getBlock(1), 
1).toStringUtf8()).isEqualTo("two");
+        assertThat(VARCHAR.getSlice(page.getBlock(1), 
2).toStringUtf8()).isEqualTo("three");
+    }
+
+    private static byte[] unscaledBytes(String decimal)
+    {
+        return new BigDecimal(decimal).unscaledValue().toByteArray();
+    }
+
+    private static Schema recordSchema(String name)
+    {
+        return SchemaBuilder.record(name).fields()
+                .name("b").type().stringType().noDefault()
+                .name("a").type().longType().noDefault()
+                .endRecord();
+    }
+
+    private static Schema reversedRecordSchema(String name)
+    {
+        return SchemaBuilder.record(name).fields()
+                .name("a").type().longType().noDefault()
+                .name("b").type().stringType().noDefault()
+                .endRecord();
+    }
+
+    private static GenericData.Record record(Schema schema, long a, String b)
+    {
+        GenericData.Record record = new GenericData.Record(schema);
+        record.put("a", a);
+        record.put("b", b);
+        return record;
+    }
+
+    private static List<HiveColumnHandle> projectedColumns()
+    {
+        return List.of(
+                HiveColumnHandle.createBaseColumn("a", 0, HiveType.HIVE_LONG, 
BIGINT, HiveColumnHandle.ColumnType.REGULAR, Optional.empty()),
+                HiveColumnHandle.createBaseColumn("b", 1, 
HiveType.HIVE_STRING, VARCHAR, HiveColumnHandle.ColumnType.REGULAR, 
Optional.empty()),
+                HiveColumnHandle.createBaseColumn("pk_int", -1, 
HiveType.HIVE_INT, INTEGER, HiveColumnHandle.ColumnType.PARTITION_KEY, 
Optional.empty()));
+    }
+
+    private static PrefilledColumnValues prefilledValues()
+    {
+        HudiBaseFile baseFile = new 
HudiBaseFile("s3://bucket/table/file1.parquet", "file1.parquet", 1234, 
1700000000123L, 0, 1234);
+        HudiSplit split = new HudiSplit(
+                baseFile,
+                List.of(),
+                "001",
+                TupleDomain.all(),
+                List.of(new HivePartitionKey("pk_int", "42")),
+                SplitWeight.standard());
+        return PrefilledColumnValues.create(split);
+    }
+}

Reply via email to