voonhous commented on code in PR #19809:
URL: https://github.com/apache/hudi/pull/19809#discussion_r3916368048


##########
hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java:
##########
@@ -883,15 +924,16 @@ private static Object 
normalizeAvroLogicalTypeToPrimitive(Object value, Schema s
    *
    * @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();
+      // Intern so the identity fast path in BaseAvroPayload#getRecord hits 
across callers that parse their own copy of the schema.
+      GenericRecord genericRecord = (GenericRecord) 
(record.toIndexedRecord(HoodieSchemaCache.intern(schema), new 
Properties()).get()).getData();

Review Comment:
   **major:** `HoodieSchemaCache` is value-keyed (`weakValues` + `maximumSize`, 
`HoodieSchemaCache.java:36-37`) and `HoodieSchema.equals` only short-circuits 
on identity before Avro's deep `RecordSchema.equals`, whereas master's 
`HoodieAvroSchemaCache` front is `weakKeys` (identity). Every task whose 
deserialized schema copy is not the stored key now pays a deep schema compare 
per call: twice per comparison in `RDDBucketIndexPartitioner.java:92,94`, once 
per record per column at `HoodieTableMetadataUtil.java:331`. Could this be 
`HoodieAvroSchemaCache.intern(schema.toAvroSchema())`, which is byte-for-byte 
the master path and lands on the same canonical instance?



##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java:
##########
@@ -769,47 +788,27 @@ public static String getRecordQualifiedName(String 
tableName) {
   }
 
   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());

Review Comment:
   **major:** pre-existing, not introduced by this fold: master has the same 
arm (`HoodieSchemaUtils.java:799-800`, since #17600). `getNonNullType()` 
returns `this` for a union without a null branch 
(`HoodieSchema.java:1397-1399`) and `["null","string","int"]` reduces to 
exactly that shape (line 1413), so this recurses to `StackOverflowError`. 
`SourceFormatAdapter.java:261` calls it on every JSON source schema; 
`HoodieSchemaRepair.hasTimestampMillisField:249` has the identical arm. Out of 
scope for a behavior-preserving PR; should the 
`getTypes().stream().anyMatch(...)` fix go in a separate PR, with an issue 
linked here meanwhile?



##########
hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java:
##########
@@ -2198,4 +2083,78 @@ public void testGetNestedFieldComplexNestedMapAndArray() 
{
     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)))
+        )
+    );
+  }
+
+  @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);
+    assertEquals("seq", seqField.name());
+    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 is left as-is rather than 
double-wrapped.

Review Comment:
   **nit:** feel free to ignore. "left as-is rather than double-wrapped" is 
only pinned indirectly: `isNullable()` and `getNonNullSchema()` hold for a 
re-wrapped `[null, long]` too, and only the dropped default proves the `[long, 
null]` order survived. Could we assert the union shape directly 
(`Arrays.asList(LONG, NULL)` against `optTsField.schema().getTypes()` mapped to 
types), and drop line 2135, which cannot fail after the field-name list 
assertion at 2132?



##########
hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java:
##########
@@ -2198,4 +2083,78 @@ public void testGetNestedFieldComplexNestedMapAndArray() 
{
     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)))
+        )
+    );
+  }
+
+  @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() {

Review Comment:
   **minor:** not blocking. Order, empty list and a null-last union are covered 
now, but no test anywhere builds a delete-log schema from a logical-typed 
ordering field, which is where the delete path's history is (#13998 
timestamp-millis ordering values, #13163, #12006); 
`TestLsmFileIterators.java:163` and `HoodieFileSliceTestUtils.java:104` both 
use a plain long. The code handles it today. Could `deleteLogTableSchema()` 
gain a timestamp-millis and a decimal field, with one assertion each that the 
logical type and precision/scale survive?



##########
hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java:
##########
@@ -955,6 +1085,25 @@ void 
testGetSortColumnValuesWithPartitionPathAndRecordKey(boolean suffixRecordKe
     }
   }
 
