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 231545998a12 fix(spark-sql): resolve a partition path without 
validating the record key (#19709)
231545998a12 is described below

commit 231545998a12fd1943e2393065f76cbdc0602eac
Author: Lin Liu <[email protected]>
AuthorDate: Tue Aug 25 01:34:30 2026 -0700

    fix(spark-sql): resolve a partition path without validating the record key 
(#19709)
    
    * fix(spark-sql): resolve a partition path without validating the record key
    
    SqlKeyGenerator#getPartitionPath(GenericRecord) delegated to 
BaseKeyGenerator#getKey,
    which builds the whole HoodieKey and so computes AND validates the record 
key. Asking
    for a partition path therefore failed on a record whose record key was 
unset, even with
    the partition column fully populated.
    
    That is reachable on MOR with a global bloom or global simple index. The 
tagging stage
    merges the incoming record with its existing version -- 
mayContainDuplicateLookup is
    tableType == MERGE_ON_READ in HoodieGlobalBloomIndex and 
HoodieGlobalSimpleIndex, and
    requiresMergingWithOlderRecordVersion in
    HoodieIndexUtils#tagGlobalLocationBackToRecords is true on MOR whereas CoW 
defers the
    merge to the file rewrite -- and then asks the key generator for the merged 
record's
    partition path via HoodieIndexUtils#inferPartitionPath. Under MOR partial 
updates that
    merged record is materialised against WRITE_PARTIAL_UPDATE_SCHEMA, carrying 
only the
    columns named in UPDATE SET, so a MERGE INTO that does not assign the 
record key -- the
    ordinary partial-update shape -- died with
    
      HoodieKeyException: recordKey value: "null" for field: "id" cannot be 
null or empty
    
    Resolve the partition path directly where the delegate exposes it. 
Narrowing on
    BaseKeyGenerator rather than casting keeps behaviour identical for a key 
generator that
    implements only SparkKeyGeneratorInterface, which declares just the Row and 
InternalRow
    accessors; its only Avro-facing method, inherited from 
KeyGeneratorInterface, is getKey.
    getPartitionPathFields in this same class already uses that construct.
    
    Scoped to the GenericRecord overload. getRecordKey(GenericRecord) still 
routes through
    getKey and still validates -- it wants the record key -- and getKey itself 
is untouched,
    so callers needing a fully validated HoodieKey keep getting one. The change 
is strictly
    more permissive: getKey is
    new HoodieKey(getRecordKey(record), getPartitionPath(record)), so this 
returns the same
    expression for the partition half and merely stops computing the record-key 
half.
    
    TestSqlKeyGenerator pins all three edges: partition resolution with the 
record key unset
    now succeeds (this case fails on the parent commit with the exception 
above), record-key
    resolution still rejects a missing key, and a complete record is unaffected.
    
    This unmasks a separate, previously unreachable defect on the same path, 
where the
    partial-update merged record is serialized against a mismatched schema and 
raises
    UnresolvedUnionException from BaseAvroPayload#getRecordBytes. That is a 
distinct root
    cause in the payload path and is left for a follow-up so this change stays 
reviewable;
    end-to-end coverage of the shape is held with it.
    
    * Cover the sql partition schema and the default-partition substitution
    
    Three cases added after review. One sets hoodie.sql.partition.schema, which 
Spark SQL always
    supplies, so convertPartitionPathToSqlType is actually exercised rather 
than short-circuited.
    
    The other two pin what happens when the partition field itself cannot be 
resolved from the
    record. KeyGenUtils.getPartitionPath substitutes the default partition for 
a null or absent
    value, and that predates this change: getKey called the same 
getPartitionPath, so the
    substitution already applied whenever the record key resolved. Confirmed on 
the parent commit,
    where the record-key-present case passes and only the both-missing case 
fails. What this change
    widens is therefore narrow, and now stated: with the record key also 
unresolvable, the record-key
    exception no longer pre-empts the substitution.
    
    Resolving a partition path is not a validity check on the record, so a 
caller that needs the
    partition to be genuinely present has to assert that itself.
    
    * Cover the timestamp arms and the auto-record-key delegate, and mirror 
getRecordKey
    
    Addresses review feedback.
    
    The sql-partition-schema case pinned nothing: a `dt string` schema takes 
the identity arm of
    convertPartitionPathToSqlType, so deleting that call entirely left every 
test green. Replaced
    with a `dt timestamp` schema over a microsecond value, which drives the 
TimestampType arm and
    escapePathName. The expected value was captured from a run rather than 
hand-derived, and the
    default DateTimeZone is pinned and restored because the output is formatted 
in it.
    
    Added a TimestampBasedKeyGenerator delegate case. That generator resolves 
an absent partition
    field to the formatted epoch rather than HUDI_DEFAULT_PARTITION_PATH, so 
the guard in
    convertPartitionPathToSqlType never fires for it. This change makes that 
reachable, where the
    record-key exception previously pre-empted it, so the behaviour is pinned 
rather than implied.
    
    Added a case that omits the record-key config. That makes 
isAutoGeneratedRecordKeysEnabled true
    and exercises the AutoRecordGenWrapperKeyGenerator, which this change does 
alter: as a
    BaseKeyGenerator it now takes the direct arm, so getPartitionPath no longer 
consumes a generated
    sequence id as a side effect of building a HoodieKey. Uniqueness does not 
depend on that stride,
    but it is now a pinned decision rather than an unexercised side effect.
    
    getRecordKey(GenericRecord) takes the mirror arm, so neither accessor pays 
for the other half.
    Previously it computed the partition path and discarded it on every Avro 
record, and a
    partition-side failure could surface from a record-key lookup: 
TimestampBasedAvroKeyGenerator
    raises independently of the key.
    
    Annotated the default-partition case with a pointer to 
TestSimpleKeyGenerator's "TODO this
    should throw as well" on the Avro path, so both expectations are unwound 
together. Dropped the
    string-interpolation prefixes that interpolate nothing and derived the two 
partial schemas from
    the base schema instead of restating them.
    
    * Set the partition schema in the fixtures, guard the MergeIntoKeyGenerator 
ordinals, drop the getRecordKey mirror
    
    Addresses review feedback. Three things, the first of which invalidated 
most of the previous
    round's coverage.
    
    The fixtures defaulted the partition schema to None, but production sets
    hoodie.sql.partition.schema at every construction site 
(ProvidesHoodieConfig and both
    MergeIntoHoodieTableCommand copies). With it unset, 
convertPartitionPathToSqlType returns its
    input immediately, so seven of the eight cases never reached the 
default-partition guard, the
    fragment-count early-out or hive-style handling, including the two named 
after default-partition
    behaviour. It now defaults to a real schema, with one explicit None case 
for the non-partitioned
    table, paired against the timestamp case on the same value so the 
conversion is shown to be
    skipped rather than merely absent. The auto-record-key case was likewise 
red on the parent commit
    only because the fixture omitted a property that HoodieCreateRecordUtils 
always sets, so it failed
    on the harness rather than on the behaviour it claimed to pin; it now sets 
both properties and
    asserts the sequence id, which reads 100_1_0 here and 100_1_2 on the 
parent. The timestamp
    delegate is pinned under all three partition-schema spellings, because they 
diverge: a timestamp
    column turns the epoch string into a bare NumberFormatException, a string 
column silently accepts
    the epoch partition.
    
    MergeIntoKeyGenerator reads the record key and partition path by meta-field 
ordinal, and a record
    materialised against WRITE_PARTIAL_UPDATE_SCHEMA can be shorter than the 
ordinal, so the read
    raised ArrayIndexOutOfBoundsException from inside the key generator. Both 
Avro arms now check the
    length first and fall through to the existing fallback. This is a bounds 
check, not a proof that
    the field at the ordinal is a meta field: a long record whose schema is not 
meta-prefixed still
    reads data there, as before. The new test covers both short-record arms 
plus two meta-prefixed
    regression cases, without which the guard could disable meta-field 
resolution outright and the
    short-record cases would still pass.
    
    getRecordKey(GenericRecord) goes back to the getKey form. The mirror is the 
right end state but it
    is a second behaviour change, unrelated to the reported issue, and this PR 
is already gated on
    merge order; it belongs in a follow-up with the case that discriminates it.
    
    Of the 15 cases, 5 fail on the parent commit and 10 pass there. The 10 pin 
behaviour rather than
    guarding a regression, so they are coverage, not proof of the fix.
    
    * Set the partition id property as a string so both lines compile
    
    The Int literal boxes to Object on this base but not on the 1.x line, where 
scalac rejects the
    implicit conversion. Both TypedProperties variants render the value through
    String.valueOf on read, so the string form is equivalent at runtime and 
compiles on either.
---
 .../sql/hudi/command/MergeIntoKeyGenerator.scala   |  28 ++-
 .../spark/sql/hudi/command/SqlKeyGenerator.scala   |  10 +-
 .../hudi/command/TestMergeIntoKeyGenerator.scala   | 141 +++++++++++
 .../sql/hudi/command/TestSqlKeyGenerator.scala     | 257 +++++++++++++++++++++
 4 files changed, 433 insertions(+), 3 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoKeyGenerator.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoKeyGenerator.scala
index 8ae16217f1aa..b6f92494b1b6 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoKeyGenerator.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoKeyGenerator.scala
@@ -39,7 +39,11 @@ import org.apache.spark.unsafe.types.UTF8String
 class MergeIntoKeyGenerator(props: TypedProperties) extends 
SqlKeyGenerator(props) {
 
   override def getRecordKey(record: GenericRecord): String = {
-    val recordKey = record.get(RECORD_KEY_META_FIELD_ORD)
+    val recordKey = if (carriesMetaField(record, RECORD_KEY_META_FIELD_ORD)) {
+      record.get(RECORD_KEY_META_FIELD_ORD)
+    } else {
+      null
+    }
     if (recordKey != null) {
       recordKey.toString
     } else {
@@ -66,7 +70,11 @@ class MergeIntoKeyGenerator(props: TypedProperties) extends 
SqlKeyGenerator(prop
   }
 
   override def getPartitionPath(record: GenericRecord): String = {
-    val partitionPath = record.get(PARTITION_PATH_META_FIELD_ORD)
+    val partitionPath = if (carriesMetaField(record, 
PARTITION_PATH_META_FIELD_ORD)) {
+      record.get(PARTITION_PATH_META_FIELD_ORD)
+    } else {
+      null
+    }
     if (partitionPath != null) {
       partitionPath.toString
     } else {
@@ -92,4 +100,20 @@ class MergeIntoKeyGenerator(props: TypedProperties) extends 
SqlKeyGenerator(prop
     }
   }
 
+  /**
+   * Whether the record is long enough for `ord` to address a meta field at 
all.
+   *
+   * The meta fields are only prepended once a record has been through the 
write path, so a record
+   * built against a projected schema can be shorter than the ordinal: a MOR 
partial update
+   * materialises the merged record against `WRITE_PARTIAL_UPDATE_SCHEMA`, 
which carries only the
+   * columns named in `UPDATE SET`. Reading the ordinal off such a record 
raises a bare
+   * `ArrayIndexOutOfBoundsException` out of the key generator, which does not 
name the statement
+   * that caused it. Falling back to the SQL key generator gives the same 
answer the unpopulated
+   * meta field would have routed to.
+   *
+   * This is a bounds check, not a proof that the field at `ord` is a meta 
field: a record of the
+   * right length whose schema is not meta-prefixed still reads data here, 
exactly as before.
+   */
+  private def carriesMetaField(record: GenericRecord, ord: Int): Boolean =
+    record.getSchema.getFields.size > ord
 }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala
index 3df7c6673961..7062beb1440c 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala
+++ 
b/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)
+      // SparkKeyGeneratorInterface exposes no Avro-facing accessor beyond 
getKey, so a generator
+      // that is not a BaseKeyGenerator retains the previous behaviour.
+      case keyGen => keyGen.getKey(record).getPartitionPath
     } getOrElse {
       complexKeyGen.getPartitionPath(record)
     }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestMergeIntoKeyGenerator.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestMergeIntoKeyGenerator.scala
new file mode 100644
index 000000000000..e306cc7311bd
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestMergeIntoKeyGenerator.scala
@@ -0,0 +1,141 @@
+/*
+ * 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 [[MergeIntoKeyGenerator]] reads the meta-field ordinals only 
from records long enough
+ * to have them.
+ *
+ * It resolves both the record key and the partition path by ordinal (2 and 3) 
off the meta-field
+ * prefix, falling back to [[SqlKeyGenerator]] when the meta field is 
unpopulated. A MOR partial
+ * update materialises the merged record against 
`WRITE_PARTIAL_UPDATE_SCHEMA`, so the record can be
+ * shorter than the ordinal, and an unguarded read raises 
`ArrayIndexOutOfBoundsException` from
+ * inside the key generator, naming nothing about the statement that caused it.
+ */
+class TestMergeIntoKeyGenerator {
+
+  /** A record carrying only the columns an `UPDATE SET amount, ts` would 
assign. */
+  private val partialSchema = new Schema.Parser().parse(
+    """
+       |{
+       |  "type": "record",
+       |  "name": "partial_record",
+       |  "fields": [
+       |    {"name": "amount", "type": ["null", "double"], "default": null},
+       |    {"name": "ts", "type": ["null", "long"], "default": null}
+       |  ]
+       |}
+     """.stripMargin)
+
+  /** The shape the write path produces: the five meta fields, then the data 
columns. */
+  private val metaPrefixedSchema = new Schema.Parser().parse(
+    """
+       |{
+       |  "type": "record",
+       |  "name": "meta_prefixed_record",
+       |  "fields": [
+       |    {"name": "_hoodie_commit_time", "type": ["null", "string"], 
"default": null},
+       |    {"name": "_hoodie_commit_seqno", "type": ["null", "string"], 
"default": null},
+       |    {"name": "_hoodie_record_key", "type": ["null", "string"], 
"default": null},
+       |    {"name": "_hoodie_partition_path", "type": ["null", "string"], 
"default": null},
+       |    {"name": "_hoodie_file_name", "type": ["null", "string"], 
"default": null},
+       |    {"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: MergeIntoKeyGenerator = {
+    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")
+    props.put(SqlKeyGenerator.PARTITION_SCHEMA, "dt string")
+    new MergeIntoKeyGenerator(props)
+  }
+
+  private def partialRecord: GenericData.Record = {
+    val record = new GenericData.Record(partialSchema)
+    record.put("amount", 15.0d)
+    record.put("ts", 200L)
+    record
+  }
+
+  /**
+   * Two fields against partition-path ordinal 3. Before the guard this raised
+   * ArrayIndexOutOfBoundsException; it now falls through to the SQL key 
generator, which resolves
+   * the absent partition field to the default partition.
+   */
+  @Test
+  def testGetPartitionPathFallsBackOnARecordShorterThanTheOrdinal(): Unit = {
+    assertEquals(PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH,
+      keyGenerator.getPartitionPath(partialRecord))
+  }
+
+  /**
+   * Same record against record-key ordinal 2. The fallback reaches record-key 
validation, so the
+   * failure names the field rather than an array bound.
+   */
+  @Test
+  def testGetRecordKeyFallsBackOnARecordShorterThanTheOrdinal(): Unit = {
+    val thrown = assertThrows(classOf[HoodieKeyException], () => 
keyGenerator.getRecordKey(partialRecord))
+    assert(thrown.getMessage.contains("id"), s"expected the message to name 
the field, got: ${thrown.getMessage}")
+  }
+
+  /**
+   * Regression guard for the normal path: a meta-prefixed record is long 
enough, so both accessors
+   * still read the ordinal rather than falling back. Without this the guard 
could disable meta-field
+   * resolution outright and the tests above would still pass.
+   */
+  @Test
+  def testMetaPrefixedRecordStillResolvesFromTheMetaFields(): Unit = {
+    val record = new GenericData.Record(metaPrefixedSchema)
+    record.put("_hoodie_record_key", "id:1")
+    record.put("_hoodie_partition_path", "dt=2026-08-11")
+    record.put("id", 1L)
+    record.put("dt", "2026-08-12") // deliberately disagrees, to show the meta 
field is what is read
+    assertEquals("id:1", keyGenerator.getRecordKey(record))
+    assertEquals("dt=2026-08-11", keyGenerator.getPartitionPath(record))
+  }
+
+  /**
+   * A meta-prefixed record whose meta fields are unpopulated is still long 
enough, so the ordinal is
+   * read, found null, and the existing fallback runs. Pins that the guard did 
not change which of
+   * the two fallback reasons applies.
+   */
+  @Test
+  def testMetaPrefixedRecordWithNullMetaFieldsFallsBackToTheDataColumns(): 
Unit = {
+    val record = new GenericData.Record(metaPrefixedSchema)
+    record.put("id", 1L)
+    record.put("dt", "2026-08-11")
+    assertEquals("1", keyGenerator.getRecordKey(record))
+    assertEquals("2026-08-11", keyGenerator.getPartitionPath(record))
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala
new file mode 100644
index 000000000000..cdb90b1a7e44
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/hudi/command/TestSqlKeyGenerator.scala
@@ -0,0 +1,257 @@
+/*
+ * 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.TimestampKeyGeneratorConfig
+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.{KeyGenUtils, SimpleKeyGenerator, 
TimestampBasedKeyGenerator}
+import org.apache.hudi.keygen.constant.KeyGeneratorOptions
+
+import org.apache.avro.Schema
+import org.apache.avro.generic.GenericData
+import org.joda.time.DateTimeZone
+import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows}
+import org.junit.jupiter.api.Test
+
+import scala.collection.JavaConverters._
+
+/**
+ * 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.
+ *
+ * The fixtures set `hoodie.sql.partition.schema` by default because 
production always does, at
+ * every construction site (`ProvidesHoodieConfig` and both 
`MergeIntoHoodieTableCommand` copies).
+ * Leaving it unset makes `convertPartitionPathToSqlType` return its input 
immediately, which skips
+ * the default-partition guard, the fragment-count early-out and hive-style 
handling, so the cases
+ * named after those behaviours would never reach them.
+ */
+class TestSqlKeyGenerator {
+
+  private val schema = new Schema.Parser().parse(
+    """
+       |{
+       |  "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)
+
+  /** 2026-08-11 00:00:00 UTC in microseconds, which is what the GenericRecord 
path assumes. */
+  private val timestampMicros = String.valueOf(1786406400000000L)
+
+  /** The same record shape with only the named fields, as a partial update 
produces. */
+  private def projected(fieldNames: String*): Schema = {
+    val fields = schema.getFields.asScala
+      .filter(f => fieldNames.contains(f.name))
+      .map(f => new Schema.Field(f.name, f.schema, null, f.defaultVal))
+    Schema.createRecord("test_record", null, null, false, fields.asJava)
+  }
+
+  private def keyGenerator(partitionSchema: Option[String] = Some("dt string"),
+                           withRecordKey: Boolean = true): SqlKeyGenerator = {
+    val props = new TypedProperties()
+    props.put(SqlKeyGenerator.ORIGINAL_KEYGEN_CLASS_NAME, 
classOf[SimpleKeyGenerator].getName)
+    if (withRecordKey) {
+      props.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key, "id")
+    }
+    props.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key, "dt")
+    // Production sets both whenever record keys are auto-generated 
(HoodieCreateRecordUtils), so
+    // without them the auto-record-key delegate fails on the missing property 
rather than on the
+    // behaviour under test.
+    props.put(KeyGenUtils.RECORD_KEY_GEN_INSTANT_TIME_CONFIG, "100")
+    props.put(KeyGenUtils.RECORD_KEY_GEN_PARTITION_ID_CONFIG, "1")
+    partitionSchema.foreach(ps => props.put(SqlKeyGenerator.PARTITION_SCHEMA, 
ps))
+    new SqlKeyGenerator(props)
+  }
+
+  /** Delegates to a TimestampBasedKeyGenerator rather than a 
SimpleKeyGenerator. */
+  private def timestampKeyGenerator(partitionSchema: Option[String]): 
SqlKeyGenerator = {
+    val props = new TypedProperties()
+    props.put(SqlKeyGenerator.ORIGINAL_KEYGEN_CLASS_NAME, 
classOf[TimestampBasedKeyGenerator].getName)
+    props.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key, "id")
+    props.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key, "dt")
+    props.put(TimestampKeyGeneratorConfig.TIMESTAMP_TYPE_FIELD.key, 
"DATE_STRING")
+    props.put(TimestampKeyGeneratorConfig.TIMESTAMP_INPUT_DATE_FORMAT.key, 
"yyyy-MM-dd")
+    props.put(TimestampKeyGeneratorConfig.TIMESTAMP_OUTPUT_DATE_FORMAT.key, 
"yyyy-MM-dd")
+    partitionSchema.foreach(ps => props.put(SqlKeyGenerator.PARTITION_SCHEMA, 
ps))
+    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
+  }
+
+  /** A partial-update shape: the partition field is not in the schema at all. 
*/
+  private def recordMissingThePartitionField: GenericData.Record = {
+    val record = new GenericData.Record(projected("id", "amount"))
+    record.put("id", 1L)
+    record.put("amount", 15.0d)
+    record
+  }
+
+  /** Runs `f` with the default timezone pinned, since the timestamp arms 
format in it. */
+  private def inUtc[T](f: => T): T = {
+    val previousZone = DateTimeZone.getDefault
+    DateTimeZone.setDefault(DateTimeZone.UTC)
+    try f finally DateTimeZone.setDefault(previousZone)
+  }
+
+  @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))
+  }
+
+  /**
+   * Drives convertPartitionPathToSqlType's TimestampType arm, the only arm 
that rewrites the value.
+   * The expected string was captured from a run rather than derived.
+   */
+  @Test
+  def testGetPartitionPathConvertsATimestampPartitionValue(): Unit = inUtc {
+    val record = new GenericData.Record(schema)
+    record.put("id", 1L)
+    record.put("dt", timestampMicros)
+    assertEquals("2026-08-11 00%3A00%3A00",
+      keyGenerator(Some("dt timestamp")).getPartitionPath(record))
+  }
+
+  /**
+   * The one shape that legitimately supplies no partition schema is a 
non-partitioned table, where
+   * convertPartitionPathToSqlType returns its input untouched. Paired with 
the case above on the
+   * same value, so the conversion is shown to be skipped rather than merely 
absent.
+   */
+  @Test
+  def testNonPartitionedTableLeavesThePartitionValueUnconverted(): Unit = 
inUtc {
+    val record = new GenericData.Record(schema)
+    record.put("id", 1L)
+    record.put("dt", timestampMicros)
+    assertEquals(timestampMicros, keyGenerator(partitionSchema = 
None).getPartitionPath(record))
+  }
+
+  /**
+   * A TimestampBasedKeyGenerator delegate does NOT substitute 
HUDI_DEFAULT_PARTITION_PATH for an
+   * absent partition field: it formats the epoch instead, so the HUDI-8315 
guard in
+   * convertPartitionPathToSqlType never fires for it. Pinned because this 
change makes the case
+   * reachable, where previously the record-key exception pre-empted it.
+   *
+   * All three partition-schema spellings are pinned separately below because 
they diverge, and the
+   * divergence is the point: a `timestamp` column turns the epoch string into 
a bare
+   * NumberFormatException, while a `string` column silently accepts the epoch 
partition.
+   */
+  @Test
+  def testTimestampDelegateResolvesAnAbsentPartitionFieldToTheEpoch(): Unit = 
inUtc {
+    assertEquals("1970-01-01",
+      
timestampKeyGenerator(None).getPartitionPath(recordMissingThePartitionField))
+  }
+
+  @Test
+  def 
testTimestampDelegateSilentlyAcceptsTheEpochUnderAStringPartitionSchema(): Unit 
= inUtc {
+    assertEquals("1970-01-01",
+      timestampKeyGenerator(Some("dt 
string")).getPartitionPath(recordMissingThePartitionField))
+  }
+
+  @Test
+  def testTimestampDelegateThrowsUnderATimestampPartitionSchema(): Unit = 
inUtc {
+    // The epoch string the delegate produced is not microseconds, so the 
TimestampType arm cannot
+    // parse it. NumberFormatException rather than a Hudi exception is what 
the code does today.
+    assertThrows(classOf[NumberFormatException],
+      () => timestampKeyGenerator(Some("dt 
timestamp")).getPartitionPath(recordMissingThePartitionField))
+  }
+
+  /**
+   * Without a record-key config KeyGenUtils#isAutoGeneratedRecordKeysEnabled 
is true, so the delegate
+   * is wrapped in an AutoRecordGenWrapperKeyGenerator. That wrapper is a 
BaseKeyGenerator, so it now
+   * takes the direct arm and getPartitionPath no longer consumes a generated 
sequence id as a side
+   * effect of building a HoodieKey.
+   *
+   * The record key is asserted after two partition lookups to pin that 
stride: the sequence id is
+   * `instantTime_partitionId_rowId`, so it reads 100_1_0 here and 100_1_2 
before the change.
+   * Uniqueness never depended on the stride, but it is now a pinned decision.
+   */
+  @Test
+  def testAutoRecordKeyDelegateDoesNotConsumeSequenceIdsOnPartitionLookups(): 
Unit = {
+    val record = new GenericData.Record(schema)
+    record.put("amount", 15.0d)
+    record.put("dt", "2026-08-11")
+    val keyGen = keyGenerator(withRecordKey = false)
+    assertEquals("2026-08-11", keyGen.getPartitionPath(record))
+    assertEquals("2026-08-11", keyGen.getPartitionPath(record))
+    assertEquals("100_1_0", keyGen.getRecordKey(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.
+   *
+   * NOTE this is the behaviour TestSimpleKeyGenerator marks with "TODO this 
should throw as well" on
+   * the Avro path, its Row-path twin already asserting HoodieException. If 
that TODO is addressed,
+   * this expectation changes with it; the sibling test below is the one that 
discriminates the fix.
+   */
+  @Test
+  def testPartitionFieldMissingFromTheSchemaYieldsTheDefaultPartition(): Unit 
= {
+    assertEquals(PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH,
+      keyGenerator().getPartitionPath(recordMissingThePartitionField))
+  }
+
+  /**
+   * With BOTH the record key and the partition field unresolvable, the 
record-key exception no
+   * longer pre-empts the default-partition substitution. This is the one 
behaviour this change
+   * widens, and it is the shape a MOR partial update produces, so it is 
stated rather than left to
+   * be discovered: resolving a partition path is not a validity check on the 
record.
+   */
+  @Test
+  def testPartitionAndRecordKeyBothMissingYieldTheDefaultPartition(): Unit = {
+    val record = new GenericData.Record(projected("amount"))
+    record.put("amount", 15.0d)
+    assertEquals(PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH,
+      keyGenerator().getPartitionPath(record))
+  }
+}

Reply via email to