This is an automated email from the ASF dual-hosted git repository.

nsivabalan 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 d8ebba4974e7 fix: relax existing column to nullable in reconcileSchema 
when source made it nullable (#19337)
d8ebba4974e7 is described below

commit d8ebba4974e74d990af9f0c71ebd6073205e603e
Author: vamsikarnika <[email protected]>
AuthorDate: Wed Jul 22 07:59:16 2026 +0530

    fix: relax existing column to nullable in reconcileSchema when source made 
it nullable (#19337)
    
    In reconcileSchema, detect existing columns whose incoming schema is 
nullable but the table is required (nullabilityRelaxColumns), exclude them from 
the early-return short-circuit, and relax them to nullable in the result via 
updateColumnNullability(col, true). This only ever widens, never tightens (a 
nullable→required incoming leaves the table nullable), consistent with 
reconcileSchemaRequirements, and still null-fills genuinely missing columns.
---
 .../internal/utils/AvroSchemaEvolutionUtils.java   | 17 +++++-
 .../utils/TestAvroSchemaEvolutionUtils.java        | 60 ++++++++++++++++++++++
 .../org/apache/hudi/TestHoodieSchemaUtils.java     | 19 +++++++
 3 files changed, 95 insertions(+), 1 deletion(-)

diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
index 4ce674bf853e..f6405fef0137 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
@@ -78,7 +78,19 @@ public class AvroSchemaEvolutionUtils {
         .stream()
         .filter(f -> colNamesFromOldSchema.contains(f) && 
!inComingInternalSchema.findType(f).equals(oldTableSchema.findType(f)))
         .collect(Collectors.toList());
-    if (colNamesFromIncoming.size() == colNamesFromOldSchema.size() && 
diffFromOldSchema.size() == 0 && typeChangeColumns.isEmpty()) {
+    // check columns the incoming schema relaxed from required to nullable. 
Since the result is built from
+    // oldTableSchema (to preserve column order/ids and to null-fill missing 
columns), an existing column
+    // whose incoming counterpart became nullable would otherwise silently 
keep the table's REQUIRED
+    // nullability, blocking a valid required -> nullable evolution. We only 
ever relax (never tighten).
+    List<String> nullabilityRelaxColumns = colNamesFromIncoming
+        .stream()
+        .filter(f -> colNamesFromOldSchema.contains(f)
+            && !META_FIELD_NAMES.contains(f)
+            && inComingInternalSchema.findField(f).isOptional()
+            && !oldTableSchema.findField(f).isOptional())
+        .collect(Collectors.toList());
+    if (colNamesFromIncoming.size() == colNamesFromOldSchema.size() && 
diffFromOldSchema.size() == 0
+        && typeChangeColumns.isEmpty() && nullabilityRelaxColumns.isEmpty()) {
       return oldTableSchema;
     }
 
@@ -122,6 +134,9 @@ public class AvroSchemaEvolutionUtils {
       typeChange.updateColumnType(col, inComingInternalSchema.findType(col));
     });
 
+    // relax existing columns to nullable when the incoming schema made them 
nullable (valid widening)
+    nullabilityRelaxColumns.forEach(col -> 
typeChange.updateColumnNullability(col, true));
+
     if (makeMissingFieldsNullable) {
       // mark columns missing from incoming schema as nullable
       Set<String> visited = new HashSet<>();
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
index 514048710034..d82dd84b87e1 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
@@ -567,4 +567,64 @@ public class TestAvroSchemaEvolutionUtils {
     // the evolved schema should be the old table schema, since there is no 
type change at all.
     Assertions.assertEquals(oldInternalSchema, evolvedSchema);
   }
+
+  /**
+   * When the incoming schema relaxes an existing required column to nullable, 
reconcileSchema must evolve
+   * that column to nullable in the result, even when 
makeMissingFieldsNullable is true. Previously the
+   * result was rebuilt from the required table and the relaxation was 
silently dropped, so records with
+   * null in that column failed the write / were quarantined.
+   */
+  @Test
+  public void testReconcileSchemaRelaxesExistingColumnToNullable() {
+    // table: id (required int), flag (required boolean) -- same column set as 
the incoming schema
+    Types.RecordType oldRecord = Types.RecordType.get(
+        Types.Field.get(0, false, "id", Types.IntType.get()),
+        Types.Field.get(1, false, "flag", Types.BooleanType.get())
+    );
+    InternalSchema oldSchema = new InternalSchema(oldRecord);
+    // incoming: identical columns, but the source relaxed "flag" to nullable
+    Types.RecordType incomingRecord = Types.RecordType.get(
+        Types.Field.get(0, false, "id", Types.IntType.get()),
+        Types.Field.get(1, true, "flag", Types.BooleanType.get())
+    );
+    incomingRecord = (Types.RecordType) 
InternalSchemaBuilder.getBuilder().refreshNewId(incomingRecord, new 
AtomicInteger(0));
+    HoodieSchema incomingSchema = 
InternalSchemaConverter.convert(incomingRecord, "test1");
+
+    InternalSchema result = 
AvroSchemaEvolutionUtils.reconcileSchema(incomingSchema, oldSchema, true);
+
+    Types.RecordType checkedRecord = Types.RecordType.get(
+        Types.Field.get(0, false, "id", Types.IntType.get()),
+        Types.Field.get(1, true, "flag", Types.BooleanType.get())
+    );
+    Assertions.assertEquals(checkedRecord, result.getRecord());
+  }
+
+  /**
+   * reconcileSchema must only ever relax (widen) an existing column's 
nullability, never tighten it: if the
+   * incoming schema marks a column required but the table has it nullable, 
the table stays nullable.
+   */
+  @Test
+  public void testReconcileSchemaDoesNotTightenNullableToRequired() {
+    // table: id (required int), flag (nullable boolean)
+    Types.RecordType oldRecord = Types.RecordType.get(
+        Types.Field.get(0, false, "id", Types.IntType.get()),
+        Types.Field.get(1, true, "flag", Types.BooleanType.get())
+    );
+    InternalSchema oldSchema = new InternalSchema(oldRecord);
+    // incoming: source tightened "flag" to required -- must NOT tighten the 
table
+    Types.RecordType incomingRecord = Types.RecordType.get(
+        Types.Field.get(0, false, "id", Types.IntType.get()),
+        Types.Field.get(1, false, "flag", Types.BooleanType.get())
+    );
+    incomingRecord = (Types.RecordType) 
InternalSchemaBuilder.getBuilder().refreshNewId(incomingRecord, new 
AtomicInteger(0));
+    HoodieSchema incomingSchema = 
InternalSchemaConverter.convert(incomingRecord, "test1");
+
+    InternalSchema result = 
AvroSchemaEvolutionUtils.reconcileSchema(incomingSchema, oldSchema, true);
+
+    Types.RecordType checkedRecord = Types.RecordType.get(
+        Types.Field.get(0, false, "id", Types.IntType.get()),
+        Types.Field.get(1, true, "flag", Types.BooleanType.get())
+    );
+    Assertions.assertEquals(checkedRecord, result.getRecord());
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java
 
b/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java
index 18d488098c8f..d4e1e3aeba7d 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieSchemaUtils.java
@@ -321,6 +321,25 @@ public class TestHoodieSchemaUtils {
     assertEquals(expected, deduceWriterSchema(end, start, true));
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void testExistingColumnRelaxedToNullableEvolves(boolean 
setNullForMissingColumns) {
+    // Table has field2 as a required boolean; the incoming (source) schema 
relaxed it to nullable, same
+    // column set otherwise. The deduced writer schema must evolve field2 to 
nullable regardless of the
+    // set.null.for.missing.columns flag -- with the flag on this used to 
silently stay required, so records
+    // with null in field2 failed the write / were quarantined.
+    HoodieSchema table = createRecord("relaxRec",
+        createPrimitiveField("field1", HoodieSchemaType.INT),
+        createPrimitiveField("field2", HoodieSchemaType.BOOLEAN));
+    HoodieSchema incoming = createRecord("relaxRec",
+        createPrimitiveField("field1", HoodieSchemaType.INT),
+        createNullablePrimitiveField("field2", HoodieSchemaType.BOOLEAN));
+    HoodieSchema expected = createRecord("relaxRec",
+        createPrimitiveField("field1", HoodieSchemaType.INT),
+        createNullablePrimitiveField("field2", HoodieSchemaType.BOOLEAN));
+    assertEquals(expected, deduceWriterSchema(incoming, table, 
setNullForMissingColumns));
+  }
+
   private static HoodieSchema deduceWriterSchema(HoodieSchema incomingSchema, 
HoodieSchema latestTableSchema) {
     return deduceWriterSchema(incomingSchema, latestTableSchema, false);
   }

Reply via email to