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 93f1f711e065 refactor(schema): schema util contracts, dead API and 
folds (#19809)
93f1f711e065 is described below

commit 93f1f711e065e704d06b26f837865933a0e43208
Author: voonhous <[email protected]>
AuthorDate: Thu Sep 3 18:09:28 2026 +0800

    refactor(schema): schema util contracts, dead API and folds (#19809)
    
    refactor(schema): schema util contracts, dead API and folds
    
    Part 1 of #16639. The HoodieSchema migration moved schema helpers out
    of AvroSchemaUtils and HoodieAvroUtils but never wrote down what
    belongs where, so helpers kept being re-implemented instead of found.
    Behavior-preserving.
    
    The routing rule, now in the class javadocs: HoodieSchema answers
    questions about one schema and holds the factories; HoodieSchemaUtils
    holds table-schema transforms and the Hudi record shapes;
    HoodieSchemaCompatibility is the only compatibility and projection
    entry point; HoodieAvroUtils holds Avro record and value operations;
    common.schema.internal is the InternalSchema domain. Look-alike
    helpers with different semantics cross-reference each other instead
    of being merged.
    
    Code changes to match it:
    - HoodieSchemas folds into HoodieSchemaUtils (createDeleteLogSchema).
    - AvroSchemaCache deleted: its one caller interned a generated-class
      static, a no-op since #18967 removed the last consumer.
    - LocalHoodieSchemaCache moves from common.util to common.schema;
      getInstance() becomes create(), which is what it always did.
    - convertBytesToBigDecimal(byte[], HoodieSchema) moves to
      HoodieAvroUtils; the (byte[], int, int) shim and the caller-less
      HoodieSchemaUtils.convertValueForSpecificDataTypes and
      hasSmallPrecisionDecimalField are deleted.
    - HoodieAvroUtils.getRecordColumnValues takes HoodieSchema, since both
      callers unwrapped one to call it; it still interns through
      HoodieAvroSchemaCache and now reuses the static PROPERTIES and a
      fixed array instead of allocating per call.
    - 13 caller-less members narrowed; test-only ones get
      @VisibleForTesting.
    - Stale "equivalent to X" javadocs fixed, cross-references added.
    
    Tests pin delete-log field order and logical types, case-insensitive
    projection, nested column values, the HoodieMetadataPayload
    reference-equality fast path, the getRecordColumnValues intern, and
    LocalHoodieSchemaCache (test restored from #17740).
    
    Pre-existing bugs found on the way are filed, not fixed here:
    #19823, #19825, #19826.
    
    Part 2 (#19810) implements asNullable natively, dissolves
    AvroSchemaUtils and dedupes the compatibility cluster.
    
    Closes #15908
---
 .../hudi/io/cdc/HoodieNativeLogFormatWriter.java   |   4 +-
 .../TestJavaBulkInsertInternalPartitioner.java     |   6 +-
 .../apache/hudi/HoodieSchemaConversionUtils.scala  |   3 +-
 .../apache/spark/sql/HoodieInternalRowUtils.scala  |   6 +-
 .../apache/hudi/common/avro/AvroSchemaCache.java   |  47 ----
 .../apache/hudi/common/avro/AvroSchemaUtils.java   |  13 +-
 .../apache/hudi/common/avro/HoodieAvroUtils.java   | 123 +++++++---
 .../hudi/common/avro/HoodieAvroWrapperUtils.java   |  14 +-
 .../processors/DecimalLogicalTypeProcessor.java    |   4 +-
 .../apache/hudi/common/engine/RecordContext.java   |   4 +-
 .../apache/hudi/common/model/HoodieAvroRecord.java |   2 +-
 .../schema/HoodieSchemaCompatibilityChecker.java   |   2 +-
 .../hudi/common/schema/HoodieSchemaRepair.java     |  19 ++
 .../common/schema/HoodieSchemaTypePromotion.java   |  38 +--
 .../hudi/common/schema/HoodieSchemaUtils.java      | 263 +++++++++++----------
 .../apache/hudi/common/schema/HoodieSchemas.java   |  52 ----
 .../{util => schema}/LocalHoodieSchemaCache.java   |  11 +-
 .../internal/convert/InternalSchemaConverter.java  |   2 +-
 .../log/block/HoodieNativeLogDeleteBlock.java      |   4 +-
 .../common/table/read/lsm/LsmFileIterators.java    |   4 +-
 .../hudi/metadata/HoodieMetadataPayload.java       |   8 +-
 .../hudi/metadata/HoodieTableMetadataUtil.java     |   2 +-
 .../hudi/common/avro/TestHoodieAvroUtils.java      | 164 +++++++++++++
 .../hudi/common/schema/TestHoodieSchemaUtils.java  | 211 +++++++----------
 .../common/schema/TestLocalHoodieSchemaCache.java  |  86 +++++++
 .../read/lsm/TestLsmFileGroupRecordIterator.java   |  15 --
 .../table/read/lsm/TestLsmFileIterators.java       |   4 +-
 .../testutils/reader/HoodieFileSliceTestUtils.java |   4 +-
 .../hudi/metadata/TestHoodieMetadataPayload.java   |  26 ++
 .../org/apache/hudi/HoodieCreateRecordUtils.scala  |   2 +-
 .../org/apache/hudi/TestDataSourceDefaults.scala   |  15 +-
 .../sql/hudi/common/HoodieSparkSqlTestBase.scala   |   1 -
 .../utilities/sources/TestJsonKafkaSource.java     |   3 +-
 33 files changed, 709 insertions(+), 453 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
index c23204b9c8fc..c45dc2fa439b 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
@@ -25,7 +25,7 @@ import org.apache.hudi.common.model.HoodieFileFormat;
 import org.apache.hudi.common.model.HoodieLogFile;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.HoodieSchema;
-import org.apache.hudi.common.schema.HoodieSchemas;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.table.HoodieTableVersion;
 import org.apache.hudi.common.table.log.AppendResult;
 import org.apache.hudi.common.table.log.HoodieLogFormat;
@@ -258,7 +258,7 @@ public class HoodieNativeLogFormatWriter extends 
HoodieLogFormat.Writer {
     ensureAppendVersion();
     if (deleteFileWriter == null) {
       deleteLogFile = createNativeLogFile(currentAppendVersion, 
DELETE_LOG_EXTENSION);
-      deleteLogSchema = HoodieSchemas.createDeleteLogSchema(tableSchema, 
orderingFieldNames);
+      deleteLogSchema = HoodieSchemaUtils.createDeleteLogSchema(tableSchema, 
orderingFieldNames);
       // The delete records are built through 
recordContext#constructEngineRecord (see #appendDeleteRecord), so the
       // writer must match the record context's engine type rather than the 
merger's record type: on Spark executors
       // the reader context for write degrades to Avro (no engine context 
available), and using the merger record
diff --git 
a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitioner.java
 
b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitioner.java
index b7d851aee79b..b8fcd04aae6f 100644
--- 
a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitioner.java
+++ 
b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/execution/bulkinsert/TestJavaBulkInsertInternalPartitioner.java
@@ -21,6 +21,7 @@ package org.apache.hudi.execution.bulkinsert;
 
 import org.apache.hudi.common.avro.HoodieAvroUtils;
 import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.FlatLists;
@@ -29,7 +30,6 @@ import org.apache.hudi.keygen.constant.KeyGeneratorOptions;
 import org.apache.hudi.table.BulkInsertPartitioner;
 import org.apache.hudi.testutils.HoodieJavaClientTestHarness;
 
-import org.apache.avro.Schema;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
@@ -63,7 +63,7 @@ public class TestJavaBulkInsertInternalPartitioner extends 
HoodieJavaClientTestH
   public void testCustomColumnSortPartitioner(String sortColumnString) throws 
Exception {
     String[] sortColumns = sortColumnString.split(",");
     Comparator<HoodieRecord> columnComparator =
-        getCustomColumnComparator(HoodieTestDataGenerator.AVRO_SCHEMA, 
sortColumns);
+        getCustomColumnComparator(HoodieTestDataGenerator.HOODIE_SCHEMA, 
sortColumns);
 
     List<HoodieRecord> records = generateTestRecordsForBulkInsert(1000);
     HoodieWriteConfig cfg = 
HoodieWriteConfig.newBuilder().withPath("basePath").build();
@@ -74,7 +74,7 @@ public class TestJavaBulkInsertInternalPartitioner extends 
HoodieJavaClientTestH
         records, true, generatePartitionNumRecords(records), 
Option.of(columnComparator));
   }
 
-  private Comparator<HoodieRecord> getCustomColumnComparator(Schema schema, 
String[] sortColumns) {
+  private Comparator<HoodieRecord> getCustomColumnComparator(HoodieSchema 
schema, String[] sortColumns) {
     return Comparator.comparing(record ->
         FlatLists.ofComparableArray(
             HoodieAvroUtils.getRecordColumnValues(record, sortColumns, schema, 
false)));
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/HoodieSchemaConversionUtils.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/HoodieSchemaConversionUtils.scala
index 93baf250bf3a..466f166a5d84 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/HoodieSchemaConversionUtils.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/HoodieSchemaConversionUtils.scala
@@ -345,8 +345,7 @@ object HoodieSchemaConversionUtils {
 
   /**
    * Gets the fully-qualified Avro record name and namespace for a Hudi table
-   * This delegates to [[HoodieSchemaUtils.getRecordQualifiedName]] which in 
turn
-   * delegates to [[AvroSchemaUtils.getAvroRecordQualifiedName]].
+   * This delegates to [[HoodieSchemaUtils.getRecordQualifiedName]].
    *
    * The qualified name follows the pattern: 
hoodie.{tableName}.{tableName}_record
    * where tableName is sanitized for Avro compatibility.
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieInternalRowUtils.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieInternalRowUtils.scala
index 23054564f53e..2cf48747573d 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieInternalRowUtils.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieInternalRowUtils.scala
@@ -122,9 +122,9 @@ object HoodieInternalRowUtils {
   }
 
   /**
-   * Get or create [[StructType]] for provided [[Schema]]
-   * @param schema [[Schema]] to convert to [[StructType]], NOTE: It is best 
that the schema passed in is cached through 
[[org.apache.hudi.common.avro.AvroSchemaCache]], so that we can reduce the 
overhead of schema lookup in the map
-   * @return [[StructType]] for provided [[Schema]]
+   * Get or create [[StructType]] for provided [[HoodieSchema]]
+   * @param schema [[HoodieSchema]] to convert to [[StructType]], NOTE: It is 
best that the schema passed in is cached through 
[[org.apache.hudi.common.schema.HoodieSchemaCache]], so that we can reduce the 
overhead of schema lookup in the map
+   * @return [[StructType]] for provided [[HoodieSchema]]
    */
   def getCachedSchema(schema: HoodieSchema): StructType = {
     val structType = schemaMap.get(schema)
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaCache.java 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaCache.java
deleted file mode 100644
index 0e4573172476..000000000000
--- a/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaCache.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you 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 org.apache.hudi.common.avro;
-
-import com.github.benmanes.caffeine.cache.Caffeine;
-import com.github.benmanes.caffeine.cache.LoadingCache;
-import org.apache.avro.Schema;
-
-/**
- * An avro schema cache implementation for reusing avro schema instantces in 
JVM/process scope.
- * This is a global cache which works for a JVM lifecycle.
- * A collection of schema instants are maintained.
- *
- * <p> NOTE: The schema which be used frequently should be cached through this 
cache.
- */
-public class AvroSchemaCache {
-
-
-  // Ensure that there is only one variable instance of the same schema within 
an entire JVM lifetime
-  private static final LoadingCache<Schema, Schema> SCHEMA_CACHE = 
Caffeine.newBuilder().weakValues().maximumSize(1024).build(k -> k);
-
-  /**
-   * Get schema variable from global cache. If not found, put it into the 
cache and then return it.
-   * @param schema schema to get
-   * @return if found, return the exist schema variable, otherwise return the 
param itself.
-   */
-  public static Schema intern(Schema schema) {
-    return SCHEMA_CACHE.get(schema);
-  }
-
-}
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java
index e4b90b989a30..1b12c3840456 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java
@@ -39,7 +39,14 @@ import static 
org.apache.hudi.common.util.CollectionUtils.reduce;
 import static org.apache.hudi.common.util.ValidationUtils.checkState;
 
 /**
- * Utils for Avro Schema.
+ * Avro-typed schema helpers, retained only as the delegate target of the call 
sites that have not moved to
+ * HoodieSchema yet: {@link 
org.apache.hudi.common.schema.HoodieSchemaUtils#asNullable(HoodieSchema)} and
+ * {@code HoodieSchemaUtils#createNullableSchema}, the field construction 
inside {@link HoodieSchema.Blob},
+ * and a handful of internal uses in {@link HoodieAvroUtils}.
+ *
+ * <p>This class is being retired under #16639. Do not add methods here: every 
method on this class except
+ * {@link #getNonNullTypeFromUnion(Schema)} already has a HoodieSchema twin, 
so use
+ * {@link org.apache.hudi.common.schema.HoodieSchema} or {@link 
org.apache.hudi.common.schema.HoodieSchemaUtils} instead.</p>
  */
 @NoArgsConstructor(access = AccessLevel.PRIVATE)
 @Slf4j
@@ -87,6 +94,10 @@ public class AvroSchemaUtils {
   /**
    * Resolves typical Avro's nullable schema definition: {@code 
Union(Schema.Type.NULL, <NonNullType>)},
    * decomposing union and returning the target non-null type
+   * <p>
+   * This is the strict variant: it throws unless the union has exactly one 
null branch and one non-null
+   * branch. See the union-unwrapping note on {@link HoodieAvroUtils} for the 
lenient alternatives.
+   * </p>
    */
   public static Schema getNonNullTypeFromUnion(Schema schema) {
     if (schema.getType() != Schema.Type.UNION) {
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
index de449eab8db1..1a83ba67fa63 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java
@@ -25,6 +25,7 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieRecordPayload;
 import org.apache.hudi.common.schema.HoodieAvroSchemaCache;
 import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaType;
 import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.util.DateTimeUtils;
 import org.apache.hudi.common.util.HoodieRecordUtils;
@@ -32,6 +33,7 @@ import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.OrderingValues;
 import org.apache.hudi.common.util.StringUtils;
 import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.VisibleForTesting;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.exception.HoodieAvroSchemaException;
 import org.apache.hudi.exception.HoodieException;
@@ -112,7 +114,44 @@ import static 
org.apache.hudi.common.util.StringUtils.getUTF8Bytes;
 import static org.apache.hudi.common.util.ValidationUtils.checkState;
 
 /**
- * Helper class to do common stuff across Avro.
+ * Operations on Avro records and values.
+ *
+ * <p>This class owns the record-level half of the Avro world:</p>
+ * <ul>
+ *   <li>serialization: {@code avroToBytes} / {@code bytesToAvro} and the JSON 
converters</li>
+ *   <li>record rewriting between schemas: {@code rewriteRecord*}, {@code 
projectRecordToNewSchemaShallow},
+ *       {@code stitchRecords}</li>
+ *   <li>value coercion for logical types: {@code convertValueFor*}, {@code 
convertBytesToBigDecimal}</li>
+ *   <li>record-key, metadata-column and nested-value access: {@code 
getNestedFieldVal},
+ *       {@code addHoodieKeyToRecord}, {@code getRecordColumnValues}, {@code 
createHoodieRecordFromAvro}</li>
+ * </ul>
+ *
+ * <p>Raw {@link Schema} helpers live here only when they serve a record 
operation on this class (for example
+ * {@code createNewSchemaField}, {@code unwrapNullable}). This is not the 
place for table-schema
+ * manipulation.</p>
+ *
+ * <p>Where else to look:</p>
+ * <ul>
+ *   <li>{@link org.apache.hudi.common.schema.HoodieSchema} - questions about 
one schema: navigation,
+ *       nullability, type predicates, and the {@code create*} / {@code parse} 
factories</li>
+ *   <li>{@link org.apache.hudi.common.schema.HoodieSchemaUtils} - 
HoodieSchema-typed structural transforms
+ *       of table schemas</li>
+ *   <li>{@link org.apache.hudi.common.schema.HoodieSchemaCompatibility} - 
reader/writer compatibility and
+ *       projection checks</li>
+ *   <li>{@code org.apache.hudi.common.schema.internal} - the field-id 
InternalSchema (schema-on-read) domain</li>
+ * </ul>
+ *
+ * <p>Union unwrapping is deliberately not one helper. Pick by contract:</p>
+ * <ul>
+ *   <li>{@code unwrapNullable} (this class) - lenient: the first non-null 
branch of any union</li>
+ *   <li>{@code getActualSchemaFromUnion} (this class, private) - resolves 
complex unions against the datum</li>
+ *   <li>{@code AvroSchemaUtils#getNonNullTypeFromUnion} - strict: throws 
unless the union is exactly one null
+ *       branch and one non-null branch</li>
+ *   <li>{@link HoodieSchema#getNonNullType()} - strips null branches and 
never throws</li>
+ *   <li>{@code HoodieSchemaUtils#resolveUnionSchema} - selects a branch by 
full name</li>
+ *   <li>{@code AvroOrcUtils#getActualSchemaType} - maps an all-null union to 
NULL</li>
+ * </ul>
+ * <p>These semantics differ on purpose (see #19212) and must not be merged 
into one helper.</p>
  */
 @Slf4j
 public class HoodieAvroUtils {
@@ -189,7 +228,7 @@ public class HoodieAvroUtils {
     return indexedRecordToBytesStream(record);
   }
 
-  public static <T extends IndexedRecord> ByteArrayOutputStream 
indexedRecordToBytesStream(T record) {
+  private static <T extends IndexedRecord> ByteArrayOutputStream 
indexedRecordToBytesStream(T record) {
     GenericDatumWriter<T> writer = new 
GenericDatumWriter<>(record.getSchema(), ConvertingGenericData.INSTANCE);
     try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
       BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(out, 
BINARY_ENCODER.get());
@@ -221,13 +260,12 @@ public class HoodieAvroUtils {
    * @param record The GenericRecord to convert
    * @param pretty Whether to pretty-print the json output
    */
-  public static String avroToJsonString(GenericRecord record, boolean pretty) 
throws IOException {
+  private static String avroToJsonString(GenericRecord record, boolean pretty) 
throws IOException {
     return avroToJsonHelper(record, pretty).toString();
   }
 
   /**
    * Convert a given avro record to a JSON string. If the record contents are 
invalid, return the record.toString().
-   * Use this method over {@link HoodieAvroUtils#avroToJsonString} when simply 
trying to print the record contents without any guarantees around their 
correctness.
    *
    * @param record The GenericRecord to convert
    * @return a JSON string
@@ -359,6 +397,7 @@ public class HoodieAvroUtils {
     return AvroSchemaUtils.createNewSchemaFromFieldsWithReference(schema, 
filteredFields);
   }
 
+  @VisibleForTesting
   public static Schema makeFieldNonNull(Schema schema, String fieldName, 
Object fieldDefaultValue) {
     ValidationUtils.checkArgument(fieldDefaultValue != null);
     List<Schema.Field> filteredFields = schema.getFields()
@@ -494,7 +533,7 @@ public class HoodieAvroUtils {
   /**
    * Wraps schema as nullable if original was a nullable union.
    */
-  public static Schema wrapNullable(Schema original, Schema updated) {
+  private static Schema wrapNullable(Schema original, Schema updated) {
     if (original.getType() == Schema.Type.UNION) {
       List<Schema> types = original.getTypes();
       if (types.stream().anyMatch(s -> s.getType() == Schema.Type.NULL)) {
@@ -575,6 +614,7 @@ public class HoodieAvroUtils {
    * <p>
    * To better understand conversion rules please check {@link 
#rewriteRecord(GenericRecord, Schema)}
    */
+  @VisibleForTesting
   public static List<GenericRecord> rewriteRecords(List<GenericRecord> 
records, Schema newSchema) {
     return records.stream().map(r -> rewriteRecord(r, 
newSchema)).collect(Collectors.toList());
   }
@@ -698,7 +738,7 @@ public class HoodieAvroUtils {
    * @return the string form of the field
    * or empty if the schema does not contain the field name or the value is 
null
    */
-  public static Option<String> getNullableValAsString(GenericRecord rec, 
String fieldName) {
+  private static Option<String> getNullableValAsString(GenericRecord rec, 
String fieldName) {
     Schema.Field field = rec.getSchema().getField(fieldName);
     String fieldVal = field == null ? null : 
StringUtils.objToString(rec.get(field.pos()));
     return Option.ofNullable(fieldVal);
@@ -883,20 +923,22 @@ public class HoodieAvroUtils {
    *
    * @param record  Hoodie record.
    * @param columns Names of the columns to get values.
-   * @param schema  {@link Schema} instance.
+   * @param schema  {@link HoodieSchema} instance.
    * @return Column value.
    */
   public static Object[] getRecordColumnValues(HoodieRecord record,
                                                String[] columns,
-                                               Schema schema,
+                                               HoodieSchema schema,
                                                boolean 
consistentLogicalTimestampEnabled) {
     try {
-      GenericRecord genericRecord = (GenericRecord) 
(record.toIndexedRecord(HoodieAvroSchemaCache.intern(schema), new 
Properties()).get()).getData();
-      List<Object> list = new ArrayList<>();
-      for (String col : columns) {
-        list.add(HoodieAvroUtils.getNestedFieldVal(genericRecord, col, true, 
consistentLogicalTimestampEnabled));
+      // Intern through the Avro-keyed cache so the per-comparison lookups in 
RDDBucketIndexPartitioner's sort
+      // comparator and the per-record lookups in 
HoodieTableMetadataUtil#collectColumnRangeMetadata stay O(1).
+      GenericRecord genericRecord = (GenericRecord) 
(record.toIndexedRecord(HoodieAvroSchemaCache.intern(schema.toAvroSchema()), 
PROPERTIES).get()).getData();
+      Object[] values = new Object[columns.length];
+      for (int i = 0; i < columns.length; i++) {
+        values[i] = HoodieAvroUtils.getNestedFieldVal(genericRecord, 
columns[i], true, consistentLogicalTimestampEnabled);
       }
-      return list.toArray();
+      return values;
     } catch (IOException e) {
       throw new HoodieIOException("Unable to read record with key:" + 
record.getKey(), e);
     }
@@ -907,7 +949,7 @@ public class HoodieAvroUtils {
    *
    * @param record  Hoodie record.
    * @param columns Names of the columns to get values.
-   * @param schema  {@link Schema} instance.
+   * @param schema  {@link HoodieSchema} instance.
    * @return Column value.
    */
   public static Object[] 
getSortColumnValuesWithPartitionPathAndRecordKey(HoodieRecord record,
@@ -1108,6 +1150,7 @@ public class HoodieAvroUtils {
     return result;
   }
 
+  @VisibleForTesting
   public static Object rewritePrimaryType(Object oldValue, Schema oldSchema, 
Schema newSchema) {
     // Normalize any java.time form (LocalDate / Instant / LocalDateTime) to 
the Avro primitive
     // form (Integer / Long) before doing any numeric / string conversion. The 
legacy branches
@@ -1274,7 +1317,8 @@ public class HoodieAvroUtils {
    * bytes is the result of BigDecimal.unscaledValue().toByteArray();
    * This is also what Conversions.DecimalConversion.toBytes() outputs inside 
a byte buffer
    */
-  public static Object convertBytesToFixed(byte[] bytes, Schema schema) {
+  @VisibleForTesting
+  static Object convertBytesToFixed(byte[] bytes, Schema schema) {
     LogicalTypes.Decimal decimal = (LogicalTypes.Decimal) 
schema.getLogicalType();
     BigDecimal bigDecimal = convertBytesToBigDecimal(bytes, decimal);
     return DECIMAL_CONVERSION.toFixed(bigDecimal, schema, decimal);
@@ -1286,7 +1330,8 @@ public class HoodieAvroUtils {
    * bytes is the result of BigDecimal.unscaledValue().toByteArray();
    * This is also what Conversions.DecimalConversion.toBytes() outputs inside 
a byte buffer
    */
-  public static BigDecimal convertBytesToBigDecimal(byte[] value, 
LogicalTypes.Decimal decimal) {
+  @VisibleForTesting
+  static BigDecimal convertBytesToBigDecimal(byte[] value, 
LogicalTypes.Decimal decimal) {
     return convertBytesToBigDecimal(value, decimal.getPrecision(), 
decimal.getScale());
   }
 
@@ -1295,6 +1340,23 @@ public class HoodieAvroUtils {
         scale, new MathContext(precision, RoundingMode.HALF_UP));
   }
 
+  /**
+   * Converts a byte array to a BigDecimal using the given decimal schema.
+   *
+   * @param value         the byte array to convert
+   * @param decimalSchema the decimal schema containing precision and scale
+   * @return the resulting BigDecimal
+   * @throws IllegalArgumentException if the schema is not a DECIMAL type
+   */
+  public static BigDecimal convertBytesToBigDecimal(byte[] value, HoodieSchema 
decimalSchema) {
+    ValidationUtils.checkArgument(decimalSchema != null, "Decimal schema 
cannot be null");
+    ValidationUtils.checkArgument(decimalSchema.getType() == 
HoodieSchemaType.DECIMAL,
+        () -> "Schema must be of DECIMAL type, but is " + 
decimalSchema.getType());
+
+    HoodieSchema.Decimal decimal = (HoodieSchema.Decimal) decimalSchema;
+    return convertBytesToBigDecimal(value, decimal.getPrecision(), 
decimal.getScale());
+  }
+
   /**
    * Projects a record to a new schema by performing a shallow copy of fields.
    * Best used for adding or removing top-level metadata fields.
@@ -1403,10 +1465,6 @@ public class HoodieAvroUtils {
 
   /**
    * convert days to Date
-   * <p>
-   * NOTE: This method could only be used in tests
-   *
-   * @VisibleForTesting
    */
   public static java.sql.Date toJavaDate(int days) {
     LocalDate date = LocalDate.ofEpochDay(days);
@@ -1417,10 +1475,6 @@ public class HoodieAvroUtils {
 
   /**
    * convert Date to days
-   * <p>
-   * NOTE: This method could only be used in tests
-   *
-   * @VisibleForTesting
    */
   public static int fromJavaDate(Date date) {
     long millisUtc = date.getTime();
@@ -1477,17 +1531,19 @@ public class HoodieAvroUtils {
   /**
    * Utility method to convert bytes to HoodieRecord using schema and payload 
class.
    */
-  public static <R> HoodieRecord<R> convertToRecord(GenericRecord rec, String 
payloadClazz, String[] preCombineFields, boolean withOperationField) {
+  @VisibleForTesting
+  static <R> HoodieRecord<R> convertToRecord(GenericRecord rec, String 
payloadClazz, String[] preCombineFields, boolean withOperationField) {
     return convertToRecord(rec, payloadClazz, preCombineFields,
         Pair.of(HoodieRecord.RECORD_KEY_METADATA_FIELD, 
HoodieRecord.PARTITION_PATH_METADATA_FIELD),
         withOperationField, Option.empty(), Option.empty());
   }
 
-  public static <R> HoodieRecord<R> convertToRecord(GenericRecord record, 
String payloadClazz,
-                                                                 String[] 
preCombineFields,
-                                                                 boolean 
withOperationField,
-                                                                 
Option<String> partitionName,
-                                                                 
Option<HoodieSchema> schemaWithoutMetaFields) {
+  @VisibleForTesting
+  static <R> HoodieRecord<R> convertToRecord(GenericRecord record, String 
payloadClazz,
+                                                          String[] 
preCombineFields,
+                                                          boolean 
withOperationField,
+                                                          Option<String> 
partitionName,
+                                                          Option<HoodieSchema> 
schemaWithoutMetaFields) {
     return convertToRecord(record, payloadClazz, preCombineFields,
         Pair.of(HoodieRecord.RECORD_KEY_METADATA_FIELD, 
HoodieRecord.PARTITION_PATH_METADATA_FIELD),
         withOperationField, partitionName, schemaWithoutMetaFields);
@@ -1496,6 +1552,7 @@ public class HoodieAvroUtils {
   /**
    * Utility method to convert bytes to HoodieRecord using schema and payload 
class.
    */
+  @VisibleForTesting
   public static <R> HoodieRecord<R> convertToRecord(GenericRecord record, 
String payloadClazz,
                                                                  String[] 
preCombineFields,
                                                                  Pair<String, 
String> recordKeyPartitionPathFieldPair,
@@ -1581,15 +1638,17 @@ public class HoodieAvroUtils {
     return rewriteRecordWithNewSchema(oldRecord, newSchema, 
Collections.EMPTY_MAP, validate);
   }
 
+  @VisibleForTesting
   public static boolean gteqAvro1_9() {
     return AVRO_VERSION != null && StringUtils.compareVersions(AVRO_VERSION, 
"1.9") >= 0;
   }
 
-  public static boolean gteqAvro1_10() {
+  @VisibleForTesting
+  static boolean gteqAvro1_10() {
     return AVRO_VERSION != null && StringUtils.compareVersions(AVRO_VERSION, 
"1.10") >= 0;
   }
 
-  static boolean gteqAvro1_12() {
+  private static boolean gteqAvro1_12() {
     return AVRO_VERSION != null && StringUtils.compareVersions(AVRO_VERSION, 
"1.12") >= 0;
   }
 
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroWrapperUtils.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroWrapperUtils.java
index e168e07b4582..6c7fbd041e8c 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroWrapperUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroWrapperUtils.java
@@ -59,6 +59,16 @@ import static 
org.apache.hudi.common.util.DateTimeUtils.instantToMicros;
 import static org.apache.hudi.common.util.DateTimeUtils.microsToInstant;
 import static 
org.apache.hudi.metadata.HoodieTableMetadataUtil.tryUpcastDecimal;
 
+/**
+ * Wraps and unwraps Java values into the generated Avro {@code *Wrapper} 
records used by column stats
+ * ({@code DateWrapper}, {@code DecimalWrapper}, {@code 
TimestampMicrosWrapper}, and so on).
+ *
+ * <p>Value conversion only: nothing here manipulates schemas. The wrapper 
schema is fixed by the generated
+ * record, and the only schema this class reads is the one it needs to pick 
the right wrapper for a value.
+ * For schema work see {@link org.apache.hudi.common.schema.HoodieSchema} and
+ * {@link org.apache.hudi.common.schema.HoodieSchemaUtils}; for general record 
and value operations see
+ * {@link HoodieAvroUtils}.</p>
+ */
 public class HoodieAvroWrapperUtils {
 
   private static final Conversions.DecimalConversion AVRO_DECIMAL_CONVERSION = 
new Conversions.DecimalConversion();
@@ -201,7 +211,7 @@ public class HoodieAvroWrapperUtils {
     }
   }
 
-  public static Comparable<?> unwrapAvroValueWrapper(Object avroValueWrapper, 
String wrapperClassName) {
+  private static Comparable<?> unwrapAvroValueWrapper(Object avroValueWrapper, 
String wrapperClassName) {
     if (avroValueWrapper == null) {
       return null;
     } else if (DateWrapper.class.getSimpleName().equals(wrapperClassName)) {
@@ -301,7 +311,7 @@ public class HoodieAvroWrapperUtils {
     return 
BytesWrapper.newBuilder(BYTES_WRAPPER_BUILDER_STUB.get()).setValue((ByteBuffer) 
value).build();
   }
 
-  public static Object wrapArray(Comparable<?> value, Function<Comparable<?>, 
Object> wrapper) {
+  private static Object wrapArray(Comparable<?> value, Function<Comparable<?>, 
Object> wrapper) {
     List<Object> avroValues = OrderingValues.getValues((ArrayComparable) 
value).stream().map(wrapper::apply).collect(Collectors.toList());
     return 
ArrayWrapper.newBuilder(ARRAY_WRAPPER_BUILDER_STUB.get()).setWrappedValues(avroValues).build();
   }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/processors/DecimalLogicalTypeProcessor.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/processors/DecimalLogicalTypeProcessor.java
index bfc3ec705b56..2544fbb95794 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/avro/processors/DecimalLogicalTypeProcessor.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/avro/processors/DecimalLogicalTypeProcessor.java
@@ -18,8 +18,8 @@
 
 package org.apache.hudi.common.avro.processors;
 
+import org.apache.hudi.common.avro.HoodieAvroUtils;
 import org.apache.hudi.common.schema.HoodieSchema;
-import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.util.collection.Pair;
 
 import org.apache.avro.LogicalTypes;
@@ -62,7 +62,7 @@ public abstract class DecimalLogicalTypeProcessor extends 
JsonFieldProcessor {
         // Case 2: Object is a number in String format.
         try {
           //encoded big decimal
-          bigDecimal = 
HoodieSchemaUtils.convertBytesToBigDecimal(decodeStringToBigDecimalBytes(obj), 
schema);
+          bigDecimal = 
HoodieAvroUtils.convertBytesToBigDecimal(decodeStringToBigDecimalBytes(obj), 
schema);
         } catch (IllegalArgumentException e) {
           //no-op
         }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java 
b/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java
index f529bbed3ff2..01f01a861c18 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java
@@ -24,11 +24,11 @@ import org.apache.hudi.common.model.DeleteRecord;
 import org.apache.hudi.common.model.HoodieOperation;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.LocalHoodieSchemaCache;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.read.BufferedRecord;
 import org.apache.hudi.common.table.read.DeleteContext;
 import org.apache.hudi.common.util.JavaTypeConverter;
-import org.apache.hudi.common.util.LocalHoodieSchemaCache;
 import org.apache.hudi.common.util.OrderingValues;
 import org.apache.hudi.common.util.collection.ArrayComparable;
 import org.apache.hudi.common.util.collection.Pair;
@@ -65,7 +65,7 @@ public abstract class RecordContext<T> implements 
Serializable {
 
   private final SerializableBiFunction<T, HoodieSchema, String> 
recordKeyExtractor;
   // for encoding and decoding schemas to the spillable map
-  private final LocalHoodieSchemaCache localSchemaCache = 
LocalHoodieSchemaCache.getInstance();
+  private final LocalHoodieSchemaCache localSchemaCache = 
LocalHoodieSchemaCache.create();
 
   @Getter
   protected final JavaTypeConverter typeConverter;
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieAvroRecord.java 
b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieAvroRecord.java
index 284fc655121e..cc8339947230 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieAvroRecord.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieAvroRecord.java
@@ -125,7 +125,7 @@ public class HoodieAvroRecord<T extends 
HoodieRecordPayload> extends HoodieRecor
 
   @Override
   public Object[] getColumnValues(HoodieSchema recordSchema, String[] columns, 
boolean consistentLogicalTimestampEnabled) {
-    return HoodieAvroUtils.getRecordColumnValues(this, columns, 
recordSchema.toAvroSchema(), consistentLogicalTimestampEnabled);
+    return HoodieAvroUtils.getRecordColumnValues(this, columns, recordSchema, 
consistentLogicalTimestampEnabled);
   }
 
   @Override
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
index b54236578d4d..f92c043c8b45 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java
@@ -65,7 +65,7 @@ public class HoodieSchemaCompatibilityChecker {
   /**
    * Message to annotate reader/writer schema pairs that are compatible.
    */
-  public static final String READER_WRITER_COMPATIBLE_MESSAGE = "Reader schema 
can always successfully decode data written using the writer schema.";
+  private static final String READER_WRITER_COMPATIBLE_MESSAGE = "Reader 
schema can always successfully decode data written using the writer schema.";
 
   /**
    * Validates that the provided reader schema can be used to decode avro data
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaRepair.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaRepair.java
index bfe7e15c5eb3..c00470238d1c 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaRepair.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaRepair.java
@@ -24,6 +24,25 @@ import org.apache.hudi.common.util.Option;
 import java.util.ArrayList;
 import java.util.List;
 
+/**
+ * Repairs the logical types of a file schema using the table schema as the 
reference.
+ *
+ * <p>Some writers persist a field without the logical type the table 
declares, or with a coarser one: a
+ * bare {@code long} where the table says local-timestamp, or timestamp-micros 
where the table says
+ * timestamp-millis. This class walks the file schema against the table schema 
and restores the table's
+ * logical type wherever the two only differ that way, returning an interned 
schema. When nothing needs
+ * repairing the input instance is returned as-is, so callers can compare by 
reference.</p>
+ *
+ * <p>This is neither a compatibility check nor schema evolution: no field is 
added, removed or promoted,
+ * and a genuine type mismatch is left alone. For reader/writer compatibility 
use
+ * {@link HoodieSchemaCompatibility}; for evolution use
+ * {@code 
org.apache.hudi.common.schema.internal.utils.AvroSchemaEvolutionUtils}.</p>
+ *
+ * <p>{@link #hasTimestampMillisField(HoodieSchema)} is the cheap pre-check 
used to decide whether the
+ * repair is worth wiring in at all. Its sibling in the metadata-table domain 
is
+ * {@code HoodieTableMetadataUtil#isTimestampMillisField}, which answers the 
same question for one field
+ * schema rather than recursively for a whole table schema.</p>
+ */
 public class HoodieSchemaRepair {
   public static HoodieSchema repairLogicalTypes(HoodieSchema fileSchema, 
HoodieSchema tableSchema) {
     HoodieSchema repairedSchema = repairSchema(fileSchema, tableSchema);
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
index b6bdc27726eb..13ae75f194a6 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaTypePromotion.java
@@ -19,22 +19,30 @@
 package org.apache.hudi.common.schema;
 
 /**
- * Defines type promotion rules for HoodieSchema compatibility checking.
+ * The single table of primitive widening promotions, used by {@link 
HoodieSchemaProjectionChecker}.
  *
- * <p>Type promotion allows a reader schema with a "wider" type to read data
- * written with a "narrower" type. This follows Avro's type promotion 
rules.</p>
- *
- * <p>Supported promotions:
+ * <p>A promotion lets a reader schema with a wider type read data written 
with a narrower one:</p>
  * <ul>
- *   <li>INT → LONG, FLOAT, DOUBLE</li>
- *   <li>LONG → FLOAT, DOUBLE</li>
- *   <li>FLOAT → DOUBLE</li>
- *   <li>STRING ↔ BYTES (bidirectional)</li>
- *   <li>Decimal precision widening: (p2-s2) ≥ (p1-s1) and s2 ≥ s1</li>
+ *   <li>INT -&gt; LONG, FLOAT, DOUBLE</li>
+ *   <li>LONG -&gt; FLOAT, DOUBLE</li>
+ *   <li>FLOAT -&gt; DOUBLE</li>
+ *   <li>STRING &lt;-&gt; BYTES (bidirectional)</li>
+ *   <li>STRING &lt;- any numeric type</li>
+ *   <li>decimal widening, see {@link #isDecimalWidening(HoodieSchema, 
HoodieSchema)}</li>
  * </ul>
- * </p>
  *
- * <p>This class is package-private and used internally by schema 
compatibility checkers.</p>
+ * <p>Logical-type-over-primitive promotions are deliberately NOT in this 
table. A TIMESTAMP reader over a
+ * LONG writer, or a UUID reader over a STRING writer, is accepted by
+ * {@link HoodieSchemaCompatibilityChecker} for reader/writer compatibility, 
but it must not make a bare
+ * long a "compatible projection" of a timestamp: writer-schema deduction 
would then silently drop the
+ * logical type. Compatibility and projection are different questions, so they 
use different tables.</p>
+ *
+ * <p>One more difference is documented rather than resolved:
+ * {@link #isDecimalWidening(HoodieSchema, HoodieSchema)} additionally 
requires the same backing (fixed
+ * versus bytes) and, for fixed, an equal fixed size, whereas the decimal 
check in
+ * {@code HoodieSchemaCompatibilityChecker} compares only precision and 
scale.</p>
+ *
+ * <p>This class is package-private and used only by {@link 
HoodieSchemaProjectionChecker}.</p>
  */
 class HoodieSchemaTypePromotion {
 
@@ -44,7 +52,7 @@ class HoodieSchemaTypePromotion {
 
   /**
    * Checks if the reader type can be promoted from the writer type.
-   * This allows type widening (e.g., int → long) but not narrowing.
+   * This allows type widening (e.g., int -&gt; long) but not narrowing.
    *
    * @param readerType the type in the reader schema
    * @param writerType the type in the writer schema
@@ -90,8 +98,8 @@ class HoodieSchemaTypePromotion {
    * <ul>
    *   <li>Both schemas are decimals with the same underlying type (FIXED or 
BYTES)</li>
    *   <li>Reader precision and scale are equal or wider than writer's</li>
-   *   <li>Specifically: (readerPrecision - readerScale) ≥ (writerPrecision - 
writerScale)</li>
-   *   <li>And: readerScale ≥ writerScale</li>
+   *   <li>Specifically: (readerPrecision - readerScale) &gt;= 
(writerPrecision - writerScale)</li>
+   *   <li>And: readerScale &gt;= writerScale</li>
    * </ul>
    * </p>
    *
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
index 98a2d262a8fa..fea4bc3ae8fa 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java
@@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.internal.HoodieSchemaException;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.VisibleForTesting;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.exception.HoodieException;
 
@@ -31,27 +32,59 @@ import org.apache.avro.JsonProperties;
 import org.apache.avro.Schema;
 import org.apache.avro.generic.GenericData;
 
-import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
- * Utility class for HoodieSchema operations including table schema 
manipulation,
- * compatibility checking, and schema evolution operations.
+ * HoodieSchema-typed structural transforms of table schemas and of the 
well-known Hudi record shapes.
  *
- * <p>This class provides HoodieSchema equivalents of operations found in 
AvroSchemaUtils
- * and HoodieAvroUtils, focusing on table schema management rather than 
record-level operations.</p>
+ * <p>What lives here:</p>
+ * <ul>
+ *   <li>metadata fields: {@link #addMetadataFields(HoodieSchema, boolean)},
+ *       {@link #removeMetadataFields(HoodieSchema)}, {@link 
#createHoodieWriteSchema(String, boolean)},
+ *       {@link #isMetadataField(String)}</li>
+ *   <li>record-key and delete-log schemas: {@link #getRecordKeySchema()},
+ *       {@link #getRecordKeyPartitionPathSchema()}, {@link 
#createDeleteLogSchema(HoodieSchema, List)}</li>
+ *   <li>projection and pruning: {@link 
#generateProjectionSchema(HoodieSchema, List)},
+ *       {@link #projectSchema(HoodieSchema, List)}, {@link 
#pruneDataSchema(HoodieSchema, HoodieSchema, Set)},
+ *       {@link #removeFields(HoodieSchema, Set)}</li>
+ *   <li>appending and merging fields: {@link 
#appendFieldsToSchema(HoodieSchema, List)},
+ *       {@link #appendFieldsToSchemaDedupNested(HoodieSchema, List)},
+ *       {@link #mergeSchemas(HoodieSchema, HoodieSchema)},
+ *       {@link #createNewSchemaFromFieldsWithReference(HoodieSchema, 
List)}</li>
+ *   <li>field copies and defaults: {@link 
#createNewSchemaField(HoodieSchemaField)} (the other
+ *       {@code createNewSchemaField} overloads are validated aliases of 
{@code HoodieSchemaField.of}),
+ *       {@link #toJavaDefaultValue(HoodieSchemaField)}</li>
+ *   <li>nullability: {@link #asNullable(HoodieSchema)}</li>
+ *   <li>naming: {@link #sanitizeName(String)}, {@link 
#getRecordQualifiedName(String)}</li>
+ *   <li>lookups and predicates that need more than {@link HoodieSchema} 
offers on its own:
+ *       {@link #findNestedField(HoodieSchema, String)}, {@link 
#findMissingFields(HoodieSchema, HoodieSchema)},
+ *       {@link #resolveUnionSchema(HoodieSchema, String)}, {@link 
#hasDecimalField(HoodieSchema)}</li>
+ * </ul>
  *
- * <p>All methods in this class delegate to the corresponding Avro utilities 
internally
- * while providing a clean HoodieSchema-based API. This approach ensures 
consistency
- * with existing behavior while enabling migration to HoodieSchema types.</p>
+ * <p>Not here:</p>
+ * <ul>
+ *   <li>value and record operations: {@link 
org.apache.hudi.common.avro.HoodieAvroUtils}</li>
+ *   <li>reader/writer compatibility checks: {@link 
HoodieSchemaCompatibility}</li>
+ *   <li>questions about a single schema: instance methods on {@link 
HoodieSchema}.
+ *       {@link #getFieldSchema(HoodieSchema, String)} and {@link 
#getNestedField(HoodieSchema, String)} are
+ *       argument-checking facades over {@link HoodieSchema#getField(String)} 
and
+ *       {@link HoodieSchema#getNestedField(String)}, not lookups of their 
own</li>
+ *   <li>the field-id InternalSchema (schema-on-read) domain: {@code 
org.apache.hudi.common.schema.internal}</li>
+ * </ul>
+ *
+ * <p>A few methods here still delegate to Avro-typed implementations
+ * ({@link #asNullable(HoodieSchema)}, {@link 
#createNullableSchema(HoodieSchema)},
+ * {@link #projectSchema(HoodieSchema, List)} and the 5-arg
+ * {@link #createNewSchemaField(String, HoodieSchema, String, Object, 
HoodieFieldOrder)}). Those delegations
+ * are being retired under #16639; new methods must be implemented on 
HoodieSchema directly.</p>
  *
  * @since 1.2.0
  */
@@ -110,8 +143,10 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Adds Hudi metadata fields to the given schema.
-   * This is equivalent to HoodieAvroUtils.addMetadataFields() but operates on 
HoodieSchema.
+   * Prepends the Hudi metadata columns ({@code _hoodie_commit_time}, {@code 
_hoodie_commit_seqno},
+   * {@code _hoodie_record_key}, {@code _hoodie_partition_path}, {@code 
_hoodie_file_name}) to the given
+   * schema; {@code withOperationField} additionally adds {@code 
_hoodie_operation}. Metadata columns
+   * already present on the input schema are dropped rather than duplicated.
    *
    * @param schema             the input schema
    * @param withOperationField whether to include operation metadata field
@@ -163,8 +198,7 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Removes Hudi metadata fields from the given schema.
-   * This is equivalent to HoodieAvroUtils.removeMetadataFields() but operates 
on HoodieSchema.
+   * Removes the Hudi metadata columns, including {@code _hoodie_operation}, 
from the given schema.
    *
    * @param schema the input schema with metadata fields
    * @return new HoodieSchema with metadata fields removed
@@ -182,6 +216,11 @@ public final class HoodieSchemaUtils {
   /**
    * Merges two schemas, combining fields from both with conflict resolution.
    *
+   * <p>This is a plain recursive union of the two field lists: source field 
order is preserved and
+   * target-only fields are appended. There is no type promotion and no 
nullability reconciliation. For
+   * schema-evolution reconciliation use {@code 
AvroSchemaEvolutionUtils#reconcileSchema} /
+   * {@code AvroSchemaEvolutionUtils#reconcileSchemaRequirements}.</p>
+   *
    * @param sourceSchema source schema to merge from
    * @param targetSchema target schema to merge into
    * @return new HoodieSchema representing the merged result
@@ -209,8 +248,10 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Creates a nullable version of the given schema (union with null).
-   * This is equivalent to AvroSchemaUtils.createNullableSchema() but operates 
on HoodieSchema.
+   * Creates a nullable version of the given schema (union of null and the 
schema).
+   *
+   * <p>{@link HoodieSchema#createNullable(HoodieSchema)} is the idempotent 
native equivalent and is
+   * preferred; this overload round-trips through Avro and is retained only 
for existing call sites.</p>
    *
    * @param schema the input schema
    * @return new HoodieSchema that allows null values
@@ -226,10 +267,10 @@ public final class HoodieSchemaUtils {
 
   /**
    * Create a new schema by force changing all the fields as nullable.
-   * This is equivalent to AvroSchemaUtils.asNullable() but operates on 
HoodieSchema.
    *
    * @return a new schema with all the fields updated as nullable
    * @throws IllegalArgumentException if schema is null
+   * @see AvroSchemaUtils#asNullable(Schema)
    */
   public static HoodieSchema asNullable(HoodieSchema schema) {
     ValidationUtils.checkArgument(schema != null, "Schema cannot be null");
@@ -240,8 +281,8 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Removes specified fields from a RECORD schema.
-   * This is equivalent to HoodieAvroUtils.removeFields() but operates on 
HoodieSchema.
+   * Removes the named top-level fields from a RECORD schema, preserving the 
record's name, namespace,
+   * error flag and custom properties. Returns the input schema unchanged when 
no field matches.
    *
    * @param schema original schema (must be RECORD type)
    * @param fieldNamesToRemove set of field names to remove
@@ -286,28 +327,32 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Finds fields that are present in the table schema but missing in the 
writer schema.
-   * This is equivalent to AvroSchemaUtils.findMissingFields() but operates on 
HoodieSchemas.
+   * Finds the top-level fields that are present in the table schema but 
missing from the writer schema.
+   * Nested records are not descended into; {@code 
HoodieSchemaCompatibility#checkValidEvolution} is the
+   * one that finds missing fields recursively.
    *
    * @param tableSchema  the complete table schema
    * @param writerSchema the writer schema to check against
    * @return list of HoodieSchemaFields that are missing in writer schema
    * @throws IllegalArgumentException if either schema is null
+   * @see HoodieSchemaCompatibility#checkValidEvolution(HoodieSchema, 
HoodieSchema)
    */
   public static List<HoodieSchemaField> findMissingFields(HoodieSchema 
tableSchema, HoodieSchema writerSchema) {
     return findMissingFields(tableSchema, writerSchema, 
Collections.emptySet());
   }
 
   /**
-   * Finds fields that are present in the table schema but missing in the 
writer schema,
-   * excluding partition columns from the check.
-   * This is equivalent to AvroSchemaUtils.findMissingFields() but operates on 
HoodieSchemas.
+   * Finds the top-level fields that are present in the table schema but 
missing from the writer schema,
+   * skipping the excluded column names (typically the partition columns). 
Nested records are not descended
+   * into; {@code HoodieSchemaCompatibility#checkValidEvolution} is the one 
that finds missing fields
+   * recursively.
    *
    * @param tableSchema    the complete table schema
    * @param writerSchema   the writer schema to check against
    * @param excludeColumns column names to exclude from missing field check
    * @return list of HoodieSchemaFields that are missing in writer schema
    * @throws IllegalArgumentException if either schema is null
+   * @see HoodieSchemaCompatibility#checkValidEvolution(HoodieSchema, 
HoodieSchema)
    */
   public static List<HoodieSchemaField> findMissingFields(HoodieSchema 
tableSchema, HoodieSchema writerSchema,
                                                           Set<String> 
excludeColumns) {
@@ -331,8 +376,8 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Creates a new schema field with the specified properties.
-   * This is equivalent to HoodieAvroUtils.createNewSchemaField() but returns 
HoodieSchemaField.
+   * Alias of {@link HoodieSchemaField#of(String, HoodieSchema, String, 
Object)} with argument validation.
+   * Prefer {@code HoodieSchemaField.of} directly in new code.
    *
    * @param name         field name
    * @param schema       field schema
@@ -350,8 +395,10 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Creates a new schema field with the specified properties, including field 
order.
-   * This is equivalent to HoodieAvroUtils.createNewSchemaField() but returns 
HoodieSchemaField.
+   * Alias of {@link HoodieSchemaField#of(String, HoodieSchema, String, 
Object, HoodieFieldOrder)} with
+   * argument validation. Prefer {@code HoodieSchemaField.of} directly in new 
code; this overload still
+   * round-trips through the Avro-typed {@code 
HoodieAvroUtils#createNewSchemaField} and is being retired
+   * under #16639.
    *
    * @param name         field name
    * @param schema       field schema
@@ -375,8 +422,10 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Creates a new HoodieSchemaField from an existing field.
-   * This is equivalent to HoodieAvroUtils.createNewSchemaField() but returns 
HoodieSchemaField.
+   * Copy factory: returns a new field carrying the same name, schema, doc and 
default value as
+   * {@code field}. It exists because the backing Avro field cannot be shared 
between two records, so a
+   * field taken off one schema has to be copied before being placed on 
another. When building a field from
+   * scratch prefer {@link HoodieSchemaField#of(String, HoodieSchema, String, 
Object)}.
    *
    * @param field the original HoodieSchemaField to create a new field from
    * @return a new HoodieSchemaField with the same properties but properly 
formatted default value
@@ -385,39 +434,15 @@ public final class HoodieSchemaUtils {
     return createNewSchemaField(field.name(), field.schema(), 
field.doc().orElse(null), field.defaultVal().orElse(null));
   }
 
-  /**
-   * Converts a byte array to a BigDecimal using the given decimal schema.
-   *
-   * @param value         the byte array to convert
-   * @param decimalSchema the decimal schema containing precision and scale
-   * @return the resulting BigDecimal
-   * @throws IllegalArgumentException if the schema is not a DECIMAL type
-   */
-  public static BigDecimal convertBytesToBigDecimal(byte[] value, HoodieSchema 
decimalSchema) {
-    ValidationUtils.checkArgument(decimalSchema != null, "Decimal schema 
cannot be null");
-    ValidationUtils.checkArgument(decimalSchema.getType() == 
HoodieSchemaType.DECIMAL,
-        () -> "Schema must be of DECIMAL type, but is " + 
decimalSchema.getType());
-
-    HoodieSchema.Decimal decimal = (HoodieSchema.Decimal) decimalSchema;
-    return convertBytesToBigDecimal(value, decimal.getPrecision(), 
decimal.getScale());
-  }
-
-  /**
-   * Converts a byte array to a BigDecimal with the specified precision and 
scale.
-   * Delegates to {@link HoodieAvroUtils#convertBytesToBigDecimal(byte[], int, 
int)}.
-   *
-   * @param value     the byte array to convert
-   * @param precision the precision of the decimal
-   * @param scale     the scale of the decimal
-   * @return the resulting BigDecimal
-   */
-  public static BigDecimal convertBytesToBigDecimal(byte[] value, int 
precision, int scale) {
-    return HoodieAvroUtils.convertBytesToBigDecimal(value, precision, scale);
-  }
-
   /**
    * Gets a field (including nested fields) from the schema using dot notation.
-   * This method delegates to {@link HoodieSchema#getNestedField(String)}.
+   * This method is a null-checking facade over {@link 
HoodieSchema#getNestedField(String)}: it returns the
+   * leaf field itself, paired with its canonical dotted path.
+   * <p>
+   * Not to be confused with {@link #findNestedField(HoodieSchema, String)}, 
which returns a synthesized
+   * lineage sub-schema rather than the leaf, and which does not understand 
{@code list.element} /
+   * {@code key_value} path segments.
+   * </p>
    * <p>
    * Supports nested field access using dot notation. For example:
    * <ul>
@@ -443,6 +468,12 @@ public final class HoodieSchemaUtils {
   /**
    * Generates a projection schema from the original schema, including only 
the specified fields.
    *
+   * <p>Field names are matched case-insensitively and the projected field 
keeps the schema's original casing:
+   * Avro field names are case-sensitive while Hive lowercases column 
projections before they reach the reader
+   * (see {@code HoodieRealtimeRecordReaderUtils#generateProjectionSchema}), 
so both sides are lowercased for the
+   * lookup. A schema with two fields that differ only in case cannot be 
projected and fails on the duplicate
+   * lowercase key.</p>
+   *
    * @param originalSchema the source schema
    * @param fieldNames     the list of field names to include in the projection
    * @return new HoodieSchema containing only the specified fields
@@ -606,6 +637,13 @@ public final class HoodieSchemaUtils {
 
   /**
    * Get gets a field from a record, works on nested fields as well (if you 
provide the whole name, eg: toplevel.nextlevel.child)
+   * <p>
+   * Returns a synthesized lineage sub-schema, not the leaf field: {@code 
b.z.z2} comes back as
+   * {@code b:record(z:record(z2))}. That shape is what {@link 
#appendFieldsToSchemaDedupNested} consumes.
+   * Only record nesting is understood here - {@code list.element} and {@code 
key_value} path segments are
+   * not. Use {@link #getNestedField(HoodieSchema, String)} or {@link 
HoodieSchema#getNestedField(String)}
+   * when the leaf field and its canonical path are what is wanted.
+   * </p>
    * @return the field, including its lineage.
    * For example, if you have a schema: record(a:int, b:record(x:int, y:long, 
z:record(z1: int, z2: float, z3: double), c:bool)
    * "fieldName" | output
@@ -676,47 +714,11 @@ public final class HoodieSchemaUtils {
   }
 
   /**
-   * Converts field values for specific data types with logical type handling.
-   * This is equivalent to HoodieAvroUtils.convertValueForSpecificDataTypes() 
but operates on HoodieSchema.
-   * <p>
-   * Handles special conversions for Avro logical types:
-   * <ul>
-   *   <li>Date type - converts epoch day integer to LocalDate</li>
-   *   <li>Timestamp types - converts epoch milliseconds/microseconds to 
Timestamp</li>
-   *   <li>Decimal type - converts bytes/fixed to BigDecimal</li>
-   * </ul>
-   *
-   * @param fieldSchema the field schema
-   * @param fieldValue the field value to convert
-   * @param consistentLogicalTimestampEnabled whether to use consistent 
logical timestamp handling
-   * @return converted value for logical types, or original value
-   * @throws IllegalStateException if fieldValue is null but schema is not 
nullable
-   * @since 1.2.0
-   */
-  public static Object convertValueForSpecificDataTypes(HoodieSchema 
fieldSchema,
-                                                        Object fieldValue,
-                                                        boolean 
consistentLogicalTimestampEnabled) {
-    if (fieldSchema == null) {
-      return fieldValue;
-    } else if (fieldValue == null) {
-      ValidationUtils.checkState(fieldSchema.isNullable(),
-          "Field value is null but schema is not nullable");
-      return null;
-    }
-
-    // Delegate to existing Avro utility
-    return HoodieAvroUtils.convertValueForSpecificDataTypes(
-        fieldSchema.toAvroSchema(),
-        fieldValue,
-        consistentLogicalTimestampEnabled
-    );
-  }
-
-  /**
-   * Fetch schema for record key and partition path.
-   * This is equivalent to HoodieAvroUtils.getRecordKeyPartitionPathSchema() 
but returns HoodieSchema.
+   * Builds the two-column {@code HoodieRecordKey} record holding {@code 
_hoodie_record_key} and
+   * {@code _hoodie_partition_path}, both nullable strings.
    *
    * @return HoodieSchema containing record key and partition path fields
+   * @see #getRecordKeySchema()
    */
   public static HoodieSchema getRecordKeyPartitionPathSchema() {
     List<HoodieSchemaField> toBeAddedFields = new ArrayList<>(2);
@@ -731,11 +733,41 @@ public final class HoodieSchemaUtils {
     return HoodieSchema.createRecord("HoodieRecordKey", "", "", false, 
toBeAddedFields);
   }
 
+  /**
+   * Schema of a native delete log record: the record key plus the ordering 
fields, which are
+   * always nullable (see the comment in the body).
+   */
+  public static HoodieSchema createDeleteLogSchema(HoodieSchema tableSchema, 
List<String> orderingFieldNames) {
+    // Native delete logs store only the record key plus optional ordering 
values, so ordering fields in
+    // the delete-log schema must always be nullable even when the table 
schema marks them required.
+    // A delete record such as HoodieEmptyRecord may carry 
OrderingValues.getDefault() as an in-memory
+    // sentinel rather than a real field value. Persist NULL for that missing 
value so readers can map it
+    // back to the default ordering without confusing it with a real business 
value such as 0.
+    List<HoodieSchemaField> fields = Stream.concat(
+        Stream.of(createNewSchemaField(
+            HoodieRecord.RECORD_KEY_METADATA_FIELD, 
HoodieSchema.create(HoodieSchemaType.STRING), null, null)),
+        orderingFieldNames.stream().map(orderingFieldName -> 
tableSchema.getField(orderingFieldName)
+            .map(field -> createNewSchemaField(
+                field.name(), HoodieSchema.createNullable(field.schema()), 
field.doc().orElse(null), HoodieSchema.NULL_VALUE))
+            .orElseThrow(() ->
+                new IllegalArgumentException("Ordering field " + 
orderingFieldName + " not found in table schema"))))
+        .collect(Collectors.toList());
+    return HoodieSchema.createRecord("hudi_delete_log_record", null, null, 
fields);
+  }
+
   /**
    * Fetches projected schema given list of fields to project. The field can 
be nested in format `a.b.c` where a is
-   * the top level field, b is at second level and so on.
+   * the top level field, b is at second level and so on. Field names are 
matched case-sensitively.
    * This is equivalent to {@link HoodieAvroUtils#projectSchema(Schema, List)} 
but operates on HoodieSchema.
    *
+   * <p>The two sibling projection helpers differ:</p>
+   * <ul>
+   *   <li>{@link #generateProjectionSchema(HoodieSchema, List)} - top-level 
fields only, matched
+   *       case-insensitively</li>
+   *   <li>{@link #pruneDataSchema(HoodieSchema, HoodieSchema, Set)} - prunes 
to the shape of a required
+   *       schema instead of to a list of names</li>
+   * </ul>
+   *
    * @param fileSchema the original schema
    * @param fields     list of fields to project
    * @return projected schema containing only specified fields
@@ -769,47 +801,27 @@ public final class HoodieSchemaUtils {
   }
 
   public static boolean hasDecimalField(HoodieSchema schema) {
-    return hasDecimalWithCondition(schema, unused -> true);
-  }
-
-  /**
-   * Checks whether the provided schema contains a decimal with a precision 
less than or equal to 18,
-   * which allows the decimal to be stored as int/long instead of a fixed size 
byte array in
-   * <a 
href="https://github.com/apache/parquet-format/blob/master/LogicalTypes.md";>parquet
 logical types</a>
-   * @param schema the input schema to search
-   * @return true if the schema contains a small precision decimal field and 
false otherwise
-   */
-  public static boolean hasSmallPrecisionDecimalField(HoodieSchema schema) {
-    return hasDecimalWithCondition(schema, 
HoodieSchemaUtils::isSmallPrecisionDecimalField);
-  }
-
-  private static boolean hasDecimalWithCondition(HoodieSchema schema, 
Function<HoodieSchema.Decimal, Boolean> condition) {
     switch (schema.getType()) {
       case RECORD:
         for (HoodieSchemaField field : schema.getFields()) {
-          if (hasDecimalWithCondition(field.schema(), condition)) {
+          if (hasDecimalField(field.schema())) {
             return true;
           }
         }
         return false;
       case ARRAY:
-        return hasDecimalWithCondition(schema.getElementType(), condition);
+        return hasDecimalField(schema.getElementType());
       case MAP:
-        return hasDecimalWithCondition(schema.getValueType(), condition);
+        return hasDecimalField(schema.getValueType());
       case UNION:
-        return hasDecimalWithCondition(schema.getNonNullType(), condition);
+        return hasDecimalField(schema.getNonNullType());
       case DECIMAL:
-        HoodieSchema.Decimal decimal = (HoodieSchema.Decimal) schema;
-        return condition.apply(decimal);
+        return true;
       default:
         return false;
     }
   }
 
-  private static boolean isSmallPrecisionDecimalField(HoodieSchema.Decimal 
decimal) {
-    return decimal.getPrecision() <= 18;
-  }
-
   /**
    * Resolves a union schema by finding the schema matching the given full 
name.
    * Handles both simple nullable unions (null + non-null) and complex unions 
with multiple types.
@@ -850,6 +862,7 @@ public final class HoodieSchemaUtils {
     return nonNullType;
   }
 
+  @VisibleForTesting
   public static String addMetadataColumnTypes(String hiveColumnTypes) {
     return "string,string,string,string,string," + hiveColumnTypes;
   }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemas.java 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemas.java
deleted file mode 100644
index 917988dd8aa4..000000000000
--- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemas.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you 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 org.apache.hudi.common.schema;
-
-import org.apache.hudi.common.model.HoodieRecord;
-
-import java.util.List;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import static 
org.apache.hudi.common.schema.HoodieSchemaUtils.createNewSchemaField;
-
-/**
- * Factory class for {@link HoodieSchema}.
- */
-public class HoodieSchemas {
-
-  public static HoodieSchema createDeleteLogSchema(HoodieSchema tableSchema, 
List<String> orderingFieldNames) {
-    // Native delete logs store only the record key plus optional ordering 
values, so ordering fields in
-    // the delete-log schema must always be nullable even when the table 
schema marks them required.
-    // A delete record such as HoodieEmptyRecord may carry 
OrderingValues.getDefault() as an in-memory
-    // sentinel rather than a real field value. Persist NULL for that missing 
value so readers can map it
-    // back to the default ordering without confusing it with a real business 
value such as 0.
-    List<HoodieSchemaField> fields = Stream.concat(
-        Stream.of(createNewSchemaField(
-            HoodieRecord.RECORD_KEY_METADATA_FIELD, 
HoodieSchema.create(HoodieSchemaType.STRING), null, null)),
-        orderingFieldNames.stream().map(orderingFieldName -> 
tableSchema.getField(orderingFieldName)
-            .map(field -> createNewSchemaField(
-                field.name(), HoodieSchema.createNullable(field.schema()), 
field.doc().orElse(null), HoodieSchema.NULL_VALUE))
-            .orElseThrow(() ->
-                new IllegalArgumentException("Ordering field " + 
orderingFieldName + " not found in table schema"))))
-        .collect(Collectors.toList());
-    return HoodieSchema.createRecord("hudi_delete_log_record", null, null, 
fields);
-  }
-}
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/util/LocalHoodieSchemaCache.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/LocalHoodieSchemaCache.java
similarity index 90%
rename from 
hudi-common/src/main/java/org/apache/hudi/common/util/LocalHoodieSchemaCache.java
rename to 
hudi-common/src/main/java/org/apache/hudi/common/schema/LocalHoodieSchemaCache.java
index 6a11d88e7718..2012ca890c49 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/util/LocalHoodieSchemaCache.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/LocalHoodieSchemaCache.java
@@ -16,9 +16,9 @@
  * limitations under the License.
  */
 
-package org.apache.hudi.common.util;
+package org.apache.hudi.common.schema;
 
-import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.util.Option;
 
 import javax.annotation.concurrent.NotThreadSafe;
 
@@ -46,7 +46,10 @@ public class LocalHoodieSchemaCache implements Serializable {
     this.schemaToVersionId = new HashMap<>();
   }
 
-  public static LocalHoodieSchemaCache getInstance() {
+  /**
+   * Returns a new, empty cache. Each caller owns its own version-id space.
+   */
+  public static LocalHoodieSchemaCache create() {
     return new LocalHoodieSchemaCache();
   }
 
@@ -63,4 +66,4 @@ public class LocalHoodieSchemaCache implements Serializable {
   public Option<HoodieSchema> getSchema(Integer versionId) {
     return Option.ofNullable(this.versionIdToSchema.get(versionId));
   }
-}
\ No newline at end of file
+}
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/convert/InternalSchemaConverter.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/convert/InternalSchemaConverter.java
index 69c97e7364c6..7b39c934e28e 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/convert/InternalSchemaConverter.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/convert/InternalSchemaConverter.java
@@ -173,7 +173,7 @@ public class InternalSchemaConverter {
    * if we compare a schema that has not been converted to internal schema
    * at any stage, the difference in ordering can cause issues. To resolve 
this,
    * we order null to be first for any HoodieSchema that enters into hudi.
-   * AvroSchemaUtils.isProjectionOfInternal uses index based comparison for 
unions.
+   * {@code HoodieSchemaProjectionChecker}'s isProjectionOfInternal uses index 
based comparison for unions.
    * Spark and flink don't support complex unions so this would not be an issue
    * but for the metadata table HoodieMetadata.avsc uses a trick where we have 
a bunch of
    * different types wrapped in record for col stats.
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieNativeLogDeleteBlock.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieNativeLogDeleteBlock.java
index 031bbe609929..8dee4a27471d 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieNativeLogDeleteBlock.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/log/block/HoodieNativeLogDeleteBlock.java
@@ -26,7 +26,7 @@ import org.apache.hudi.common.model.HoodieFileFormat;
 import org.apache.hudi.common.model.HoodieLogFile;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.HoodieSchema;
-import org.apache.hudi.common.schema.HoodieSchemas;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.table.read.BufferedRecord;
 import org.apache.hudi.common.table.read.BufferedRecords;
 import org.apache.hudi.common.util.Option;
@@ -69,7 +69,7 @@ public class HoodieNativeLogDeleteBlock extends 
HoodieDeleteBlock {
     super(Option.empty(), null, true, getContentLocation(storage, logFile), 
header, footer);
     this.storage = storage;
     this.logFile = logFile;
-    this.deleteLogSchema = 
HoodieSchemas.createDeleteLogSchema(getSchemaFromHeader(), orderingFieldNames);
+    this.deleteLogSchema = 
HoodieSchemaUtils.createDeleteLogSchema(getSchemaFromHeader(), 
orderingFieldNames);
     this.orderingFieldNames = orderingFieldNames;
     this.partitionPath = partitionPath;
     this.props = props == null ? new Properties() : props;
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/LsmFileIterators.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/LsmFileIterators.java
index 4a43f1671948..1f1f1fa88625 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/LsmFileIterators.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/LsmFileIterators.java
@@ -25,7 +25,7 @@ import org.apache.hudi.common.model.HoodieBaseFile;
 import org.apache.hudi.common.model.HoodieLogFile;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.HoodieSchema;
-import org.apache.hudi.common.schema.HoodieSchemas;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.TableSchemaResolver;
 import org.apache.hudi.common.table.read.BufferedRecord;
@@ -173,7 +173,7 @@ final class LsmFileIterators {
       StoragePath storagePath,
       long fileSize,
       List<String> orderingFieldNames) throws IOException {
-    HoodieSchema deleteLogSchema = HoodieSchemas.createDeleteLogSchema(
+    HoodieSchema deleteLogSchema = HoodieSchemaUtils.createDeleteLogSchema(
         readerContext.getSchemaHandler().getTableSchema(), orderingFieldNames);
     ClosableIterator<T> recordIterator = createFileRecordIterator(
         readerContext, storage, pathInfo, storagePath, fileSize, 
deleteLogSchema, deleteLogSchema);
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java 
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java
index 810cbecacc03..6f100d65643e 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieMetadataPayload.java
@@ -24,7 +24,6 @@ import org.apache.hudi.avro.model.HoodieMetadataFileInfo;
 import org.apache.hudi.avro.model.HoodieMetadataRecord;
 import org.apache.hudi.avro.model.HoodieRecordIndexInfo;
 import org.apache.hudi.avro.model.HoodieSecondaryIndexInfo;
-import org.apache.hudi.common.avro.AvroSchemaCache;
 import org.apache.hudi.common.fs.FSUtils;
 import org.apache.hudi.common.model.EmptyHoodieRecordPayload;
 import org.apache.hudi.common.model.HoodieAvroRecord;
@@ -109,8 +108,9 @@ public class HoodieMetadataPayload implements 
HoodieRecordPayload<HoodieMetadata
   // Note: Variable is unused, but caching is required.
   private static final HoodieSchema HOODIE_METADATA_SCHEMA = 
HoodieSchemaCache.intern(
       HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema()));
-  // Cache the Avro schema reference for O(1) equality checks during 
Avro.Schema -> HoodieSchema migration
-  private static final Schema HOODIE_METADATA_AVRO_SCHEMA = 
AvroSchemaCache.intern(HoodieMetadataRecord.getClassSchema());
+  // Held for the reference-equality fast path in getInsertValue: 
getClassSchema() returns the
+  // generated class's SCHEMA$ singleton, so no interning is needed.
+  private static final Schema HOODIE_METADATA_AVRO_SCHEMA = 
HoodieMetadataRecord.getClassSchema();
   /**
    * Field offsets when metadata fields are present
    */
@@ -425,7 +425,7 @@ public class HoodieMetadataPayload implements 
HoodieRecordPayload<HoodieMetadata
     }
 
     // TODO: feature(schema): Swap this over to HOODIE_METADATA_SCHEMA after 
HoodieRecordPayload implementations are using HoodieSchema
-    // Uses cached Avro schema reference for O(1) equality check.
+    // Reference equality against the generated class's SCHEMA$ singleton held 
in HOODIE_METADATA_AVRO_SCHEMA.
     if (schema == null || schema == HOODIE_METADATA_AVRO_SCHEMA) {
       // If the schema is same or none is provided, we can return the record 
directly
       HoodieMetadataRecord record = new HoodieMetadataRecord(key, type, 
filesystemMetadata, bloomFilterMetadata,
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
 
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
index 202a2e6a0b33..6c87d584765a 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieTableMetadataUtil.java
@@ -328,7 +328,7 @@ public class HoodieTableMetadataUtil {
     Object fieldValue;
     HoodieSchemaType fieldSchemaType = fieldSchema.getType();
     if (record.getRecordType() == HoodieRecordType.AVRO) {
-      fieldValue = HoodieAvroUtils.getRecordColumnValues(record, new 
String[]{fieldName}, recordSchema.toAvroSchema(), false)[0];
+      fieldValue = HoodieAvroUtils.getRecordColumnValues(record, new 
String[]{fieldName}, recordSchema, false)[0];
       if (fieldValue != null && fieldSchemaType.equals(HoodieSchemaType.DATE)) 
{
         fieldValue = java.sql.Date.valueOf(fieldValue.toString());
       }
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
index 30c726c094a8..d59171dd653a 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java
@@ -63,7 +63,9 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieRecordPayload;
 import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
 import org.apache.hudi.common.model.RewriteAvroPayload;
+import org.apache.hudi.common.schema.HoodieAvroSchemaCache;
 import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaType;
 import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.exception.HoodieException;
@@ -124,6 +126,7 @@ import static 
org.apache.hudi.common.schema.HoodieSchemaUtils.sanitizeName;
 import static org.apache.hudi.common.util.StringUtils.getUTF8Bytes;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -566,6 +569,124 @@ public class TestHoodieAvroUtils {
     assertEquals(FIXTURE_EPOCH_MICROS, 
HoodieAvroUtils.convertValueForAvroLogicalTypes(LOCAL_TS_MICROS_SCHEMA, 
FIXTURE_LOCAL_DT_MICROS, false));
   }
 
+  @Test
+  public void testConvertValueForSpecificDataTypes_NullSchema() {
+    // Test with null schema - should return value unchanged
+    String testValue = "test_value";
+    Object result = HoodieAvroUtils.convertValueForSpecificDataTypes(null, 
testValue, false);
+    assertEquals(testValue, result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_NullValue_NullableSchema() {
+    // Test with null value and nullable schema - should return null
+    Schema nullableIntSchema = 
HoodieSchema.createNullable(HoodieSchema.create(HoodieSchemaType.INT)).toAvroSchema();
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(nullableIntSchema, null, 
false);
+    assertNull(result);
+  }
+
+  @Test
+  public void 
testConvertValueForSpecificDataTypes_NullValue_NonNullableSchema() {
+    // Test with null value and non-nullable schema - should throw exception
+    Schema nonNullableSchema = Schema.create(Schema.Type.STRING);
+    assertThrows(IllegalStateException.class, () ->
+        HoodieAvroUtils.convertValueForSpecificDataTypes(nonNullableSchema, 
null, false));
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_DateLogicalType() {
+    // Test value: epoch days for 2023-01-01
+    int epochDays = 19358;
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(DATE_SCHEMA, epochDays, false);
+    assertNotNull(result);
+    assertTrue(result instanceof LocalDate);
+    assertEquals(LocalDate.of(2023, 1, 1), result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_TimestampMillis_Enabled() {
+    // Test value: milliseconds for 2023-01-01 00:00:00
+    long millis = 1672560000000L;
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(TS_MILLIS_SCHEMA, millis, 
true);
+    assertNotNull(result);
+    assertTrue(result instanceof Timestamp);
+    assertEquals(new Timestamp(millis), result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_TimestampMillis_Disabled() {
+    long millis = 1672560000000L;
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(TS_MILLIS_SCHEMA, millis, 
false);
+    assertEquals(millis, result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_TimestampMicros_Enabled() {
+    // Test value: microseconds for 2023-01-01 00:00:00
+    long micros = 1672560000000000L;
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(TS_MICROS_SCHEMA, micros, 
true);
+    assertNotNull(result);
+    assertTrue(result instanceof Timestamp);
+    assertEquals(new Timestamp(micros / 1000), result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_DecimalBytes() {
+    // Create decimal schema with precision=10, scale=2
+    Schema decimalSchema = HoodieSchema.createDecimal(10, 2).toAvroSchema();
+
+    // Create test value: 1234.56
+    BigDecimal expectedDecimal = new BigDecimal("1234.56");
+    ByteBuffer byteBuffer = 
ByteBuffer.wrap(expectedDecimal.unscaledValue().toByteArray());
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(decimalSchema, byteBuffer, 
false);
+    assertNotNull(result);
+    assertTrue(result instanceof BigDecimal);
+    assertEquals(expectedDecimal, result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_NonLogicalType() {
+    // Test with non-logical type (plain string) - should return unchanged
+    Schema stringSchema = Schema.create(Schema.Type.STRING);
+    String testValue = "test_string";
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(stringSchema, testValue, 
false);
+    assertEquals(testValue, result);
+  }
+
+  @Test
+  public void testConvertValueForSpecificDataTypes_UnionWithNull() {
+    // Test with union type containing null
+    Schema nullableDateSchema = 
HoodieSchema.createNullable(HoodieSchema.createDate()).toAvroSchema();
+
+    // Test with non-null value
+    int epochDays = 19358; // 2023-01-01
+    Object result = 
HoodieAvroUtils.convertValueForSpecificDataTypes(nullableDateSchema, epochDays, 
false);
+    assertNotNull(result);
+    assertTrue(result instanceof LocalDate);
+    assertEquals(LocalDate.of(2023, 1, 1), result);
+  }
+
+  @Test
+  public void testConvertBytesToBigDecimalWithHoodieSchema() {
+    HoodieSchema decimalSchema = HoodieSchema.createDecimal(10, 2);
+    BigDecimal expected = new BigDecimal("1234.56");
+    assertEquals(expected,
+        
HoodieAvroUtils.convertBytesToBigDecimal(expected.unscaledValue().toByteArray(),
 decimalSchema));
+  }
+
+  @Test
+  public void testConvertBytesToBigDecimalWithNonDecimalHoodieSchema() {
+    HoodieSchema stringSchema = HoodieSchema.create(HoodieSchemaType.STRING);
+    assertThrows(IllegalArgumentException.class, () ->
+        HoodieAvroUtils.convertBytesToBigDecimal(new byte[] {0x01}, 
stringSchema));
+  }
+
+  @Test
+  public void testConvertBytesToBigDecimalWithNullHoodieSchema() {
+    assertThrows(IllegalArgumentException.class, () ->
+        HoodieAvroUtils.convertBytesToBigDecimal(new byte[] {0x01}, 
(HoodieSchema) null));
+  }
+
   /**
    * Cross-Avro-version invariant for ordering-value extraction: a record 
whose timestamp/date field
    * holds the java.time form (Avro 1.12.1 fast reader) must yield the same 
comparable ordering value
@@ -955,6 +1076,49 @@ public class TestHoodieAvroUtils {
     }
   }
 
+  @Test
+  void testGetRecordColumnValues() {
+    Schema schema = new Schema.Parser().parse(SCHEMA_WITH_NESTED_FIELD_STR);
+    GenericRecord student = new 
GenericData.Record(schema.getField("student").schema());
+    student.put("lastnameNested", "nested-last");
+    GenericRecord record = new GenericData.Record(schema);
+    record.put("firstname", "first");
+    record.put("lastname", "last");
+    record.put("student", student);
+    HoodieRecordPayload avroPayload = new RewriteAvroPayload(record);
+    HoodieAvroRecord avroRecord = new HoodieAvroRecord(new 
HoodieKey("record1", "partition1"), avroPayload);
+    HoodieSchema hoodieSchema = 
HoodieSchema.parse(SCHEMA_WITH_NESTED_FIELD_STR);
+
+    // A nested column resolves through the intermediate record; a missing 
column yields null rather than
+    // throwing because getRecordColumnValues hardcodes returnNullIfNotFound.
+    assertArrayEquals(new Object[] {"first", "nested-last", null}, 
HoodieAvroUtils.getRecordColumnValues(
+        avroRecord, new String[] {"firstname", "student.lastnameNested", 
"missing_col"}, hoodieSchema, false));
+
+    // A null intermediate record and a non-record intermediate both yield 
null instead of throwing.
+    record.put("student", null);
+    assertArrayEquals(new Object[] {null, null}, 
HoodieAvroUtils.getRecordColumnValues(
+        avroRecord, new String[] {"student.lastnameNested", 
"firstname.nested"}, hoodieSchema, false));
+  }
+
+  @Test
+  void testGetRecordColumnValuesInternsSchema() {
+    HoodieSchema interned = HoodieAvroSchemaCache.intern(new 
Schema.Parser().parse(EXAMPLE_SCHEMA));
+    GenericRecord record = new GenericData.Record(interned.toAvroSchema());
+    record.put("timestamp", 3.5);
+    record.put("_row_key", "record1");
+    record.put("non_pii_col", "val1");
+    record.put("pii_col", "val2");
+    HoodieAvroRecord avroRecord = new HoodieAvroRecord(
+        new HoodieKey("record1", "partition1"), new 
OverwriteWithLatestAvroPayload(record, 0));
+
+    // A freshly parsed, equal-but-distinct schema must resolve to the 
interned instance so the payload hands back
+    // the record it holds; without the intern it re-serializes and the string 
comes back as Utf8, not String.
+    Object[] columnValues = HoodieAvroUtils.getRecordColumnValues(
+        avroRecord, new String[] {"non_pii_col"}, 
HoodieSchema.parse(EXAMPLE_SCHEMA), false);
+    assertInstanceOf(String.class, columnValues[0]);
+    assertEquals("val1", columnValues[0]);
+  }
+
   private static Stream<Arguments> 
recordNeedsRewriteForExtendedAvroTypePromotion() {
     Schema decimal1 = LogicalTypes.decimal(12, 
2).addToSchema(Schema.create(Schema.Type.BYTES));
     Schema decimal2 = LogicalTypes.decimal(10, 
2).addToSchema(Schema.create(Schema.Type.BYTES));
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
index d2eee99d31cd..1862f2c80a33 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java
@@ -31,11 +31,7 @@ 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.nio.ByteBuffer;
-import java.sql.Timestamp;
-import java.time.LocalDate;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -1047,6 +1043,11 @@ public class TestHoodieSchemaUtils {
     assertTrue(fieldNames1.contains("_row_key"));
     assertTrue(fieldNames1.contains("timestamp"));
 
+    // Field names are matched case-insensitively; HiveHoodieReaderContext 
lowercases names before calling this.
+    HoodieSchema schema2 = 
HoodieSchemaUtils.generateProjectionSchema(originalSchema, 
Arrays.asList("_ROW_KEY"));
+    assertEquals(1, schema2.getFields().size());
+    assertEquals("_row_key", schema2.getFields().get(0).name());
+
     Throwable caughtException = assertThrows(HoodieException.class, () ->
         HoodieSchemaUtils.generateProjectionSchema(originalSchema, 
Arrays.asList("_row_key", "timestamp", "fake_field")));
     assertTrue(caughtException.getMessage().contains("Field fake_field not 
found in log schema. Query cannot proceed!"));
@@ -1306,115 +1307,6 @@ public class TestHoodieSchemaUtils {
     assertTrue(HoodieSchemaCompatibility.isSchemaCompatible(projectedSchema, 
expectedSchema, false));
   }
 
-  @Test
-  public void testConvertValueForSpecificDataTypes_NullSchema() {
-    // Test with null schema - should return value unchanged
-    String testValue = "test_value";
-    Object result = HoodieSchemaUtils.convertValueForSpecificDataTypes(null, 
testValue, false);
-    assertEquals(testValue, result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_NullValue_NullableSchema() {
-    // Test with null value and nullable schema - should return null
-    HoodieSchema nullableIntSchema = 
HoodieSchema.createNullable(HoodieSchema.create(HoodieSchemaType.INT));
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(nullableIntSchema, null, 
false);
-    assertNull(result);
-  }
-
-  @Test
-  public void 
testConvertValueForSpecificDataTypes_NullValue_NonNullableSchema() {
-    // Test with null value and non-nullable schema - should throw exception
-    HoodieSchema nonNullableSchema = 
HoodieSchema.create(HoodieSchemaType.STRING);
-    assertThrows(IllegalStateException.class, () ->
-        HoodieSchemaUtils.convertValueForSpecificDataTypes(nonNullableSchema, 
null, false));
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_DateLogicalType() {
-    // Create date schema
-    HoodieSchema dateSchema = HoodieSchema.createDate();
-
-    // Test value: epoch days for 2023-01-01
-    int epochDays = 19358;
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(dateSchema, epochDays, 
false);
-    assertNotNull(result);
-    assertTrue(result instanceof LocalDate);
-    assertEquals(LocalDate.of(2023, 1, 1), result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_TimestampMillis_Enabled() {
-    // Create timestamp-millis schema
-    HoodieSchema timestampMillisSchema = HoodieSchema.createTimestampMillis();
-
-    // Test value: milliseconds for 2023-01-01 00:00:00
-    long millis = 1672560000000L;
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(timestampMillisSchema, 
millis, true);
-    assertNotNull(result);
-    assertTrue(result instanceof Timestamp);
-    assertEquals(new Timestamp(millis), result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_TimestampMillis_Disabled() {
-    // Create timestamp-millis schema
-    HoodieSchema timestampMillisSchema = HoodieSchema.createTimestampMillis();
-    long millis = 1672560000000L;
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(timestampMillisSchema, 
millis, false);
-    assertEquals(millis, result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_TimestampMicros_Enabled() {
-    // Create timestamp-micros schema
-    HoodieSchema timestampMicrosSchema = HoodieSchema.createTimestampMicros();
-
-    // Test value: microseconds for 2023-01-01 00:00:00
-    long micros = 1672560000000000L;
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(timestampMicrosSchema, 
micros, true);
-    assertNotNull(result);
-    assertTrue(result instanceof Timestamp);
-    assertEquals(new Timestamp(micros / 1000), result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_DecimalBytes() {
-    // Create decimal schema with precision=10, scale=2
-    HoodieSchema decimalSchema = HoodieSchema.createDecimal(10, 2);
-
-    // Create test value: 1234.56
-    BigDecimal expectedDecimal = new BigDecimal("1234.56");
-    ByteBuffer byteBuffer = 
ByteBuffer.wrap(expectedDecimal.unscaledValue().toByteArray());
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(decimalSchema, byteBuffer, 
false);
-    assertNotNull(result);
-    assertTrue(result instanceof BigDecimal);
-    assertEquals(expectedDecimal, result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_NonLogicalType() {
-    // Test with non-logical type (plain string) - should return unchanged
-    HoodieSchema stringSchema = HoodieSchema.create(HoodieSchemaType.STRING);
-    String testValue = "test_string";
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(stringSchema, testValue, 
false);
-    assertEquals(testValue, result);
-  }
-
-  @Test
-  public void testConvertValueForSpecificDataTypes_UnionWithNull() {
-    // Test with union type containing null
-    HoodieSchema dateSchema = HoodieSchema.createDate();
-    HoodieSchema nullableDateSchema = HoodieSchema.createNullable(dateSchema);
-
-    // Test with non-null value
-    int epochDays = 19358; // 2023-01-01
-    Object result = 
HoodieSchemaUtils.convertValueForSpecificDataTypes(nullableDateSchema, 
epochDays, false);
-    assertNotNull(result);
-    assertTrue(result instanceof LocalDate);
-    assertEquals(LocalDate.of(2023, 1, 1), result);
-  }
-
   @Test
   void testHasDecimalField() {
     
assertTrue(HoodieSchemaUtils.hasDecimalField(HoodieSchema.parse(SCHEMA_WITH_DECIMAL_FIELD)));
@@ -1442,13 +1334,6 @@ public class TestHoodieSchemaUtils {
     assertTrue(HoodieSchemaUtils.hasDecimalField(recordWithMapAndDecArray));
   }
 
-  @Test
-  void testHasSmallPrecisionDecimalField() {
-    
assertTrue(HoodieSchemaUtils.hasSmallPrecisionDecimalField(HoodieSchema.parse(SCHEMA_WITH_DECIMAL_FIELD)));
-    
assertFalse(HoodieSchemaUtils.hasSmallPrecisionDecimalField(HoodieSchema.parse(SCHEMA_WITH_AVRO_TYPES_STR)));
-    
assertFalse(HoodieSchemaUtils.hasSmallPrecisionDecimalField(HoodieSchema.parse(EXAMPLE_SCHEMA)));
-  }
-
   @Test
   void testResolveUnionSchemaWithNonUnionSchema() {
     // Non-union schemas should be returned as-is
@@ -2198,4 +2083,90 @@ public class TestHoodieSchemaUtils {
     assertEquals("value", result.get().getRight().name());
     assertEquals(HoodieSchemaType.LONG, 
result.get().getRight().schema().getType());
   }
+
+  private static HoodieSchema deleteLogTableSchema() {
+    return HoodieSchema.createRecord(
+        "TestRecord",
+        null,
+        null,
+        Arrays.asList(
+            HoodieSchemaField.of("ts", 
HoodieSchema.create(HoodieSchemaType.LONG), "ordering field doc", null),
+            HoodieSchemaField.of("name", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+            HoodieSchemaField.of("seq", 
HoodieSchema.create(HoodieSchemaType.STRING)),
+            HoodieSchemaField.of("opt_ts", HoodieSchema.createUnion(
+                HoodieSchema.create(HoodieSchemaType.LONG), 
HoodieSchema.create(HoodieSchemaType.NULL))),
+            HoodieSchemaField.of("ts_ms", 
HoodieSchema.createTimestampMillis()),
+            HoodieSchemaField.of("amount", HoodieSchema.createDecimal(10, 2))
+        )
+    );
+  }
+
+  @Test
+  public void testCreateDeleteLogSchema() {
+    HoodieSchema deleteLogSchema =
+        HoodieSchemaUtils.createDeleteLogSchema(deleteLogTableSchema(), 
Collections.singletonList("ts"));
+
+    assertEquals("hudi_delete_log_record", deleteLogSchema.getName());
+    assertEquals(2, deleteLogSchema.getFields().size());
+
+    // The record key is always present and never nullable.
+    HoodieSchemaField recordKeyField = deleteLogSchema.getFields().get(0);
+    assertEquals(HoodieRecord.RECORD_KEY_METADATA_FIELD, 
recordKeyField.name());
+    assertEquals(HoodieSchemaType.STRING, recordKeyField.schema().getType());
+    assertFalse(recordKeyField.isNullable());
+
+    // The ordering field keeps its doc but is made nullable with a null 
default, even though
+    // the table schema marks it required.
+    HoodieSchemaField orderingField = deleteLogSchema.getFields().get(1);
+    assertEquals("ts", orderingField.name());
+    assertTrue(orderingField.isNullable());
+    assertEquals(HoodieSchemaType.LONG, 
orderingField.getNonNullSchema().getType());
+    assertEquals("ordering field doc", orderingField.doc().get());
+    assertEquals(HoodieSchema.NULL_VALUE, orderingField.defaultVal().get());
+  }
+
+  @Test
+  public void testCreateDeleteLogSchemaOrderingFieldVariants() {
+    HoodieSchema tableSchema = deleteLogTableSchema();
+
+    // Multiple ordering fields keep the caller's order and their own types.
+    HoodieSchema multiOrderingSchema = 
HoodieSchemaUtils.createDeleteLogSchema(tableSchema, Arrays.asList("ts", 
"seq"));
+    assertEquals(Arrays.asList(HoodieRecord.RECORD_KEY_METADATA_FIELD, "ts", 
"seq"),
+        
multiOrderingSchema.getFields().stream().map(HoodieSchemaField::name).collect(Collectors.toList()));
+    HoodieSchemaField seqField = multiOrderingSchema.getFields().get(2);
+    assertTrue(seqField.isNullable());
+    assertEquals(HoodieSchemaType.STRING, 
seqField.getNonNullSchema().getType());
+    assertEquals(HoodieSchema.NULL_VALUE, seqField.defaultVal().get());
+
+    // No ordering fields leaves the record key alone.
+    assertEquals(1, HoodieSchemaUtils.createDeleteLogSchema(tableSchema, 
Collections.emptyList()).getFields().size());
+
+    // An already-nullable ordering field keeps its [long, null] branch order 
instead of being wrapped again.
+    HoodieSchema optionalOrderingSchema =
+        HoodieSchemaUtils.createDeleteLogSchema(tableSchema, 
Collections.singletonList("opt_ts"));
+    HoodieSchemaField optTsField = optionalOrderingSchema.getFields().get(1);
+    assertEquals(Arrays.asList(HoodieSchemaType.LONG, HoodieSchemaType.NULL),
+        
optTsField.schema().getTypes().stream().map(HoodieSchema::getType).collect(Collectors.toList()));
+    assertTrue(optTsField.isNullable());
+    assertEquals(HoodieSchemaType.LONG, 
optTsField.getNonNullSchema().getType());
+    // Pins the current behaviour of HoodieSchemaField.of: it drops the NULL 
default for a non-null-first union.
+    assertFalse(optTsField.defaultVal().isPresent());
+
+    // Logical types survive the nullable wrapping.
+    HoodieSchema logicalOrderingSchema = 
HoodieSchemaUtils.createDeleteLogSchema(tableSchema, Arrays.asList("ts_ms", 
"amount"));
+    HoodieSchema tsMsSchema = 
logicalOrderingSchema.getFields().get(1).getNonNullSchema();
+    assertEquals(HoodieSchemaType.TIMESTAMP, tsMsSchema.getType());
+    assertEquals(HoodieSchema.TimePrecision.MILLIS, ((HoodieSchema.Timestamp) 
tsMsSchema).getPrecision());
+    HoodieSchema.Decimal amountSchema = (HoodieSchema.Decimal) 
logicalOrderingSchema.getFields().get(2).getNonNullSchema();
+    assertEquals(10, amountSchema.getPrecision());
+    assertEquals(2, amountSchema.getScale());
+  }
+
+  @Test
+  public void testCreateDeleteLogSchemaWithUnknownOrderingField() {
+    HoodieSchema tableSchema = deleteLogTableSchema();
+    IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class,
+        () -> HoodieSchemaUtils.createDeleteLogSchema(tableSchema, 
Collections.singletonList("not_a_field")));
+    assertEquals("Ordering field not_a_field not found in table schema", 
exception.getMessage());
+  }
 }
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/TestLocalHoodieSchemaCache.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestLocalHoodieSchemaCache.java
new file mode 100644
index 000000000000..8acb875849f7
--- /dev/null
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/TestLocalHoodieSchemaCache.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.hudi.common.schema;
+
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class TestLocalHoodieSchemaCache {
+
+  @Test
+  public void testBasicCacheUsage() {
+    LocalHoodieSchemaCache schemaCache = LocalHoodieSchemaCache.create();
+    Integer schemaCacheNum = 
schemaCache.cacheSchema(HoodieTestDataGenerator.HOODIE_SCHEMA);
+    Integer nestedSchemaCacheNum = 
schemaCache.cacheSchema(HoodieTestDataGenerator.NESTED_SCHEMA);
+    Integer metaFieldsSchemaCacheNum = 
schemaCache.cacheSchema(HoodieTestDataGenerator.HOODIE_SCHEMA_WITH_METADATA_FIELDS);
+    Integer decimalSchemaCacheNum = 
schemaCache.cacheSchema(HoodieTestDataGenerator.HOODIE_TRIP_ENCODED_DECIMAL_SCHEMA);
+    Set<Integer> uniqueSet = new HashSet<>(
+        Arrays.asList(schemaCacheNum, nestedSchemaCacheNum, 
metaFieldsSchemaCacheNum, decimalSchemaCacheNum));
+    assertEquals(4, uniqueSet.size());
+    assertTrue(schemaCache.getSchema(schemaCacheNum).isPresent());
+    assertEquals(HoodieTestDataGenerator.HOODIE_SCHEMA, 
schemaCache.getSchema(schemaCacheNum).get());
+    assertTrue(schemaCache.getSchema(nestedSchemaCacheNum).isPresent());
+    assertEquals(HoodieTestDataGenerator.NESTED_SCHEMA, 
schemaCache.getSchema(nestedSchemaCacheNum).get());
+    assertTrue(schemaCache.getSchema(metaFieldsSchemaCacheNum).isPresent());
+    assertEquals(HoodieTestDataGenerator.HOODIE_SCHEMA_WITH_METADATA_FIELDS, 
schemaCache.getSchema(metaFieldsSchemaCacheNum).get());
+    assertTrue(schemaCache.getSchema(decimalSchemaCacheNum).isPresent());
+    assertEquals(HoodieTestDataGenerator.HOODIE_TRIP_ENCODED_DECIMAL_SCHEMA, 
schemaCache.getSchema(decimalSchemaCacheNum).get());
+    assertFalse(schemaCache.getSchema(999).isPresent());
+  }
+
+  @Test
+  public void testCopiesOfSameSchema() {
+    LocalHoodieSchemaCache schemaCache = LocalHoodieSchemaCache.create();
+    HoodieSchema testSchema1 = 
HoodieSchema.parse(HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA);
+    HoodieSchema testSchema2 = 
HoodieSchema.parse(HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA);
+    Integer cacheNum = schemaCache.cacheSchema(testSchema1);
+    Integer secondSchemaCacheNum = schemaCache.cacheSchema(testSchema2);
+    assertEquals(cacheNum, secondSchemaCacheNum);
+    assertTrue(schemaCache.getSchema(cacheNum).isPresent());
+    assertEquals(testSchema1, schemaCache.getSchema(cacheNum).get());
+  }
+
+  @Test
+  public void testCreateReturnsIndependentCaches() {
+    LocalHoodieSchemaCache first = LocalHoodieSchemaCache.create();
+    LocalHoodieSchemaCache second = LocalHoodieSchemaCache.create();
+    assertNotSame(first, second);
+
+    Integer firstId = first.cacheSchema(HoodieTestDataGenerator.HOODIE_SCHEMA);
+    // the second cache has not seen the schema: a shared instance would 
resolve the id
+    assertFalse(second.getSchema(firstId).isPresent());
+
+    // each cache starts its own id space, so the same id resolves to a 
different schema per cache
+    Integer secondId = 
second.cacheSchema(HoodieTestDataGenerator.NESTED_SCHEMA);
+    assertEquals(firstId, secondId);
+    assertEquals(HoodieTestDataGenerator.HOODIE_SCHEMA, 
first.getSchema(firstId).get());
+    assertEquals(HoodieTestDataGenerator.NESTED_SCHEMA, 
second.getSchema(secondId).get());
+  }
+}
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileGroupRecordIterator.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileGroupRecordIterator.java
index 640b0b490209..8090c4859601 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileGroupRecordIterator.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileGroupRecordIterator.java
@@ -34,7 +34,6 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.HoodieSchemaField;
 import org.apache.hudi.common.schema.HoodieSchemaType;
-import org.apache.hudi.common.schema.HoodieSchemas;
 import org.apache.hudi.common.schema.internal.InternalSchema;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
@@ -164,20 +163,6 @@ class TestLsmFileGroupRecordIterator {
     iterator.close();
   }
 
-  @Test
-  void testDeleteLogSchemaUsesRecordKeyAndOrderingFields() {
-    HoodieSchema deleteLogSchema = 
HoodieSchemas.createDeleteLogSchema(tableSchema(), Arrays.asList("ts"));
-
-    assertEquals(Arrays.asList(HoodieRecord.RECORD_KEY_METADATA_FIELD, "ts"), 
deleteLogSchema.getFields().stream()
-        .map(HoodieSchemaField::name)
-        .collect(Collectors.toList()));
-    assertEquals(HoodieSchemaType.STRING, 
deleteLogSchema.getField(HoodieRecord.RECORD_KEY_METADATA_FIELD).get().schema().getType());
-    HoodieSchemaField orderingField = deleteLogSchema.getField("ts").get();
-    assertTrue(orderingField.schema().isNullable());
-    assertEquals(HoodieSchemaType.LONG, 
orderingField.getNonNullSchema().getType());
-    assertEquals(HoodieSchema.NULL_VALUE, orderingField.defaultVal().get());
-  }
-
   @Test
   void testLoserTreeMergesByRecordKeyThenMergeOrder() {
     List<LsmFileGroupRecordIterator.SortedRunReader<String>> readers = 
Arrays.asList(
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileIterators.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileIterators.java
index dc2379b0d4cc..723a11aeb7c5 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileIterators.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/lsm/TestLsmFileIterators.java
@@ -26,7 +26,7 @@ import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.HoodieSchemaField;
 import org.apache.hudi.common.schema.HoodieSchemaType;
-import org.apache.hudi.common.schema.HoodieSchemas;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.table.log.InstantRange;
 import org.apache.hudi.common.table.read.BufferedRecord;
 import org.apache.hudi.common.table.read.DeleteContext;
@@ -64,7 +64,7 @@ class TestLsmFileIterators {
     RecordContext<Map<String, Object>> recordContext = 
mock(RecordContext.class);
     Map<String, Object> record = Collections.emptyMap();
     List<String> orderingFields = Collections.singletonList("ts");
-    HoodieSchema deleteLogSchema = 
HoodieSchemas.createDeleteLogSchema(tableSchema(), orderingFields);
+    HoodieSchema deleteLogSchema = 
HoodieSchemaUtils.createDeleteLogSchema(tableSchema(), orderingFields);
 
     when(readerContext.getRecordContext()).thenReturn(recordContext);
     when(recordContext.getValue(record, deleteLogSchema, 
HoodieRecord.RECORD_KEY_METADATA_FIELD)).thenReturn("key1");
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java
index 7d875aacb7be..09b71e019dbd 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/reader/HoodieFileSliceTestUtils.java
@@ -37,7 +37,7 @@ import org.apache.hudi.common.model.HoodieLogFile;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieRecordLocation;
 import org.apache.hudi.common.schema.HoodieSchema;
-import org.apache.hudi.common.schema.HoodieSchemas;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.log.HoodieLogFormat;
 import org.apache.hudi.common.table.log.HoodieLogFormatWriter;
@@ -388,7 +388,7 @@ public class HoodieFileSliceTestUtils {
       HoodieSchema tableSchema,
       String logInstantTime
   ) throws IOException {
-    HoodieSchema deleteLogSchema = 
HoodieSchemas.createDeleteLogSchema(tableSchema, Arrays.asList(TIMESTAMP));
+    HoodieSchema deleteLogSchema = 
HoodieSchemaUtils.createDeleteLogSchema(tableSchema, Arrays.asList(TIMESTAMP));
     try (HoodieAvroFileWriter writer = createNativeLogWriter(storage, 
logFilePath, deleteLogSchema, logInstantTime)) {
       for (IndexedRecord record : records) {
         String recordKey = 
record.get(record.getSchema().getField(ROW_KEY).pos()).toString();
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
index 45048d4e80b0..e569d9b7f753 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataPayload.java
@@ -30,6 +30,7 @@ import 
org.apache.hudi.metadata.stats.HoodieColumnRangeMetadata;
 import org.apache.hudi.metadata.stats.ValueMetadata;
 
 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.junit.jupiter.api.Test;
@@ -47,6 +48,8 @@ import static org.apache.hudi.metadata.HoodieIndexVersion.V1;
 import static 
org.apache.hudi.metadata.HoodieMetadataPayload.SECONDARY_INDEX_RECORD_KEY_SEPARATOR;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -368,6 +371,29 @@ public class TestHoodieMetadataPayload extends 
HoodieCommonTestHarness {
         ((GenericRecord) projectedRecord).get("BloomFilterMetadata"));
   }
 
+  @Test
+  public void testInsertValueFastPathOnlyForClassSchema() throws IOException {
+    HoodieMetadataPayload payload = 
HoodieMetadataPayload.createBloomFilterMetadataRecord(
+        PARTITION_NAME, "file-id_1-0-1_20240101000000000.parquet", 
"20240101000000000", "SIMPLE",
+        ByteBuffer.wrap("bloom-data".getBytes()), false).getData();
+
+    // The class schema singleton (and no schema) takes the reference-equality 
fast path and returns the generated record.
+    IndexedRecord fastPath = 
payload.getInsertValue(HoodieMetadataRecord.getClassSchema()).get();
+    assertInstanceOf(HoodieMetadataRecord.class, fastPath);
+    assertSame(HoodieMetadataRecord.getClassSchema(), fastPath.getSchema());
+    assertInstanceOf(HoodieMetadataRecord.class, 
payload.getInsertValue(null).get());
+
+    // Any other instance takes the slow path, which fills a GenericRecord at 
the metadata-field offsets.
+    Schema withMetaFields = HoodieSchemaUtils.addMetadataFields(
+        
HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema())).toAvroSchema();
+    assertInstanceOf(GenericData.Record.class, 
payload.getInsertValue(withMetaFields).get());
+
+    // So an equal but distinct copy of the bare class schema is not usable: 
the fast path has to hit by identity.
+    Schema equalCopy = new 
Schema.Parser().parse(HoodieMetadataRecord.getClassSchema().toString());
+    assertEquals(HoodieMetadataRecord.getClassSchema(), equalCopy);
+    assertThrows(ArrayIndexOutOfBoundsException.class, () -> 
payload.getInsertValue(equalCopy));
+  }
+
   @Test
   public void testPayloadToStringForIndexedRecordTypes() {
     HoodieMetadataPayload filesPayload = 
HoodieMetadataPayload.createPartitionFilesRecord(
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
index bd96649c19af..b8498c672559 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
@@ -19,7 +19,7 @@
 package org.apache.hudi
 
 import org.apache.hudi.DataSourceWriteOptions.INSERT_DROP_DUPS
-import org.apache.hudi.common.avro.{AvroRecordContext, AvroSchemaCache, 
HoodieAvroUtils}
+import org.apache.hudi.common.avro.{AvroRecordContext, HoodieAvroUtils}
 import org.apache.hudi.common.config.{RecordMergeMode, TypedProperties}
 import org.apache.hudi.common.fs.FSUtils
 import org.apache.hudi.common.model._
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSourceDefaults.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSourceDefaults.scala
index dadf45197414..61f52d8ff47f 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSourceDefaults.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSourceDefaults.scala
@@ -21,7 +21,7 @@ import org.apache.hudi.HoodieSparkUtils.sparkAdapter
 import org.apache.hudi.common.avro.HoodieAvroUtils
 import org.apache.hudi.common.config.TypedProperties
 import org.apache.hudi.common.model._
-import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaUtils => 
HoodieCommonSchemaUtils}
+import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.testutils.{OrderingFieldsTestUtils, 
SchemaTestUtil}
 import org.apache.hudi.common.util.Option
 import 
org.apache.hudi.common.util.PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH
@@ -30,6 +30,7 @@ import org.apache.hudi.exception.{HoodieException, 
HoodieKeyException}
 import org.apache.hudi.keygen._
 import org.apache.hudi.testutils.SparkDatasetTestUtils
 
+import org.apache.avro.Schema
 import org.apache.avro.generic.GenericRecord
 import org.apache.spark.sql.Row
 import org.apache.spark.sql.catalyst.InternalRow
@@ -624,14 +625,14 @@ class TestDataSourceDefaults extends 
ScalaAssertionSupport {
     val props = new TypedProperties()
     OrderingFieldsTestUtils.setOrderingFieldsConfig(props, key, 
"favoriteIntNumber")
     val baseOrderingVal: Object = baseRecord.get("favoriteIntNumber")
-    val fieldSchema: HoodieSchema = 
HoodieSchema.fromAvroSchema(baseRecord.getSchema().getField("favoriteIntNumber").schema())
+    val fieldSchema: Schema = 
baseRecord.getSchema().getField("favoriteIntNumber").schema()
 
-    val basePayload = new OverwriteWithLatestAvroPayload(baseRecord, 
HoodieCommonSchemaUtils.convertValueForSpecificDataTypes(fieldSchema, 
baseOrderingVal, false).asInstanceOf[Comparable[_]])
+    val basePayload = new OverwriteWithLatestAvroPayload(baseRecord, 
HoodieAvroUtils.convertValueForSpecificDataTypes(fieldSchema, baseOrderingVal, 
false).asInstanceOf[Comparable[_]])
 
     val laterRecord = SchemaTestUtil
       .generateAvroRecordFromJson(schema, 2, "001", "f1")
     val laterOrderingVal: Object = laterRecord.get("favoriteIntNumber")
-    val newerPayload = new OverwriteWithLatestAvroPayload(laterRecord, 
HoodieCommonSchemaUtils.convertValueForSpecificDataTypes(fieldSchema, 
laterOrderingVal, false).asInstanceOf[Comparable[_]])
+    val newerPayload = new OverwriteWithLatestAvroPayload(laterRecord, 
HoodieAvroUtils.convertValueForSpecificDataTypes(fieldSchema, laterOrderingVal, 
false).asInstanceOf[Comparable[_]])
 
     // it always returns the latest payload.
     val preCombinedPayload = basePayload.preCombine(newerPayload)
@@ -640,7 +641,7 @@ class TestDataSourceDefaults extends ScalaAssertionSupport {
   }
 
   @Test def testDefaultHoodieRecordPayloadCombineAndGetUpdateValue(): Unit = {
-    val fieldSchema: HoodieSchema = 
HoodieSchema.fromAvroSchema(baseRecord.getSchema().getField("favoriteIntNumber").schema())
+    val fieldSchema: Schema = 
baseRecord.getSchema().getField("favoriteIntNumber").schema()
     val props = HoodiePayloadConfig.newBuilder()
       .withPayloadOrderingFields("favoriteIntNumber").build().getProps;
 
@@ -653,10 +654,10 @@ class TestDataSourceDefaults extends 
ScalaAssertionSupport {
     val earlierOrderingVal: Object = earlierRecord.get("favoriteIntNumber")
 
     val laterPayload = new DefaultHoodieRecordPayload(laterRecord,
-      HoodieCommonSchemaUtils.convertValueForSpecificDataTypes(fieldSchema, 
laterOrderingVal, false).asInstanceOf[Comparable[_]])
+      HoodieAvroUtils.convertValueForSpecificDataTypes(fieldSchema, 
laterOrderingVal, false).asInstanceOf[Comparable[_]])
 
     val earlierPayload = new DefaultHoodieRecordPayload(earlierRecord,
-      HoodieCommonSchemaUtils.convertValueForSpecificDataTypes(fieldSchema, 
earlierOrderingVal, false).asInstanceOf[Comparable[_]])
+      HoodieAvroUtils.convertValueForSpecificDataTypes(fieldSchema, 
earlierOrderingVal, false).asInstanceOf[Comparable[_]])
 
     // it will provide the record with greatest combine value
     val preCombinedPayload = laterPayload.preCombine(earlierPayload)
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
index b5f5662c5258..ed775afc13be 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
@@ -19,7 +19,6 @@ package org.apache.spark.sql.hudi.common
 
 import org.apache.hudi.{DefaultSparkRecordMerger, HoodieSparkUtils}
 import org.apache.hudi.HoodieFileIndex.DataSkippingFailureMode
-import org.apache.hudi.common.avro.AvroSchemaCache
 import org.apache.hudi.common.config.{HoodieCommonConfig, 
HoodieMetadataConfig, HoodieStorageConfig}
 import org.apache.hudi.common.engine.HoodieLocalEngineContext
 import org.apache.hudi.common.model.{FileSlice, HoodieAvroRecordMerger, 
HoodieLogFile, HoodieRecord, HoodieReplaceCommitMetadata}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKafkaSource.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKafkaSource.java
index 71926f32cd48..1e0bbac0e759 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKafkaSource.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKafkaSource.java
@@ -21,6 +21,7 @@ package org.apache.hudi.utilities.sources;
 import org.apache.hudi.HoodieSchemaUtils;
 import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.client.common.HoodieSparkEngineContext;
+import org.apache.hudi.common.avro.HoodieAvroUtils;
 import org.apache.hudi.common.config.TypedProperties;
 import org.apache.hudi.common.model.HoodieAvroIndexedRecord;
 import org.apache.hudi.common.model.HoodieKey;
@@ -296,7 +297,7 @@ public class TestJsonKafkaSource extends 
BaseTestKafkaSource {
     double maxVal = Math.pow(10, decSchema.getPrecision() - 
decSchema.getScale());
     double minVal = maxVal * 0.1;
     for (GenericRecord record : records) {
-      BigDecimal dec = 
org.apache.hudi.common.schema.HoodieSchemaUtils.convertBytesToBigDecimal(((ByteBuffer)
 record.get(fieldname)).array(), decSchema);
+      BigDecimal dec = HoodieAvroUtils.convertBytesToBigDecimal(((ByteBuffer) 
record.get(fieldname)).array(), decSchema);
       double doubleValue = dec.doubleValue();
       assertTrue(doubleValue <= maxVal && doubleValue >= minVal);
     }

Reply via email to