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 c2e884aa20a4 perf(trino): drop the decimal schema cache and memoize 
prefilled values (#19495)
c2e884aa20a4 is described below

commit c2e884aa20a48c539750b7b4ac7211f12c41d9fe
Author: voonhous <[email protected]>
AuthorDate: Tue Aug 4 19:26:57 2026 +0800

    perf(trino): drop the decimal schema cache and memoize prefilled values 
(#19495)
    
    * perf(trino): drop the decimal schema cache and memoize prefilled column 
values
    
    Follow-up to #19483, addressing wombatu-kun's review comments.
    
    The decimal schema cache was unnecessary rather than merely mis-keyed. 
Avro's
    DecimalConversion.fromBytes reads only the scale (it never touches 
precision, and
    ignores its schema argument), and Decimals.encodeShortScaledValue then calls
    setScale to that same scale, which returns the BigDecimal unchanged. The 
pair
    reduces to new BigInteger(fixed.bytes()).longValueExact(), so 
AvroDecimalConverter
    and its ConcurrentHashMap are deleted instead of re-keyed.
    
    PrefilledColumnValues resolved every value through 
HiveUtil.getPrefilledColumnValue
    on each call, and appendTo runs once per prefilled column per record. Every 
input is
    a split constant, so the resolved value is now memoized per column.
    
    Tests: the decimal test now drives the public appendTo path over scales, 
signs and
    the widest short decimal rather than the deleted converter; it passes 
against both
    the old and new implementations. Added repeated resolution of a hive-null 
column,
    the case a naive memo would get wrong.
    
    * refactor(trino): rename the uncached prefilled resolver to 
computeNativeValue
    
    * perf(trino): collapse the prefilled memo hit path to a single hash lookup
    
    Addresses wombatu-kun's review comment on #19495.
    
    containsKey-then-get was two hash lookups per prefilled column per record 
on the
    buildRecordInPage path. An UNRESOLVED sentinel with getOrDefault does it in 
one,
    while still distinguishing a not-yet-resolved column from one resolved to 
null
    (the hive-"\N" convention, and the lenient fallback for a column the split 
cannot
    provide). The map still stores real nulls, so it stays a HashMap.
    
    * test(trino): build the decimal fixed the way Avro writes it
    
    Addresses wombatu-kun's two review comments on #19495.
    
    The short-decimal cases built the Fixed from 
BigDecimal.unscaledValue().toByteArray(),
    the minimal two's-complement encoding, and sized the schema to those bytes. 
Avro's
    DecimalConversion.toFixed instead left-pads to the schema's fixed size with 
the sign
    byte, so a real decimal(10,2) is always five bytes. The negative cases were 
one and
    two bytes wide, meaning no case exercised sign extension across padding -- 
the thing
    the decode is most likely to get wrong. The fixture now sizes the schema 
from the
    precision and runs the value through Avro's own conversion, matching how
    TestHudiUtilColumnHandles builds its decimal fixed schema. Cases are 
unchanged and
    still pass; -0.07 now decodes from FF FF FF FF F9 rather than F9.
    
    Also drops an overreaching claim on the hive-null repeats: a null-check 
memo returns
    null on every call too, so the repeats do not rule it out. They cover what 
they
    actually cover, that both read paths keep returning null once the memo is 
populated.
---
 .../trino/plugin/hudi/util/HudiAvroSerializer.java | 33 +++-------
 .../plugin/hudi/util/PrefilledColumnValues.java    | 27 ++++++++
 .../plugin/hudi/TestPrefilledColumnValues.java     | 13 +++-
 .../plugin/hudi/util/TestHudiAvroSerializer.java   | 74 +++++++++++++++-------
 4 files changed, 98 insertions(+), 49 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 fe7ddfe42515..8b035c084af6 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
@@ -41,8 +41,6 @@ import io.trino.spi.type.SqlVarbinary;
 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;
@@ -50,6 +48,7 @@ import org.apache.avro.generic.IndexedRecord;
 import org.apache.avro.util.Utf8;
 
 import java.math.BigDecimal;
+import java.math.BigInteger;
 import java.nio.ByteBuffer;
 import java.time.DateTimeException;
 import java.time.Instant;
@@ -60,7 +59,6 @@ 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;
@@ -70,7 +68,6 @@ import static 
io.trino.spi.StandardErrorCode.GENERIC_INTERNAL_ERROR;
 import static io.trino.spi.StandardErrorCode.NUMERIC_VALUE_OUT_OF_RANGE;
 import static io.trino.spi.type.BigintType.BIGINT;
 import static io.trino.spi.type.DateType.DATE;
-import static io.trino.spi.type.Decimals.encodeShortScaledValue;
 import static io.trino.spi.type.Decimals.writeBigDecimal;
 import static io.trino.spi.type.Decimals.writeShortDecimal;
 import static io.trino.spi.type.IntegerType.INTEGER;
@@ -108,7 +105,6 @@ public class HudiAvroSerializer
             1, // 9 digits after the dot
     };
 
-    private static final AvroDecimalConverter DECIMAL_CONVERTER = new 
AvroDecimalConverter();
     private final PrefilledColumnValues prefilledColumnValues;
 
     private final List<HiveColumnHandle> columnHandles;
@@ -262,8 +258,13 @@ public class HudiAvroSerializer
                     }
                     else if (value instanceof GenericData.Fixed fixed) {
                         verify(decimalType.isShort(), "The type should be 
short decimal");
-                        BigDecimal decimal = 
DECIMAL_CONVERTER.convert(decimalType.getPrecision(), decimalType.getScale(), 
fixed.bytes());
-                        type.writeLong(output, encodeShortScaledValue(decimal, 
decimalType.getScale()));
+                        // Avro stores a decimal as its unscaled value in 
big-endian two's complement, which is
+                        // exactly what Trino's short decimal holds. Going 
through Avro's DecimalConversion and
+                        // Decimals.encodeShortScaledValue is a no-op round 
trip: DecimalConversion.fromBytes reads
+                        // only the scale (it ignores precision, and its 
schema argument entirely) to build
+                        // BigDecimal(unscaled, scale), and 
encodeShortScaledValue then calls setScale to that same
+                        // scale, which returns the BigDecimal unchanged, 
before taking the unscaled value back out.
+                        type.writeLong(output, new 
BigInteger(fixed.bytes()).longValueExact());
                     }
                     else {
                         throw new TrinoException(GENERIC_INTERNAL_ERROR,
@@ -544,22 +545,4 @@ 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)
-        {
-            // 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/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java 
b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java
index 19c6c9d01322..c55d46956c27 100644
--- 
a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java
+++ 
b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java
@@ -22,6 +22,7 @@ import io.trino.spi.block.Block;
 import io.trino.spi.block.BlockBuilder;
 import io.trino.spi.block.RunLengthEncodedBlock;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.OptionalInt;
@@ -45,11 +46,18 @@ import static io.trino.spi.type.TypeUtils.writeNativeValue;
  */
 public class PrefilledColumnValues
 {
+    // Absent-marker for the memo, so a not-yet-resolved column is 
distinguishable from one resolved to null
+    private static final Object UNRESOLVED = new Object();
+
     private final Map<String, HivePartitionKey> partitionKeysByName;
     private final String partitionName;
     private final String filePath;
     private final long fileSize;
     private final long fileModifiedTime;
+    // Resolved native value per column name, populated lazily. One instance 
belongs to one split and a
+    // split is read by a single driver thread, so a plain HashMap is enough; 
it also has to hold nulls,
+    // which a ConcurrentHashMap could not.
+    private final Map<String, Object> resolvedValues = new HashMap<>();
 
     public static PrefilledColumnValues create(HudiSplit hudiSplit)
     {
@@ -108,6 +116,25 @@ public class PrefilledColumnValues
     }
 
     private Object nativeValueOf(HiveColumnHandle columnHandle)
+    {
+        // Every input to computeNativeValue() is a constant of the split, but 
appendTo is called once per
+        // prefilled column per record, and computing re-parses the partition 
string each time
+        // ($file_modified_time even formats a timestamp and parses it 
straight back). Memoize per column so
+        // each one is resolved once per split. Keyed on the name rather than 
the handle because
+        // HiveColumnHandle.hashCode hashes seven fields through a varargs 
array, whereas a String caches
+        // its hash. A sentinel rather than a null check, because null is a 
legitimate resolved value -- both
+        // for the hive-null convention and for the lenient fallback below -- 
and getOrDefault keeps the hit
+        // path, the one taken per record, to a single hash lookup.
+        String name = columnHandle.getName();
+        Object value = resolvedValues.getOrDefault(name, UNRESOLVED);
+        if (value == UNRESOLVED) {
+            value = computeNativeValue(columnHandle);
+            resolvedValues.put(name, value);
+        }
+        return value;
+    }
+
+    private Object computeNativeValue(HiveColumnHandle columnHandle)
     {
         if (!isPrefilled(columnHandle)) {
             // Lenient null fill, e.g. for a hidden column Trino defines but 
Hudi does not populate.
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java
index 4ee508cada0b..59442f8cdbe6 100644
--- 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestPrefilledColumnValues.java
@@ -81,9 +81,20 @@ class TestPrefilledColumnValues
     {
         // Trino's HivePartitionKey encodes a null partition value as the 
literal string "\N"
         PrefilledColumnValues values = prefilledValues(new 
HivePartitionKey("pk_string", "\\N"));
+        HiveColumnHandle handle = partitionKey("pk_string", VARCHAR, 
HiveType.HIVE_STRING);
 
-        Block block = singleValueBlock(values, partitionKey("pk_string", 
VARCHAR, HiveType.HIVE_STRING));
+        Block block = singleValueBlock(values, handle);
         assertThat(block.isNull(0)).isTrue();
+
+        // Values are resolved once per column and reused, so both read paths 
have to keep returning null
+        // for a hive-null column after the first read has populated the memo.
+        BlockBuilder blockBuilder = VARCHAR.createBlockBuilder(null, 2);
+        values.appendTo(handle, blockBuilder);
+        values.appendTo(handle, blockBuilder);
+        Block repeated = blockBuilder.build();
+        assertThat(repeated.isNull(0)).isTrue();
+        assertThat(repeated.isNull(1)).isTrue();
+        assertThat(values.toRleBlock(handle, 1).isNull(0)).isTrue();
     }
 
     @Test
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
index 116d50d115e8..514218d48add 100644
--- 
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
@@ -25,14 +25,21 @@ 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.Conversions;
+import org.apache.avro.LogicalTypes;
 import org.apache.avro.Schema;
 import org.apache.avro.SchemaBuilder;
 import org.apache.avro.generic.GenericData;
 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.math.BigInteger;
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Stream;
 
 import static io.trino.spi.type.BigintType.BIGINT;
 import static io.trino.spi.type.IntegerType.INTEGER;
@@ -41,32 +48,37 @@ 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()
+    /**
+     * A short decimal is stored in Trino as the unscaled value, which is 
exactly what Avro writes into the
+     * fixed bytes, so the read is a plain big-endian two's complement decode. 
The cases below pin the parts
+     * that decode gets wrong if it is ever rewritten: sign extension for 
negatives, scale 0, and the
+     * full-width value at the maximum short-decimal precision.
+     */
+    @ParameterizedTest
+    @MethodSource("shortDecimals")
+    public void testAppendShortDecimalFromAvroFixed(int precision, int scale, 
String value, long expectedUnscaled)
     {
-        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);
+        DecimalType type = DecimalType.createDecimalType(precision, scale);
 
         BlockBuilder blockBuilder = type.createBlockBuilder(null, 1);
-        HudiAvroSerializer.appendTo(type, fixed, blockBuilder);
+        HudiAvroSerializer.appendTo(type, avroDecimalFixed(precision, scale, 
value), blockBuilder);
         Block block = blockBuilder.build();
 
-        assertThat(type.getLong(block, 0)).isEqualTo(12345L);
+        assertThat(type.getLong(block, 0)).isEqualTo(expectedUnscaled);
+    }
+
+    private static Stream<Arguments> shortDecimals()
+    {
+        return Stream.of(
+                Arguments.of(10, 2, "123.45", 12345L),
+                Arguments.of(10, 2, "-0.07", -7L),
+                Arguments.of(10, 2, "0.00", 0L),
+                Arguments.of(10, 4, "123.4567", 1234567L),
+                Arguments.of(5, 0, "42", 42L),
+                Arguments.of(5, 0, "-42", -42L),
+                // Widest short decimal: 18 digits, both signs
+                Arguments.of(18, 2, "9999999999999999.99", 
999999999999999999L),
+                Arguments.of(18, 2, "-9999999999999999.99", 
-999999999999999999L));
     }
 
     @Test
@@ -96,9 +108,25 @@ class TestHudiAvroSerializer
         assertThat(VARCHAR.getSlice(page.getBlock(1), 
2).toStringUtf8()).isEqualTo("three");
     }
 
-    private static byte[] unscaledBytes(String decimal)
+    /**
+     * Encodes the value the way an Avro writer does, via Avro's own 
conversion: a fixed sized from the
+     * precision, holding the unscaled value left-padded to that width with 
the sign byte (0xFF for
+     * negatives). Building the fixed from the minimal two's-complement 
encoding instead would leave the
+     * padding bytes, and so sign extension across them, untested.
+     */
+    private static GenericData.Fixed avroDecimalFixed(int precision, int 
scale, String value)
+    {
+        LogicalTypes.Decimal decimalType = LogicalTypes.decimal(precision, 
scale);
+        Schema fixedSchema = decimalType.addToSchema(
+                Schema.createFixed("fix", null, null, 
decimalFixedSize(precision)));
+        return (GenericData.Fixed) new Conversions.DecimalConversion()
+                .toFixed(new BigDecimal(value), fixedSchema, decimalType);
+    }
+
+    /** Bytes needed to hold the widest unscaled value at this precision, i.e. 
the fixed size Avro sizes a decimal to. */
+    private static int decimalFixedSize(int precision)
     {
-        return new BigDecimal(decimal).unscaledValue().toByteArray();
+        return 
BigInteger.TEN.pow(precision).subtract(BigInteger.ONE).toByteArray().length;
     }
 
     private static Schema recordSchema(String name)

Reply via email to