voonhous commented on code in PR #19713:
URL: https://github.com/apache/hudi/pull/19713#discussion_r3852169992
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java:
##########
@@ -377,6 +378,9 @@ private static <R> Option<HoodieRecord<R>>
mergeIncomingWithExistingRecordWithEx
HoodieRecord<R> existing,
HoodieSchema writeSchema,
Review Comment:
**nit, feel free to ignore** -- `writeSchema` is now unused in this method:
both former uses (`prependMetaFields` and `Option.of(writeSchema)`) became
`mergedSchema`. Could we drop the parameter and the argument at line 482?
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java:
##########
@@ -398,27 +402,62 @@ private static <R> Option<HoodieRecord<R>>
mergeIncomingWithExistingRecordWithEx
}
//record is inserted or updated
- String partitionPath = inferPartitionPath(incoming, existing,
writeSchemaWithMetaFields, keyGenerator, existingRecordContext, mergeResult);
+ String partitionPath = inferPartitionPath(incoming, existing,
mergedSchemaWithMetaFields, keyGenerator,
+ existingRecordContext, mergeResult, partitionResolvableFromRecord);
HoodieRecord<R> result =
existingRecordContext.constructHoodieRecord(mergeResult, partitionPath);
- HoodieRecord<R> withMeta = result.prependMetaFields(writeSchema,
writeSchemaWithMetaFields,
+ HoodieRecord<R> withMeta = result.prependMetaFields(mergedSchema,
mergedSchemaWithMetaFields,
Review Comment:
**major** -- generic guard behind this fix.
`HoodieAvroIndexedRecord.prependMetaFields:206` infers `metaFieldSize =
target.size - record.size` (positional since #13860, in 1.1.0 through 1.2.0)
and nothing bounds it; #19712's shape gives 8 and `JoinedGenericRecord` writes
the shifted slots silently. Seven of the eight callers are pinned to 0/5/6;
`HoodieBootstrapRecordIterator.java:67` is the exception and is already
malformed when a bootstrap writer schema gained more than one column.
Could we add `checkArgument(0 <= metaFieldSize && metaFieldSize <=
HOODIE_META_COLUMNS.size() + 1)` there, so the next mismatched caller fails
loudly instead of writing shifted values?
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java:
##########
@@ -398,27 +402,62 @@ private static <R> Option<HoodieRecord<R>>
mergeIncomingWithExistingRecordWithEx
}
//record is inserted or updated
- String partitionPath = inferPartitionPath(incoming, existing,
writeSchemaWithMetaFields, keyGenerator, existingRecordContext, mergeResult);
+ String partitionPath = inferPartitionPath(incoming, existing,
mergedSchemaWithMetaFields, keyGenerator,
+ existingRecordContext, mergeResult, partitionResolvableFromRecord);
HoodieRecord<R> result =
existingRecordContext.constructHoodieRecord(mergeResult, partitionPath);
- HoodieRecord<R> withMeta = result.prependMetaFields(writeSchema,
writeSchemaWithMetaFields,
+ HoodieRecord<R> withMeta = result.prependMetaFields(mergedSchema,
mergedSchemaWithMetaFields,
new
MetadataValues().setRecordKey(incoming.getRecordKey()).setPartitionPath(partitionPath),
properties);
- return
Option.of(withMeta.wrapIntoHoodieRecordPayloadWithParams(writeSchemaWithMetaFields,
properties, Option.empty(),
- config.allowOperationMetadataField(), Option.empty(), false,
Option.of(writeSchema)));
+ return
Option.of(withMeta.wrapIntoHoodieRecordPayloadWithParams(mergedSchemaWithMetaFields,
properties, Option.empty(),
+ config.allowOperationMetadataField(), Option.empty(), false,
Option.of(mergedSchema)));
}
private static <R> String inferPartitionPath(HoodieRecord<R> incoming,
HoodieRecord<R> existing, HoodieSchema recordSchema, BaseKeyGenerator
keyGenerator,
- RecordContext<R> recordContext,
BufferedRecord<R> resultingBufferedRecord) {
+ RecordContext<R> recordContext,
BufferedRecord<R> resultingBufferedRecord,
+ boolean
partitionResolvableFromRecord) {
R record = resultingBufferedRecord.getRecord();
if (record == incoming.getData()) {
return incoming.getPartitionPath();
} else if (record == existing.getData()) {
return existing.getPartitionPath();
+ } else if (!partitionResolvableFromRecord) {
+ // A partial update carries only the fields named in the assignments, so
the partition fields are
+ // absent. The merge cannot have changed a field the record does not
carry, so the partition is the
+ // existing record's. Deriving it from the record instead resolves to
the default partition,
+ // because KeyGenUtils#getPartitionPath substitutes it for a value it
cannot find.
+ return existing.getPartitionPath();
} else {
// the merged record is not the same as either incoming or existing, so
we need to compute the partition path
return
keyGenerator.getPartitionPath(recordContext.convertToAvroRecord(record,
recordSchema));
}
}
+ /**
+ * Whether the merged record can yield a partition path on its own. False
only for a partial update whose
+ * schema omits a partition field; a full merge result always can, so those
paths keep resolving through
+ * the key generator. Evaluated once per stage, not per record.
+ */
+ private static boolean isPartitionResolvableFromRecord(HoodieSchema
recordSchema, BaseKeyGenerator keyGenerator) {
+ List<String> partitionPathFields = keyGenerator.getPartitionPathFields();
+ if (partitionPathFields == null || partitionPathFields.isEmpty()) {
+ // A non-partitioned table has nothing to resolve, so let the key
generator answer as before.
+ return true;
+ }
+ return partitionPathFields.stream()
+ .map(HoodieIndexUtils::partitionFieldRootName)
+ .allMatch(rootField -> rootField.isEmpty() ||
recordSchema.getField(rootField).isPresent());
+ }
+
+ /**
+ * The schema field name a partition-path config entry refers to. The custom
key generators keep the
+ * mandatory "field:TYPE" spec verbatim in getPartitionPathFields, so the
type suffix is stripped before
+ * the nested path is reduced to its root.
+ */
+ private static String partitionFieldRootName(String partitionPathField) {
Review Comment:
**nit, feel free to ignore** --
`CustomAvroKeyGenerator.getPartitionFieldAndKeyType(field).getLeft()` already
strips the `:TYPE` suffix, and the root split is `split("\\.")[0]`, which would
avoid re-adding the `HoodieAvroUtils` import that #17599 removed from this
file. Separately, `inferPartitionPath` (405) and this guard (565) are handed
`mergedSchemaWithMetaFields` while the record carries `mergedSchema`; inert on
the Avro path, but could we pass `mergedSchema` so the argument matches the
record?
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/others/TestPartialUpdateForMergeInto.scala:
##########
@@ -732,12 +732,144 @@ class TestPartialUpdateForMergeInto extends
HoodieSparkSqlTestBase {
}
}
+ // A partial update names only the columns being changed, so the record key
is normally absent
+ // from the assignments. On MOR the global-index tagging stage merges the
incoming record with its
+ // existing version and then asks the key generator for the merged record's
partition path - and
+ // that merged record is materialised against WRITE_PARTIAL_UPDATE_SCHEMA,
which carries only the
+ // assigned columns. Resolving the partition path must therefore not also
require the record key.
+ //
+ // The index types split on whether that tagging stage runs at all:
GLOBAL_BLOOM and GLOBAL_SIMPLE
+ // set mayContainDuplicateLookup on MOR and so reach it, while the
record-index spellings pass
+ // false and short-circuit. Both cells are covered, so the fix is pinned
where it applies and the
+ // already-working path is guarded against regression. The source projects
the partition column in
+ // every case, to keep this independent of partition-column resolution
(ENG-46864).
+ Seq(
+ ("GLOBAL_BLOOM re-keying", Map(
+ "hoodie.index.type" -> "GLOBAL_BLOOM",
+ "hoodie.bloom.index.update.partition.path" -> "false")),
+ ("GLOBAL_SIMPLE re-keying", Map(
+ "hoodie.index.type" -> "GLOBAL_SIMPLE",
+ "hoodie.simple.index.update.partition.path" -> "false")),
+ ("RECORD_INDEX", Map(
+ "hoodie.index.type" -> "RECORD_INDEX",
+ "hoodie.record.index.update.partition.path" -> "false",
+ "hoodie.metadata.enable" -> "true",
+ "hoodie.metadata.record.index.enable" -> "true")),
+ ("GLOBAL_RECORD_LEVEL_INDEX", Map(
+ "hoodie.index.type" -> "GLOBAL_RECORD_LEVEL_INDEX",
+ "hoodie.record.index.update.partition.path" -> "false",
+ "hoodie.metadata.enable" -> "true",
+ "hoodie.metadata.record.index.enable" -> "true"))
+ ).foreach { case (label, indexConfs) =>
+ test(s"Test MOR partial update on a global index without assigning the
record key ($label)") {
+ withTempDir { tmp =>
+ withSQLConf(indexConfs.toSeq: _*) {
+ val tableName = generateTableName
+ val basePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ | create table $tableName (
+ | id bigint,
+ | name string,
+ | amount double,
+ | ts bigint,
+ | dt string
+ | ) using hudi
+ | partitioned by (dt)
+ | location '$basePath'
+ | tblproperties (
+ | type = 'mor',
+ | primaryKey = 'id',
+ | preCombineField = 'ts')
+ """.stripMargin)
+ spark.sql(s"insert into $tableName values (1, 'a', 10.0, 1,
'2026-08-11')")
+
+ // `id` is deliberately absent from the assignments - that is what
makes it partial.
+ spark.sql(
+ s"""
+ | merge into $tableName as t
+ | using (
+ | select 1L as id, 15.0 as amount, 200L as ts, '2026-08-11'
as dt
+ | ) as s
+ | on t.id = s.id
+ | when matched then update set t.amount = s.amount, t.ts = s.ts
+ """.stripMargin)
+
+ // The assigned columns change, the unassigned ones keep their
existing values, and the
+ // record stays in its own partition - asserted rather than merely
checking row count,
+ // since a dropped record leaves a table that also "looks unchanged".
+ checkAnswer(s"select id, name, amount, ts, dt,
_hoodie_partition_path from $tableName")(
+ Seq(1L, "a", 15.0, 200L, "2026-08-11", "dt=2026-08-11")
+ )
+
+ // The rows alone cannot distinguish a correct partial log block
from a full-schema one, and
+ // this fix is precisely about which schema the block's meta fields
are laid out against. So
+ // assert the block: IS_PARTIAL set, and the schema equal to the
meta fields prepended onto
+ // the assigned columns only. Without the fix the meta count is
inferred as
+ // targetSchema.size - record.size and the values land in the wrong
slots.
+ validateLogBlock(basePath, 1, Seq(Seq("amount", "ts")), isPartial =
true, "dt=2026-08-11")
+ }
+ }
+ }
+ }
+
+
+ // update.partition.path enabled is deliberately NOT in the matrix above:
for GLOBAL_BLOOM and
+ // GLOBAL_SIMPLE it makes useGlobalIndex true, which disables MOR partial
updates altogether
+ // (isPartialUpdateActionForMOR requires !useGlobalIndex), and MOR then
requires the record key to be
+ // assigned. So the statement is rejected at analysis time rather than
reaching the writer. Pinned
+ // here so the boundary is explicit and nobody mistakes this rejection for
the partial-update defect.
+ //
+ // NOTE the exemption is not universal: isGlobalIndexEnabled maps only
GLOBAL_SIMPLE, GLOBAL_BLOOM
+ // and RECORD_INDEX, so GLOBAL_RECORD_LEVEL_INDEX returns false there
regardless of its
+ // update.partition.path value, while
SparkMetadataTableGlobalRecordLevelIndex still honours that
+ // config. That combination therefore reaches the writer WITH partial
updates enabled. The
+ // partition-resolution guard in HoodieIndexUtils covers it, and the missing
enum mapping is being
+ // added separately, so this test asserts the rejection only for the
spellings that actually reject.
+ test("Test MOR merge without assigning the record key is rejected when the
global index updates the partition path") {
Review Comment:
**minor** -- this is a near-clone of the test at line 686: same table shape,
same statement, same `checkExceptionContain` on the message from
`MergeIntoHoodieTableCommand.scala:1051`, differing only in which
`isPartialUpdateActionForMOR` conjunct fails (custom merge mode there,
`useGlobalIndex` here). The `insert into` is dead, since `validate` runs at the
top of `run()` before any read, and the rejection is in `run()`, not analysis.
Could we fold this into 686 as a two-element `Seq(...).foreach`, or drop it?
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/others/TestPartialUpdateForMergeInto.scala:
##########
@@ -732,12 +732,144 @@ class TestPartialUpdateForMergeInto extends
HoodieSparkSqlTestBase {
}
}
+ // A partial update names only the columns being changed, so the record key
is normally absent
+ // from the assignments. On MOR the global-index tagging stage merges the
incoming record with its
+ // existing version and then asks the key generator for the merged record's
partition path - and
+ // that merged record is materialised against WRITE_PARTIAL_UPDATE_SCHEMA,
which carries only the
+ // assigned columns. Resolving the partition path must therefore not also
require the record key.
+ //
+ // The index types split on whether that tagging stage runs at all:
GLOBAL_BLOOM and GLOBAL_SIMPLE
+ // set mayContainDuplicateLookup on MOR and so reach it, while the
record-index spellings pass
+ // false and short-circuit. Both cells are covered, so the fix is pinned
where it applies and the
+ // already-working path is guarded against regression. The source projects
the partition column in
+ // every case, to keep this independent of partition-column resolution
(ENG-46864).
+ Seq(
+ ("GLOBAL_BLOOM re-keying", Map(
Review Comment:
**minor** -- the four cells are two behaviours. `GLOBAL_BLOOM` and
`GLOBAL_SIMPLE` call `tagGlobalLocationBackToRecords` with identical flags
(`HoodieGlobalBloomIndex.java:106-109`, `HoodieGlobalSimpleIndex.java:77-80`),
and both record-index spellings map to
`SparkMetadataTableGlobalRecordLevelIndex`
(`SparkHoodieIndexFactory.java:65-67`). Also
`hoodie.metadata.record.index.enable` is a deprecated alias of
`hoodie.metadata.global.record.level.index.enable`; if it stops resolving, the
index falls back to `GLOBAL_SIMPLE` on a warning and the cell stays green.
Could we collapse to two cells and use the current key (or assert the
`record_index` partition exists)?
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/others/TestPartialUpdateForMergeInto.scala:
##########
@@ -732,12 +732,144 @@ class TestPartialUpdateForMergeInto extends
HoodieSparkSqlTestBase {
}
}
+ // A partial update names only the columns being changed, so the record key
is normally absent
+ // from the assignments. On MOR the global-index tagging stage merges the
incoming record with its
+ // existing version and then asks the key generator for the merged record's
partition path - and
+ // that merged record is materialised against WRITE_PARTIAL_UPDATE_SCHEMA,
which carries only the
+ // assigned columns. Resolving the partition path must therefore not also
require the record key.
+ //
+ // The index types split on whether that tagging stage runs at all:
GLOBAL_BLOOM and GLOBAL_SIMPLE
+ // set mayContainDuplicateLookup on MOR and so reach it, while the
record-index spellings pass
+ // false and short-circuit. Both cells are covered, so the fix is pinned
where it applies and the
+ // already-working path is guarded against regression. The source projects
the partition column in
+ // every case, to keep this independent of partition-column resolution
(ENG-46864).
Review Comment:
**nit, feel free to ignore** -- comment accuracy: this paragraph describes
the #19708 key-generator problem rather than the meta-field layout this PR
fixes; `ENG-46864` is an internal tracker id; the `validateLogBlock` note at
805-809 overclaims, since it reads only the block header whose schema comes
from config on both sides of the fix (the discriminator is the merge no longer
throwing); the "re-keying" labels say the opposite of
`update.partition.path=false`; "short-circuit" should read "skip the merged
lookup" (those cells do enter the stage); double blank line at 815; commit
message and body say five cases for four.
Could we tidy these in one pass?
--
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]