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

danny0405 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 ee093f0e148a fix(spark): make new schema evolution fields nullable 
(#19665)
ee093f0e148a is described below

commit ee093f0e148a6063d6dc92560516d5e2fa848346
Author: Danny Chan <[email protected]>
AuthorDate: Fri Aug 21 12:14:01 2026 +0800

    fix(spark): make new schema evolution fields nullable (#19665)
    
    * fix(spark): restore null backfill for new columns
---
 .../internal/utils/AvroSchemaEvolutionUtils.java   | 33 ++++++++---
 .../scala/org/apache/hudi/HoodieSchemaUtils.scala  |  5 +-
 .../org/apache/hudi/TestHoodieSchemaUtils.java     | 66 +++++++++++++++++++++
 .../apache/hudi/functional/TestCOWDataSource.scala | 69 ++++++++++++----------
 4 files changed, 131 insertions(+), 42 deletions(-)

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 0310ccfb236e..2e7ade10dab5 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
@@ -302,17 +302,19 @@ public class AvroSchemaEvolutionUtils {
    * {@code target} one. Source is considered to be new incoming schema, while 
target could refer to prev table schema.
    * For example,
    * if colA in source is non-nullable, but is nullable in target, output 
schema will have colA as nullable.
-   * if "hoodie.datasource.write.new.columns.nullable" is set to true and if 
colB is not present in source, but
-   * is present in target, output schema will have colB as nullable.
+   * if colB is present in source, but not in target, output schema will have 
colB as nullable. If colB is a complex
+   * type, its existing descendants retain their nullability constraints.
    * if colC has different data type in source schema compared to target 
schema and if its promotable, (say source is int,
    * and target is long and since int can be promoted to long), colC will be 
long data type in output schema.
    *
    *
    * @param sourceSchema source schema that needs reconciliation
    * @param targetSchema target schema that source schema will be reconciled 
against
+   * @param shouldReorderColumns whether fields should be reordered to match 
the target schema
    * @return schema (based off {@code source} one) that has nullability 
constraints and datatypes reconciled
    */
-  public static HoodieSchema reconcileSchemaRequirements(HoodieSchema 
sourceSchema, HoodieSchema targetSchema, boolean shouldReorderColumns) {
+  public static HoodieSchema reconcileSchemaRequirements(HoodieSchema 
sourceSchema, HoodieSchema targetSchema,
+                                                          boolean 
shouldReorderColumns) {
     if (targetSchema.isSchemaNull() || targetSchema.getFields().isEmpty()) {
       return sourceSchema;
     }
@@ -327,12 +329,30 @@ public class AvroSchemaEvolutionUtils {
 
     List<String> colNamesSourceSchema = 
sourceInternalSchema.getAllColsFullName();
     List<String> colNamesTargetSchema = 
targetInternalSchema.getAllColsFullName();
+    List<String> userColNamesSourceSchema = colNamesSourceSchema.stream()
+        .filter(field -> !META_FIELD_NAMES.contains(field))
+        .collect(Collectors.toList());
 
     List<String> nullableUpdateColsInSource = new ArrayList<>();
     List<String> typeUpdateColsInSource = new ArrayList<>();
-    colNamesSourceSchema.forEach(field -> {
-      // handle columns that needs to be made nullable
-      if (colNamesTargetSchema.contains(field) && 
sourceInternalSchema.findField(field).isOptional() != 
targetInternalSchema.findField(field).isOptional()) {
+
+    // Only relax the topmost field in a wholly new subtree. Relaxing every 
descendant would alter the
+    // element/field constraints supplied by the writer instead of only making 
the evolved field backfillable.
+    Set<String> visitedNewColumns = new HashSet<>();
+    userColNamesSourceSchema.stream()
+        .filter(field -> !colNamesTargetSchema.contains(field))
+        .sorted()
+        .forEach(field -> {
+          String parent = TableChangesHelper.getParentName(field);
+          if (!visitedNewColumns.contains(parent)) {
+            nullableUpdateColsInSource.add(field);
+          }
+          visitedNewColumns.add(field);
+        });
+
+    userColNamesSourceSchema.forEach(field -> {
+      if (colNamesTargetSchema.contains(field)
+          && sourceInternalSchema.findField(field).isOptional() != 
targetInternalSchema.findField(field).isOptional()) {
         nullableUpdateColsInSource.add(field);
       }
       // handle columns that needs type to be updated
@@ -364,4 +384,3 @@ public class AvroSchemaEvolutionUtils {
     return 
convert(SchemaChangeUtils.applyTableChanges2Schema(sourceInternalSchema, 
schemaChange), sourceSchema.getFullName());
   }
 }
-
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala
index 234ef5a556b1..f82f496ef027 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSchemaUtils.scala
@@ -137,7 +137,7 @@ object HoodieSchemaUtils {
         val shouldReconcileSchema = 
opts.getOrElse(DataSourceWriteOptions.RECONCILE_SCHEMA.key(),
           
DataSourceWriteOptions.RECONCILE_SCHEMA.defaultValue().toString).toBoolean
         val canonicalizedSourceSchema = if (shouldCanonicalizeSchema) {
-          canonicalizeSchema(sourceSchema, latestTableSchema, opts, 
!shouldReconcileSchema)
+          canonicalizeSchema(sourceSchema, latestTableSchema, 
!shouldReconcileSchema)
         } else {
           InternalSchemaConverter.fixNullOrdering(sourceSchema)
         }
@@ -276,12 +276,11 @@ object HoodieSchemaUtils {
    *
    * TODO support casing reconciliation
    */
-  private def canonicalizeSchema(sourceSchema: HoodieSchema, 
latestTableSchema: HoodieSchema, opts : Map[String, String],
+  private def canonicalizeSchema(sourceSchema: HoodieSchema, 
latestTableSchema: HoodieSchema,
                                  shouldReorderColumns: Boolean): HoodieSchema 
= {
     reconcileSchemaRequirements(sourceSchema, latestTableSchema, 
shouldReorderColumns)
   }
 
-
   private def reconcileSchemasLegacy(tableSchema: HoodieSchema, newSchema: 
HoodieSchema): (HoodieSchema, Boolean) = {
     // Legacy reconciliation implements following semantic
     //    - In case new-schema is a "compatible" projection of the existing 
table's one (projection allowing
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 d4e1e3aeba7d..6175c385912d 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
@@ -21,8 +21,12 @@ package org.apache.hudi;
 
 import org.apache.hudi.common.config.HoodieCommonConfig;
 import org.apache.hudi.common.config.TypedProperties;
+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.internal.convert.InternalSchemaConverter;
+import org.apache.hudi.common.schema.internal.utils.AvroSchemaEvolutionUtils;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.exception.HoodieNullSchemaTypeException;
@@ -31,6 +35,7 @@ import 
org.apache.hudi.exception.SchemaBackwardsCompatibilityException;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.EnumMap;
@@ -340,6 +345,67 @@ public class TestHoodieSchemaUtils {
     assertEquals(expected, deduceWriterSchema(incoming, table, 
setNullForMissingColumns));
   }
 
+  @ParameterizedTest
+  @CsvSource({
+      "false,false",
+      "true,false",
+      "true,true"
+  })
+  void testNewColumnsAreNullableAcrossReconciliationPaths(boolean 
reconcileSchema, boolean useInternalSchema) {
+    HoodieSchema table = createRecord("newColumns",
+        createPrimitiveField("id", HoodieSchemaType.INT),
+        HoodieSchemaField.of("address", createRecord("address",
+            createPrimitiveField("city", HoodieSchemaType.STRING)), null, 
null));
+    HoodieSchema incoming = createRecord("newColumns",
+        createPrimitiveField("id", HoodieSchemaType.INT),
+        HoodieSchemaField.of("address", createRecord("address",
+            createPrimitiveField("city", HoodieSchemaType.STRING),
+            createPrimitiveField("country", HoodieSchemaType.STRING)), null, 
null),
+        createPrimitiveField("phone", HoodieSchemaType.STRING),
+        HoodieSchemaField.of("profile", createRecord("profile",
+            createPrimitiveField("name", HoodieSchemaType.STRING)), null, 
null),
+        createArrayField("items", createRecord("item",
+            createPrimitiveField("sku", HoodieSchemaType.STRING))));
+    TypedProperties properties = new TypedProperties();
+    properties.setProperty(HoodieCommonConfig.RECONCILE_SCHEMA.key(), 
Boolean.toString(reconcileSchema));
+
+    HoodieSchema actual = HoodieSchemaUtils.deduceWriterSchema(
+        incoming,
+        Option.of(table),
+        useInternalSchema ? Option.of(InternalSchemaConverter.convert(table)) 
: Option.empty(),
+        properties);
+
+    
assertFalse(actual.getNestedField("address.city").get().getRight().isNullable());
+    
assertNewFieldIsNullableWithNullDefault(actual.getNestedField("address.country").get().getRight());
+    assertNewFieldIsNullableWithNullDefault(actual.getField("phone").get());
+    HoodieSchemaField profile = actual.getField("profile").get();
+    assertNewFieldIsNullableWithNullDefault(profile);
+    
assertFalse(profile.schema().getNonNullType().getField("name").get().isNullable());
+    HoodieSchemaField items = actual.getField("items").get();
+    assertNewFieldIsNullableWithNullDefault(items);
+    HoodieSchema itemElement = 
items.schema().getNonNullType().getElementType();
+    assertFalse(itemElement.isNullable());
+    assertFalse(itemElement.getField("sku").get().isNullable());
+  }
+
+  @Test
+  void testMetadataFieldsAreExcludedFromNewColumnNullability() {
+    HoodieSchema table = createRecord("metadata", createPrimitiveField("id", 
HoodieSchemaType.INT));
+    HoodieSchema incoming = createRecord("metadata",
+        createPrimitiveField("id", HoodieSchemaType.INT),
+        createPrimitiveField(HoodieRecord.COMMIT_TIME_METADATA_FIELD, 
HoodieSchemaType.STRING));
+
+    HoodieSchema actual = 
AvroSchemaEvolutionUtils.reconcileSchemaRequirements(incoming, table, false);
+
+    
assertFalse(actual.getField(HoodieRecord.COMMIT_TIME_METADATA_FIELD).get().isNullable());
+  }
+
+  private static void 
assertNewFieldIsNullableWithNullDefault(HoodieSchemaField field) {
+    assertTrue(field.isNullable());
+    assertTrue(field.hasDefaultValue());
+    assertEquals(HoodieSchema.NULL_VALUE, field.defaultVal().get());
+  }
+
   private static HoodieSchema deduceWriterSchema(HoodieSchema incomingSchema, 
HoodieSchema latestTableSchema) {
     return deduceWriterSchema(incomingSchema, latestTableSchema, false);
   }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala
index 9a3123ffde0c..38cdbb3af6c6 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestCOWDataSource.scala
@@ -27,9 +27,9 @@ import org.apache.hudi.common.config.{HoodieCommonConfig, 
HoodieMetadataConfig,
 import 
org.apache.hudi.common.config.TimestampKeyGeneratorConfig.{TIMESTAMP_INPUT_DATE_FORMAT,
 TIMESTAMP_OUTPUT_DATE_FORMAT, TIMESTAMP_TIMEZONE_FORMAT, TIMESTAMP_TYPE_FIELD}
 import org.apache.hudi.common.config.metrics.HoodieMetricsConfig
 import org.apache.hudi.common.fs.FSUtils
-import org.apache.hudi.common.model.{HoodieRecord, 
HoodieReplaceCommitMetadata, WriteOperationType}
+import org.apache.hudi.common.model.{HoodieCommitMetadata, HoodieRecord, 
HoodieReplaceCommitMetadata, WriteOperationType}
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
-import 
org.apache.hudi.common.schema.HoodieSchemaCompatibilityChecker.SchemaIncompatibilityType
+import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient, 
HoodieTableVersion, TableSchemaResolver}
 import org.apache.hudi.common.table.timeline.{HoodieInstant, HoodieTimeline, 
TimelineUtils}
 import org.apache.hudi.common.testutils.{HoodieTestDataGenerator, 
HoodieTestUtils}
@@ -37,7 +37,7 @@ import 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.{deleteRecordsTo
 import 
org.apache.hudi.common.testutils.HoodieTestUtils.{INSTANT_FILE_NAME_GENERATOR, 
INSTANT_GENERATOR}
 import org.apache.hudi.common.util.{ClusteringUtils, Option}
 import org.apache.hudi.config.HoodieWriteConfig
-import org.apache.hudi.exception.{HoodieException, 
SchemaBackwardsCompatibilityException}
+import org.apache.hudi.exception.HoodieException
 import org.apache.hudi.hive.HiveSyncConfigHolder
 import org.apache.hudi.keygen.{ComplexKeyGenerator, CustomKeyGenerator, 
GlobalDeleteKeyGenerator, NonpartitionedKeyGenerator, SimpleKeyGenerator, 
TimestampBasedKeyGenerator}
 import org.apache.hudi.keygen.constant.{KeyGeneratorOptions, KeyGeneratorType}
@@ -1966,11 +1966,12 @@ class TestCOWDataSource extends 
HoodieSparkClientTestBase with ScalaAssertionSup
     assertEquals(0, result.filter(result("id") === 1).count())
   }
 
-  /** Test case to verify MAKE_NEW_COLUMNS_NULLABLE config parameter. */
-  @Test
-  def testSchemaEvolutionWithNewColumn(): Unit = {
-    val df1 = spark.sql("select '1' as event_id, '2' as ts, '3' as version, 
'foo' as event_date")
-    var hudiOptions = Map[String, String](
+  @ParameterizedTest
+  @CsvSource(value = Array("false,false", "false,true", "true,false", 
"true,true"))
+  def testSchemaEvolutionWithNewColumns(schemaOnRead: Boolean, 
reconcileSchema: Boolean): Unit = {
+    val df1 = spark.sql(
+      "select '1' as event_id, '2' as ts, '3' as version, named_struct('city', 
'Paris') as address")
+    val hudiOptions = Map[String, String](
       HoodieWriteConfig.TBL_NAME.key() -> "test_hudi_merger",
       KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key() -> "event_id",
       KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key() -> "version",
@@ -1979,35 +1980,39 @@ class TestCOWDataSource extends 
HoodieSparkClientTestBase with ScalaAssertionSup
       HoodieWriteConfig.KEYGENERATOR_CLASS_NAME.key() -> 
"org.apache.hudi.keygen.ComplexKeyGenerator",
       KeyGeneratorOptions.HIVE_STYLE_PARTITIONING_ENABLE.key() -> "true",
       HiveSyncConfigHolder.HIVE_SYNC_ENABLED.key() -> "false",
-      HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key() -> 
"org.apache.hudi.DefaultSparkRecordMerger"
+      HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key() -> 
"org.apache.hudi.DefaultSparkRecordMerger",
+      HoodieCommonConfig.RECONCILE_SCHEMA.key() -> reconcileSchema.toString,
+      HoodieCommonConfig.SCHEMA_EVOLUTION_ENABLE.key() -> schemaOnRead.toString
     )
     
df1.write.format("hudi").options(hudiOptions).mode(SaveMode.Append).save(basePath)
 
-    // Try adding a string column. This operation is expected to throw 'schema 
not compatible' exception since
-    // 'MAKE_NEW_COLUMNS_NULLABLE' parameter is 'false' by default.
-    val df2 = spark.sql("select '2' as event_id, '2' as ts, '3' as version, 
'foo' as event_date, 'bar' as add_col")
-    try {
-      
(df2.write.format("hudi").options(hudiOptions).mode("append").save(basePath))
-      fail("Option succeeded, but was expected to fail.")
-    } catch {
-      case ex: SchemaBackwardsCompatibilityException => {
-        
assertTrue(ex.getMessage.contains(SchemaIncompatibilityType.READER_FIELD_MISSING_DEFAULT_VALUE.name()))
-      }
-      case ex: Exception => {
-        fail(ex)
-      }
-    }
+    val df2 = spark.sql(
+      "select '2' as event_id, '2' as ts, '3' as version, "
+        + "named_struct('city', 'Berlin', 'country', 'DE') as address, '123' 
as phone")
 
-    // Try adding the string column again. This operation is expected to 
succeed since 'MAKE_NEW_COLUMNS_NULLABLE'
-    // parameter has been set to 'true'.
-    hudiOptions = hudiOptions + 
(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key() -> "true")
-    try {
-      
(df2.write.format("hudi").options(hudiOptions).mode("append").save(basePath))
-    } catch {
-      case ex: Exception => {
-        fail(ex)
-      }
+    df2.write.format("hudi").options(hudiOptions).mode("append").save(basePath)
+
+    val metaClient = createMetaClient(basePath)
+    val timeline = 
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants()
+    val commitMetadata = 
timeline.readCommitMetadata(timeline.lastInstant().get())
+    val committedSchema = 
HoodieSchema.parse(commitMetadata.getMetadata(HoodieCommitMetadata.SCHEMA_KEY))
+    val phoneField = committedSchema.getField("phone").get()
+    val countryField = 
committedSchema.getNestedField("address.country").get().getRight
+    Seq(phoneField, countryField).foreach { field =>
+      assertTrue(field.isNullable)
+      assertTrue(field.hasDefaultValue)
+      assertEquals(HoodieSchema.NULL_VALUE, field.defaultVal().get())
     }
+
+    val rows = spark.read.format("hudi").load(basePath)
+      .select("event_id", "phone", "address")
+      .orderBy("event_id")
+      .collect()
+    assertEquals(2, rows.length)
+    assertEquals(null, rows(0).getAs[String]("phone"))
+    assertEquals(null, rows(0).getAs[Row]("address").getAs[String]("country"))
+    assertEquals("123", rows(1).getAs[String]("phone"))
+    assertEquals("DE", rows(1).getAs[Row]("address").getAs[String]("country"))
   }
 
   def assertLastCommitIsUpsert(): Boolean = {

Reply via email to