+  @Test
+  void testGetRecordColumnValues() {

Review Comment:
   **minor:** not blocking. This test passes with or without the intern at 
`HoodieAvroUtils.java:936`: `RewriteAvroPayload.getInsertValue` ignores its 
schema argument (`RewriteAvroPayload.java:51-53`), so the 
`BaseAvroPayload#getRecord` identity path never runs. Could one case use 
`OverwriteWithLatestAvroPayload`, build the record on the interned Avro schema, 
and call with a freshly parsed equal `HoodieSchema`? With the intern the string 
comes back as `String`; without it the payload round-trips through 
`fromAvroBytes` and yields `Utf8`. `student.firstnameNested` is already pinned 
on this fixture at lines 476-479, so it could go.



##########
hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchemaUtils.java:
##########
@@ -1047,6 +1043,11 @@ public void testGenerateProjectionSchema() {
     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"));

Review Comment:
   **minor:** not blocking, pre-existing. This pins case-insensitive matching, 
but `generateProjectionSchema` lowercases with the default locale 
(`HoodieSchemaUtils.java:475,478`) while its only production caller uses 
`Locale.ROOT` (`HiveHoodieReaderContext.java:236`); under `tr_TR` an `ID` 
column lowercases to dotless-i on one side and `id` on the other, and the 
projection throws "Field id not found in log schema". `_ROW_KEY` has no `I`, so 
this case cannot catch it. Should the `Locale.ROOT` fix and an `I`-bearing name 
(`PII_COL`) go in a separate PR, with an issue linked here?



##########
hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java:
##########
@@ -883,15 +924,16 @@ private static Object 
normalizeAvroLogicalTypeToPrimitive(Object value, Schema s
    *
    * @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();
+      // Intern so the identity fast path in BaseAvroPayload#getRecord hits 
across callers that parse their own copy of the schema.

Review Comment:
   **minor:** not blocking. This comment describes callers that parse their own 
schema copy, but the sibling `getSortColumnValuesWithPartitionPathAndRecordKey` 
(line 961) reaches the same sort path through `SortUtils.java:225,259` and 
never interned, on master either. Is the asymmetry intended, kept because this 
PR is behavior-preserving? If so could the comment say so, and if not should 
the sibling intern too?



##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaUtils.java:
##########
@@ -731,11 +720,41 @@ public static HoodieSchema 
getRecordKeyPartitionPathSchema() {
     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

Review Comment:
   **minor:** not blocking. This is the only place that says 
`generateProjectionSchema` matches case-insensitively; the method's own javadoc 
(lines 461-469) does not, and the reason survives only on the hadoop-mr twin 
`HoodieRealtimeRecordReaderUtils.java:126-133` (Hive lowercases column 
projections, `37838cea6094`). The lowercase `toMap` also throws 
`IllegalStateException: Duplicate key` for two fields differing only in case. 
Could the two-line rationale and the collision caveat be ported onto the 
method's javadoc?



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestJsonKafkaSource.java:
##########
@@ -296,7 +296,7 @@ private static void verifyDecimalValue(List<GenericRecord> 
records, HoodieSchema
     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 = 
org.apache.hudi.common.avro.HoodieAvroUtils.convertBytesToBigDecimal(((ByteBuffer)
 record.get(fieldname)).array(), decSchema);

Review Comment:
   **nit:** feel free to ignore. The fully-qualified name was only needed 
because `org.apache.hudi.HoodieSchemaUtils` (line 21) clashed with the old 
`common.schema.HoodieSchemaUtils` target; `HoodieAvroUtils` has no clash in 
this file. Could we import it and use the short name?



##########
hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java:
##########
@@ -883,15 +924,16 @@ private static Object 
normalizeAvroLogicalTypeToPrimitive(Object value, Schema s
    *
    * @param record  Hoodie record.
    * @param columns Names of the columns to get values.
-   * @param schema  {@link Schema} instance.
+   * @param schema  {@link HoodieSchema} instance.

Review Comment:
   **nit:** feel free to ignore. This line moved to `{@link HoodieSchema}`, but 
the sibling `getSortColumnValuesWithPartitionPathAndRecordKey` javadoc at line 
952 still says `@param schema {@link Schema} instance` for a `HoodieSchema` 
parameter (stale on master; `d74b3cd4f3b6` missed it). Could it get the same 
edit?



##########
hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java:
##########
@@ -566,6 +567,135 @@ public void 
testConvertValueForAvroLogicalTypesCrossAvroVersion() {
     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() {
+    // Create date schema
+    Schema dateSchema = HoodieSchema.createDate().toAvroSchema();

Review Comment:
   **nit:** feel free to ignore. The moved `convertValueForSpecificDataTypes_*` 
tests build the date / timestamp-millis / timestamp-micros schemas by hand 
(lines 597, 610, 623, 632) while this class already declares them as 
`DATE_SCHEMA`, `TS_MILLIS_SCHEMA` and `TS_MICROS_SCHEMA` (lines 527-529). Could 
they reuse the constants now that they live here?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to