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


##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala:
##########
@@ -99,7 +99,15 @@ class SqlKeyGenerator(props: TypedProperties) extends 
BuiltinKeyGenerator(props)
 
   override def getPartitionPath(record: GenericRecord): String = {
     val partitionPath = originalKeyGen.map {
-      _.getKey(record).getPartitionPath
+      // Resolve the partition path on its own where the key generator exposes 
it. Going through
+      // BaseKeyGenerator#getKey would also compute and validate the record 
key, which a MOR partial
+      // update legitimately leaves unset: the merged record is materialised 
against
+      // WRITE_PARTIAL_UPDATE_SCHEMA and so carries only the columns named in 
UPDATE SET. Callers
+      // that want the record key validated still ask for it, via getKey or 
getRecordKey.
+      case baseKeyGen: BaseKeyGenerator => baseKeyGen.getPartitionPath(record)

Review Comment:
   This looks blocking on merge order rather than on the code. The body says 
fixing this only "exposes" an `UnresolvedUnionException`, i.e. the intermediate 
state is loud. That holds only when the partial-update column types disagree 
with the tail of the write schema; where they agree the record serializes fine 
and a wrong row is written.
   
   Could we land this together with (or after) #19713, and say `Addresses 
#19708` rather than `Closes #19708`, so merging does not auto-close an issue 
whose repro still fails?
   
   <details><summary>Repro, driving <code>JoinedGenericRecord</code> + 
<code>HoodieAvroUtils.avroToBytes</code> directly</summary>
   
   ```
   Table (id bigint, name string, city string, ts bigint, dt string), UPDATE 
SET name
     prependMetaFields infers metaFieldSize = 9 (correct: 5)
     avroToBytes: OK, 98 bytes -- no exception
       -> name: null    (the assigned value shifted into dt)
       -> _hoodie_partition_path: __HIVE_DEFAULT_PARTITION__
   ```
   
   On master the same statement threw `HoodieKeyException` from this line.
   </details>



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala:
##########
@@ -99,7 +99,15 @@ class SqlKeyGenerator(props: TypedProperties) extends 
BuiltinKeyGenerator(props)
 
   override def getPartitionPath(record: GenericRecord): String = {
     val partitionPath = originalKeyGen.map {
-      _.getKey(record).getPartitionPath
+      // Resolve the partition path on its own where the key generator exposes 
it. Going through
+      // BaseKeyGenerator#getKey would also compute and validate the record 
key, which a MOR partial
+      // update legitimately leaves unset: the merged record is materialised 
against
+      // WRITE_PARTIAL_UPDATE_SCHEMA and so carries only the columns named in 
UPDATE SET. Callers
+      // that want the record key validated still ask for it, via getKey or 
getRecordKey.

Review Comment:
   The "no longer pre-empted" note in the body covers the 
`__HIVE_DEFAULT_PARTITION__` spelling, but a `TimestampBasedKeyGenerator` 
delegate substitutes the epoch instead, so the HUDI-8315 guard at line 170 
never fires:
   
   ```
   TimestampBased (DATE_STRING) -> '1970-01-01'                  guard hit = 
false
   Simple                       -> '__HIVE_DEFAULT_PARTITION__'  guard hit = 
true
   ```
   
   With a `TIMESTAMP` partition schema `_partitionValue.toLong` then throws a 
bare `NumberFormatException`; with a non-timestamp one the row silently takes 
the `1970-01-01` partition. Both were previously pre-empted by the 
`HoodieKeyException`. Could we add a `TimestampBasedKeyGenerator` case to 
`TestSqlKeyGenerator` pinning what a missing timestamp partition field now 
resolves to?



##########
hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala:
##########
@@ -0,0 +1,150 @@
+/*
+ * 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.spark.sql.hudi.command
+
+import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.util.PartitionPathEncodeUtils
+import org.apache.hudi.exception.HoodieKeyException
+import org.apache.hudi.keygen.SimpleKeyGenerator
+import org.apache.hudi.keygen.constant.KeyGeneratorOptions
+
+import org.apache.avro.Schema
+import org.apache.avro.generic.GenericData
+import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows}
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests that [[SqlKeyGenerator]] resolves a partition path without also 
requiring the record key.
+ *
+ * MOR partial updates materialise the merged record against 
`WRITE_PARTIAL_UPDATE_SCHEMA`, which
+ * carries only the fields named in `UPDATE SET`. 
`HoodieIndexUtils#inferPartitionPath` then asks
+ * the key generator for that record's partition path, so a record key absent 
from the assignments
+ * is legitimately unset at that point and must not fail partition resolution.
+ */
+class TestSqlKeyGenerator {
+
+  private val schema = new Schema.Parser().parse(
+    s"""
+       |{
+       |  "type": "record",
+       |  "name": "test_record",
+       |  "fields": [
+       |    {"name": "id", "type": ["null", "long"], "default": null},
+       |    {"name": "amount", "type": ["null", "double"], "default": null},
+       |    {"name": "dt", "type": ["null", "string"], "default": null}
+       |  ]
+       |}
+     """.stripMargin)
+
+  private def keyGenerator(partitionSchema: Option[String] = None): 
SqlKeyGenerator = {
+    val props = new TypedProperties()
+    props.put(SqlKeyGenerator.ORIGINAL_KEYGEN_CLASS_NAME, 
classOf[SimpleKeyGenerator].getName)
+    props.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key, "id")
+    props.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key, "dt")
+    // Spark SQL always sets this (see MergeIntoHoodieTableCommand), and it is 
what makes
+    // convertPartitionPathToSqlType do any work, so cover it rather than 
leaving it None.
+    partitionSchema.foreach(schema => 
props.put(SqlKeyGenerator.PARTITION_SCHEMA, schema))
+    new SqlKeyGenerator(props)
+  }
+
+  /** The partition column is populated; only the record key is missing, as 
under a partial update. */
+  private def recordWithoutRecordKey: GenericData.Record = {
+    val record = new GenericData.Record(schema)
+    record.put("amount", 15.0d)
+    record.put("dt", "2026-08-11")
+    record
+  }
+
+  @Test
+  def testGetPartitionPathDoesNotRequireRecordKey(): Unit = {
+    // Before the fix this threw HoodieKeyException, because getPartitionPath 
delegated to
+    // BaseKeyGenerator#getKey, which builds the whole HoodieKey and so 
validates the record key.
+    assertEquals("2026-08-11", 
keyGenerator().getPartitionPath(recordWithoutRecordKey))
+  }
+
+  @Test
+  def testGetRecordKeyStillRejectsAMissingRecordKey(): Unit = {
+    // Scope guard: the fix must not weaken record-key validation, only stop 
getPartitionPath from
+    // triggering it. A record key that is genuinely required and absent is 
still an error.
+    assertThrows(classOf[HoodieKeyException], () => 
keyGenerator().getRecordKey(recordWithoutRecordKey))
+  }
+
+  @Test
+  def testGetPartitionPathAndRecordKeyOnACompleteRecord(): Unit = {
+    val record = recordWithoutRecordKey
+    record.put("id", 1L)
+    assertEquals("2026-08-11", keyGenerator().getPartitionPath(record))
+    assertEquals("1", keyGenerator().getRecordKey(record))
+  }
+
+  @Test
+  def testGetPartitionPathResolvesThroughTheSqlPartitionSchema(): Unit = {
+    val record = recordWithoutRecordKey
+    record.put("id", 1L)
+    assertEquals("2026-08-11", keyGenerator(Some("dt 
string")).getPartitionPath(record))
+  }
+
+  /**
+   * An unresolvable partition field yields the default partition rather than 
an error. That is
+   * KeyGenUtils#getPartitionPath substituting HUDI_DEFAULT_PARTITION_PATH for 
a null or absent
+   * value, and it predates this change: getKey called the very same 
getPartitionPath, so the
+   * substitution already happened whenever the record key resolved. Pinned 
here so the behaviour is
+   * explicit, because the fix does widen when it is observable, see the 
sibling test below.
+   */
+  @Test
+  def testPartitionFieldMissingFromTheSchemaYieldsTheDefaultPartition(): Unit 
= {
+    val partialSchema = new Schema.Parser().parse(
+      s"""
+         |{
+         |  "type": "record",
+         |  "name": "test_record",
+         |  "fields": [
+         |    {"name": "id", "type": ["null", "long"], "default": null},
+         |    {"name": "amount", "type": ["null", "double"], "default": null}
+         |  ]
+         |}
+       """.stripMargin)
+    val record = new GenericData.Record(partialSchema)
+    record.put("id", 1L)
+    record.put("amount", 15.0d)
+    assertEquals(PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH, 
keyGenerator().getPartitionPath(record))

Review Comment:
   nit, feel free to ignore: this pins the exact behaviour the keygen owners 
have a TODO to change. `TestSimpleKeyGenerator.java:127` marks the Avro path 
with `// TODO this should throw as well`, and its Row-path twin already asserts 
`HoodieException` for a wrong partition field.
   
   The sibling test below already covers the substitution and is the one that 
discriminates the fix. Could we drop this case, or add a pointer to that TODO 
so both do not have to be unwound when it is fixed?



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala:
##########
@@ -99,7 +99,15 @@ class SqlKeyGenerator(props: TypedProperties) extends 
BuiltinKeyGenerator(props)
 
   override def getPartitionPath(record: GenericRecord): String = {
     val partitionPath = originalKeyGen.map {

Review Comment:
   nit, feel free to ignore: this leaves the two sides asymmetric. 
`getRecordKey(GenericRecord)` at lines 78-83 still goes through `getKey`, so it 
computes the partition path and discards it on every Avro record, and a 
record-key lookup can still fail on a partition-side error 
(`TimestampBasedAvroKeyGenerator` throws `HoodieKeyGeneratorException` 
independently of the key).
   
   Could `getRecordKey` take the mirror arm, `case baseKeyGen: BaseKeyGenerator 
=> baseKeyGen.getRecordKey(record)`, so each method does one lookup?



##########
hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala:
##########
@@ -0,0 +1,150 @@
+/*
+ * 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.spark.sql.hudi.command
+
+import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.util.PartitionPathEncodeUtils
+import org.apache.hudi.exception.HoodieKeyException
+import org.apache.hudi.keygen.SimpleKeyGenerator
+import org.apache.hudi.keygen.constant.KeyGeneratorOptions
+
+import org.apache.avro.Schema
+import org.apache.avro.generic.GenericData
+import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows}
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests that [[SqlKeyGenerator]] resolves a partition path without also 
requiring the record key.
+ *
+ * MOR partial updates materialise the merged record against 
`WRITE_PARTIAL_UPDATE_SCHEMA`, which
+ * carries only the fields named in `UPDATE SET`. 
`HoodieIndexUtils#inferPartitionPath` then asks
+ * the key generator for that record's partition path, so a record key absent 
from the assignments
+ * is legitimately unset at that point and must not fail partition resolution.
+ */
+class TestSqlKeyGenerator {
+
+  private val schema = new Schema.Parser().parse(
+    s"""
+       |{
+       |  "type": "record",
+       |  "name": "test_record",
+       |  "fields": [
+       |    {"name": "id", "type": ["null", "long"], "default": null},
+       |    {"name": "amount", "type": ["null", "double"], "default": null},
+       |    {"name": "dt", "type": ["null", "string"], "default": null}
+       |  ]
+       |}
+     """.stripMargin)
+
+  private def keyGenerator(partitionSchema: Option[String] = None): 
SqlKeyGenerator = {
+    val props = new TypedProperties()
+    props.put(SqlKeyGenerator.ORIGINAL_KEYGEN_CLASS_NAME, 
classOf[SimpleKeyGenerator].getName)
+    props.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key, "id")

Review Comment:
   Setting the record key here makes `autoRecordKeyGen` always false, so the 
`AutoRecordGenWrapperKeyGenerator` path is never exercised -- and this change 
does alter it. The wrapper is a `BaseKeyGenerator`, so it now takes the new arm 
and `getPartitionPath` no longer consumes a sequence id (stride 2 becomes 1). 
It is reachable: `KeyGenUtils.isAutoGeneratedRecordKeysEnabled` returns true on 
an *empty-string* record key, commented "spark-sql sets record key config to 
empty string for update".
   
   The numbering itself looks harmless (uniqueness only needs `(instantTime, 
partitionId)`). Could we add one case that omits `RECORDKEY_FIELD_NAME`, so it 
is a pinned decision rather than a side effect?



##########
hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala:
##########
@@ -0,0 +1,150 @@
+/*
+ * 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.spark.sql.hudi.command
+
+import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.util.PartitionPathEncodeUtils
+import org.apache.hudi.exception.HoodieKeyException
+import org.apache.hudi.keygen.SimpleKeyGenerator
+import org.apache.hudi.keygen.constant.KeyGeneratorOptions
+
+import org.apache.avro.Schema
+import org.apache.avro.generic.GenericData
+import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows}
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests that [[SqlKeyGenerator]] resolves a partition path without also 
requiring the record key.
+ *
+ * MOR partial updates materialise the merged record against 
`WRITE_PARTIAL_UPDATE_SCHEMA`, which
+ * carries only the fields named in `UPDATE SET`. 
`HoodieIndexUtils#inferPartitionPath` then asks
+ * the key generator for that record's partition path, so a record key absent 
from the assignments
+ * is legitimately unset at that point and must not fail partition resolution.
+ */
+class TestSqlKeyGenerator {
+
+  private val schema = new Schema.Parser().parse(
+    s"""
+       |{
+       |  "type": "record",
+       |  "name": "test_record",
+       |  "fields": [
+       |    {"name": "id", "type": ["null", "long"], "default": null},
+       |    {"name": "amount", "type": ["null", "double"], "default": null},
+       |    {"name": "dt", "type": ["null", "string"], "default": null}
+       |  ]
+       |}
+     """.stripMargin)
+
+  private def keyGenerator(partitionSchema: Option[String] = None): 
SqlKeyGenerator = {
+    val props = new TypedProperties()
+    props.put(SqlKeyGenerator.ORIGINAL_KEYGEN_CLASS_NAME, 
classOf[SimpleKeyGenerator].getName)
+    props.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key, "id")
+    props.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key, "dt")
+    // Spark SQL always sets this (see MergeIntoHoodieTableCommand), and it is 
what makes
+    // convertPartitionPathToSqlType do any work, so cover it rather than 
leaving it None.
+    partitionSchema.foreach(schema => 
props.put(SqlKeyGenerator.PARTITION_SCHEMA, schema))
+    new SqlKeyGenerator(props)
+  }
+
+  /** The partition column is populated; only the record key is missing, as 
under a partial update. */
+  private def recordWithoutRecordKey: GenericData.Record = {
+    val record = new GenericData.Record(schema)
+    record.put("amount", 15.0d)
+    record.put("dt", "2026-08-11")
+    record
+  }
+
+  @Test
+  def testGetPartitionPathDoesNotRequireRecordKey(): Unit = {
+    // Before the fix this threw HoodieKeyException, because getPartitionPath 
delegated to
+    // BaseKeyGenerator#getKey, which builds the whole HoodieKey and so 
validates the record key.
+    assertEquals("2026-08-11", 
keyGenerator().getPartitionPath(recordWithoutRecordKey))
+  }
+
+  @Test
+  def testGetRecordKeyStillRejectsAMissingRecordKey(): Unit = {
+    // Scope guard: the fix must not weaken record-key validation, only stop 
getPartitionPath from
+    // triggering it. A record key that is genuinely required and absent is 
still an error.
+    assertThrows(classOf[HoodieKeyException], () => 
keyGenerator().getRecordKey(recordWithoutRecordKey))
+  }
+
+  @Test
+  def testGetPartitionPathAndRecordKeyOnACompleteRecord(): Unit = {
+    val record = recordWithoutRecordKey
+    record.put("id", 1L)
+    assertEquals("2026-08-11", keyGenerator().getPartitionPath(record))
+    assertEquals("1", keyGenerator().getRecordKey(record))
+  }
+
+  @Test
+  def testGetPartitionPathResolvesThroughTheSqlPartitionSchema(): Unit = {
+    val record = recordWithoutRecordKey
+    record.put("id", 1L)
+    assertEquals("2026-08-11", keyGenerator(Some("dt 
string")).getPartitionPath(record))

Review Comment:
   This case pins nothing about the SQL partition schema: `dt string` takes the 
`case _ => partitionValue` identity arm, so it is 
`testGetPartitionPathAndRecordKeyOnACompleteRecord`'s first assertion plus an 
inert config. Deleting the entire `convertPartitionPathToSqlType(...)` call 
from `getPartitionPath(GenericRecord)` leaves all 6 tests green, so the intent 
stated at lines 59-60 is not met.
   
   Could we switch this to a `TimestampType` partition schema (a micros-valued 
`dt`, with a pinned timezone since the output is TZ-dependent), which would 
also cover the arm the change newly feeds?



##########
hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala:
##########
@@ -0,0 +1,150 @@
+/*
+ * 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.spark.sql.hudi.command
+
+import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.util.PartitionPathEncodeUtils
+import org.apache.hudi.exception.HoodieKeyException
+import org.apache.hudi.keygen.SimpleKeyGenerator
+import org.apache.hudi.keygen.constant.KeyGeneratorOptions
+
+import org.apache.avro.Schema
+import org.apache.avro.generic.GenericData
+import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows}
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests that [[SqlKeyGenerator]] resolves a partition path without also 
requiring the record key.
+ *
+ * MOR partial updates materialise the merged record against 
`WRITE_PARTIAL_UPDATE_SCHEMA`, which
+ * carries only the fields named in `UPDATE SET`. 
`HoodieIndexUtils#inferPartitionPath` then asks
+ * the key generator for that record's partition path, so a record key absent 
from the assignments
+ * is legitimately unset at that point and must not fail partition resolution.
+ */
+class TestSqlKeyGenerator {
+
+  private val schema = new Schema.Parser().parse(
+    s"""

Review Comment:
   nit, feel free to ignore: `s"""` here (and at lines 112 and 137) has no 
interpolation, so the `s` prefix can go. The two partial schemas are also 
near-copies of this one -- deriving them by filtering this `schema` val's 
fields would drop roughly 20 lines. Purely cosmetic, nothing fires on it.



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala:
##########
@@ -99,7 +99,15 @@ class SqlKeyGenerator(props: TypedProperties) extends 
BuiltinKeyGenerator(props)
 
   override def getPartitionPath(record: GenericRecord): String = {

Review Comment:
   `MergeIntoKeyGenerator` overrides this method and is the generator used for 
a primary-key-less table (`MergeIntoHoodieTableCommand.scala:780-785`). It 
reads `record.get(PARTITION_PATH_META_FIELD_ORD)` (ordinal 3) unconditionally, 
which throws `ArrayIndexOutOfBoundsException` on the same 2-field partial 
record before `super.getPartitionPath` ever runs, so the fix does not reach 
that spelling.
   
   I did not confirm that keyless + MOR + global index + partial update is 
actually configurable. If it is, could `MergeIntoKeyGenerator.getPartitionPath` 
check `record.getSchema.getFields.size` before reading the ordinal?



-- 
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