linliu-code commented on code in PR #19610:
URL: https://github.com/apache/hudi/pull/19610#discussion_r3788854529


##########
hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoHoodieTableCommand.scala:
##########
@@ -266,6 +266,74 @@ case class MergeIntoHoodieTableCommand(mergeInto: 
MergeIntoTable) extends Hoodie
       "ordering fields",
       updatingActions.flatMap(_.assignments))
 
+  /**
+   * Mapping of the target table's partition columns onto the [[sourceTable]] 
expression that
+   * supplies each one, resolved the same way as the record-key and ordering 
fields.
+   *
+   * This is required, not optional. For a target table bearing a record key 
the incoming batch is
+   * written from the [[sourceTable]] alone (see [[getProcessedInputDf]]) and 
Hudi's index tagging
+   * is what identifies updates - tagging keys off `(recordKey, 
partitionPath)`. When the partition
+   * column reaches the writer unset, the key generator resolves it to the 
default partition, so
+   * tagging looks in the wrong partition, finds nothing, and treats every 
incoming record as
+   * not-matched. That is silently destructive rather than merely wrong:
+   *
+   *  - with only `WHEN MATCHED` clauses the record falls through to 
[[HoodieRecord.SENTINEL]] in
+   *    `ExpressionPayload#processNotMatchedRecord` and is dropped - the 
statement reports success
+   *    and writes an *empty commit*;
+   *  - with a `WHEN NOT MATCHED ... INSERT` clause the record is instead 
inserted into the default
+   *    partition, **duplicating the primary key across two partitions** while 
the intended update
+   *    is lost.
+   *
+   * Resolving the column here means the merge places records in the right 
partition whenever the
+   * value is derivable. Three sources are consulted, in order:
+   *
+   *  1. the `ON` condition, via [[recordKeyAttributeToConditionExpression]], 
which already
+   *     enumerates partition fields but leaves them optional (see the "allow 
partition path to be
+   *     part of the merge condition but not required" note there) - reused 
as-is so a column
+   *     matched under a different source name (`ON t.dt = s.event_dt`) keeps 
working, and so the
+   *     same target attribute is never contributed twice;
+   *  2. the source-table output and the MERGE assignments, via
+   *     [[resolveFieldAssociationsBetweenSourceAndTarget]];
+   *  3. otherwise the statement is rejected, rather than letting the write 
silently corrupt the
+   *     table.
+   *
+   * NOTE: this applies only to tables bearing a record key. A primary-keyless 
target takes the
+   *       other branch of [[getProcessedInputDf]], where the source is 
left-outer-joined with the
+   *       target and the meta columns are projected, and 
[[MergeIntoKeyGenerator.getPartitionPath]]
+   *       reads `_hoodie_partition_path` off that meta - so a matched row is 
already placed in the
+   *       right partition without the source carrying the column at all.
+   */
+  private lazy val partitionFieldsAssociatedExpressions: Seq[(Attribute, 
Expression)] =
+    if (!hasPrimaryKey() || hoodieCatalogTable.partitionFields.isEmpty) {
+      Seq.empty
+    } else {
+      val resolver = sparkSession.sessionState.conf.resolver
+      // Associations the ON-condition path has already produced; anything 
covered there needs no
+      // further resolution, and re-adding it would put two aliases for one 
column in the projection.
+      val resolvedFromCondition = recordKeyAttributeToConditionExpression
+      hoodieCatalogTable.partitionFields.toSeq.flatMap { partitionField =>
+        if (resolvedFromCondition.exists { case (attr, _) => 
resolver(attr.name, partitionField) }) {
+          Seq.empty
+        } else {
+          try {
+            resolveFieldAssociationsBetweenSourceAndTarget(
+              resolver,
+              mergeInto.targetTable,
+              mergeInto.sourceTable,
+              Seq(partitionField),
+              "partition fields",
+              updatingActions.flatMap(_.assignments) ++ 
insertingActions.flatMap(_.assignments))

Review Comment:
   An assignment gives the record's *new* partition, not its current one — so 
for a partition-changing update on a non-global index (`update set t.dt = 
s.new_dt` where `new_dt` differs from the row's partition), tagging looks in 
`new_dt`, misses, and the record is dropped as `SENTINEL`: the empty commit 
this PR is fixing. The assignment test passes because `src_dt` equals the 
existing partition value; could we add a case with a differing value to pin 
down what actually happens there?
   
   Relatedly, `orderingFieldsAssociatedExpressions` (`:267`) uses only 
`updatingActions` — with `insertingActions` in the union here, a merge whose 
INSERT clause alone names the partition column will tag *matched* records from 
it. Same applies to the spark4-common copy.



##########
hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoHoodieTableCommand.scala:
##########
@@ -266,6 +266,74 @@ case class MergeIntoHoodieTableCommand(mergeInto: 
MergeIntoTable) extends Hoodie
       "ordering fields",
       updatingActions.flatMap(_.assignments))
 
+  /**
+   * Mapping of the target table's partition columns onto the [[sourceTable]] 
expression that
+   * supplies each one, resolved the same way as the record-key and ordering 
fields.
+   *
+   * This is required, not optional. For a target table bearing a record key 
the incoming batch is
+   * written from the [[sourceTable]] alone (see [[getProcessedInputDf]]) and 
Hudi's index tagging
+   * is what identifies updates - tagging keys off `(recordKey, 
partitionPath)`. When the partition
+   * column reaches the writer unset, the key generator resolves it to the 
default partition, so
+   * tagging looks in the wrong partition, finds nothing, and treats every 
incoming record as
+   * not-matched. That is silently destructive rather than merely wrong:
+   *
+   *  - with only `WHEN MATCHED` clauses the record falls through to 
[[HoodieRecord.SENTINEL]] in
+   *    `ExpressionPayload#processNotMatchedRecord` and is dropped - the 
statement reports success
+   *    and writes an *empty commit*;
+   *  - with a `WHEN NOT MATCHED ... INSERT` clause the record is instead 
inserted into the default
+   *    partition, **duplicating the primary key across two partitions** while 
the intended update
+   *    is lost.
+   *
+   * Resolving the column here means the merge places records in the right 
partition whenever the
+   * value is derivable. Three sources are consulted, in order:
+   *
+   *  1. the `ON` condition, via [[recordKeyAttributeToConditionExpression]], 
which already
+   *     enumerates partition fields but leaves them optional (see the "allow 
partition path to be
+   *     part of the merge condition but not required" note there) - reused 
as-is so a column
+   *     matched under a different source name (`ON t.dt = s.event_dt`) keeps 
working, and so the
+   *     same target attribute is never contributed twice;
+   *  2. the source-table output and the MERGE assignments, via
+   *     [[resolveFieldAssociationsBetweenSourceAndTarget]];
+   *  3. otherwise the statement is rejected, rather than letting the write 
silently corrupt the
+   *     table.
+   *
+   * NOTE: this applies only to tables bearing a record key. A primary-keyless 
target takes the
+   *       other branch of [[getProcessedInputDf]], where the source is 
left-outer-joined with the
+   *       target and the meta columns are projected, and 
[[MergeIntoKeyGenerator.getPartitionPath]]
+   *       reads `_hoodie_partition_path` off that meta - so a matched row is 
already placed in the
+   *       right partition without the source carrying the column at all.
+   */
+  private lazy val partitionFieldsAssociatedExpressions: Seq[(Attribute, 
Expression)] =
+    if (!hasPrimaryKey() || hoodieCatalogTable.partitionFields.isEmpty) {

Review Comment:
   Should this be gated on the index being global? For `hoodie.index.type = 
RECORD_INDEX` (global, and `hoodie.record.index.update.partition.path` defaults 
to false), `tagGlobalLocationBackToRecords` takes the branch at 
`HoodieIndexUtils:656-663` and re-keys the incoming record to the existing 
location's partition, so
   
   ```sql
   MERGE INTO t USING (select 1L as id, 15.0 as amount) s
   ON t.id = s.id
   WHEN MATCHED THEN UPDATE SET t.amount = s.amount
   ```
   
   on a partitioned table updates correctly today and would now be rejected. 
Same for `GLOBAL_SIMPLE`/`GLOBAL_BLOOM` with `update.partition.path=false`.
   
   Worth noting `useGlobalIndex` (`:1199`) returns the value of 
`update.partition.path`, so it's true precisely when the guard *isn't* needed — 
the check would need "is global AND !updatePartitionPath". The existing 
global-index merge test (`TestMergeIntoTable.scala:242`) projects the partition 
column, so CI wouldn't flag this. Same applies to the spark4-common copy.



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/others/TestMergeIntoPartitionFieldResolution.scala:
##########
@@ -0,0 +1,387 @@
+/*
+ * 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.dml.others
+
+import org.apache.hudi.common.table.timeline.TimelineUtils
+import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
+
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+
+/**
+ * Regression coverage for MERGE INTO partition-column resolution.
+ *
+ * For a target table bearing a record key, the incoming batch is written from 
the source alone
+ * and Hudi's index tagging identifies updates, keying off `(recordKey, 
partitionPath)`. Before the
+ * fix, a partition column the source did not carry was never back-filled into 
the source
+ * projection (only record-key and ordering columns were), so the key 
generator resolved it to the
+ * default partition and tagging looked in the wrong place. Every incoming 
record came back
+ * not-matched, which is silently destructive in two different ways depending 
on the clauses:
+ *
+ *  - `WHEN MATCHED` only: the record falls through to HoodieRecord.SENTINEL in
+ *    `ExpressionPayload#processNotMatchedRecord` and is dropped - the 
statement reports success and
+ *    writes an EMPTY COMMIT, leaving the target untouched.
+ *  - `WHEN NOT MATCHED ... INSERT` present: the record is inserted into the 
default partition
+ *    instead, DUPLICATING THE PRIMARY KEY across two partitions while the 
intended update is lost.
+ *
+ * The assertions below deliberately check the resulting rows AND their 
`_hoodie_partition_path`,
+ * not merely that the data is unchanged. "Unchanged" is exactly what the 
dropped-record bug
+ * produces, so a test asserting only that passes vacuously in the presence of 
this defect.
+ */
+class TestMergeIntoPartitionFieldResolution extends HoodieSparkSqlTestBase {
+
+  private val expectedError = "Failed to resolve partition fields"
+
+  private def createPartitionedTable(tableName: String, tableType: String, 
location: String): Unit =
+    spark.sql(
+      s"""
+         |create table $tableName (
+         |  id bigint,
+         |  name string,
+         |  amount double,
+         |  ts bigint,
+         |  dt string
+         |) using hudi
+         | partitioned by (dt)
+         | tblproperties (
+         |   type = '$tableType',
+         |   primaryKey = 'id',
+         |   preCombineField = 'ts'
+         | )
+         | location '$location'
+       """.stripMargin)
+
+  Seq("cow", "mor").foreach { tableType =>
+    test(s"Test MergeInto rejects an update whose source omits the partition 
column ($tableType)") {
+      withTempDir { tmp =>
+        val tableName = generateTableName
+        createPartitionedTable(tableName, tableType, 
s"${tmp.getCanonicalPath}/$tableName")
+        spark.sql(s"insert into $tableName values (1, 'a', 10.0, 1, 
'2026-08-11')")
+
+        // `dt` appears in neither the source output, the ON condition, nor 
the assignments.
+        // Before the fix this reported success and wrote an empty commit.
+        checkExceptionContain(
+          s"""
+             |merge into $tableName as t
+             |using (
+             |  select 1L as id, 15.0 as amount, 200L as ts
+             |) as s
+             |on t.id = s.id
+             |when matched then update set t.amount = s.amount, t.ts = s.ts
+       """.stripMargin)(expectedError)
+
+        // Target untouched, and still a single row in its original partition.
+        checkAnswer(s"select id, amount, ts, dt, _hoodie_partition_path from 
$tableName")(
+          Seq(1L, 10.0, 1L, "2026-08-11", "dt=2026-08-11")
+        )
+      }
+    }
+  }
+
+  Seq("cow", "mor").foreach { tableType =>
+    test(s"Test MergeInto with an INSERT clause cannot duplicate the key into 
the default partition ($tableType)") {
+      withTempDir { tmp =>
+        val tableName = generateTableName
+        createPartitionedTable(tableName, tableType, 
s"${tmp.getCanonicalPath}/$tableName")
+        spark.sql(s"insert into $tableName values (1, 'a', 10.0, 1, 
'2026-08-11')")
+
+        // The most damaging shape: with a NOT MATCHED clause the 
mis-partitioned record used to be
+        // INSERTED into the default partition, yielding two rows with id=1 in 
different partitions
+        // (name/dt null on the new one) while the intended update never 
landed.
+        checkExceptionContain(
+          s"""
+             |merge into $tableName as t
+             |using (
+             |  select 1L as id, 99.0 as amount, 9L as ts
+             |) as s
+             |on t.id = s.id
+             |when matched then update set t.amount = s.amount, t.ts = s.ts
+             |when not matched then insert (id, amount, ts) values (s.id, 
s.amount, s.ts)
+       """.stripMargin)(expectedError)
+
+        // Exactly one row, in the right partition - no duplicate key, no 
default-partition row.
+        checkAnswer(s"select count(*) from $tableName")(Seq(1L))
+        checkAnswer(s"select id, amount, dt, _hoodie_partition_path from 
$tableName")(
+          Seq(1L, 10.0, "2026-08-11", "dt=2026-08-11")
+        )
+      }
+    }
+  }
+
+  Seq("cow", "mor").foreach { tableType =>
+    test(s"Test MergeInto applies when the source projects the partition 
column ($tableType)") {

Review Comment:
   Could we add a `when matched then delete` case? `DeleteAction` carries no 
assignments, so a delete-only merge whose source omits the partition column now 
hits the new throw via a path none of the nine cases exercise. Previously it 
wrote an empty commit and deleted nothing, so rejecting looks right — just 
untested, and it's the common CDC shape.



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