-
Notifications
You must be signed in to change notification settings - Fork 2.5k
fix: support reading tables partitioned on a nested column #19123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
49ab365
30b1874
8e064fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -210,7 +210,12 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String, | |
| } | ||
| originalVectorTypes.map { | ||
| o: Seq[String] => o.zipWithIndex.map(a => { | ||
| if (a._2 >= requiredSchema.length && mandatoryFields.contains(partitionSchema.fields(a._2 - requiredSchema.length).name)) { | ||
| val isPartitionField = a._2 >= requiredSchema.length | ||
| if (isPartitionField | ||
| && { | ||
| val fieldName = partitionSchema.fields(a._2 - requiredSchema.length).name | ||
| mandatoryFields.contains(fieldName) && !isNestedPartitionField(fieldName) | ||
| }) { | ||
| regularVectorType | ||
| } else { | ||
| a._1 | ||
|
|
@@ -253,7 +258,11 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String, | |
| val augmentedStorageConf = new HadoopStorageConfiguration(hadoopConf).getInline | ||
| setSchemaEvolutionConfigs(augmentedStorageConf) | ||
| augmentedStorageConf.set(ENABLE_LOGICAL_TIMESTAMP_REPAIR, hasTimestampMillisFieldInTableSchema.toString) | ||
| val (remainingPartitionSchemaArr, fixedPartitionIndexesArr) = partitionSchema.fields.toSeq.zipWithIndex.filter(p => !mandatoryFields.contains(p._1.name)).unzip | ||
| // Nested partition columns (e.g. "nested_record.level") are never read from the data file: the | ||
| // flattened dotted name is not a valid top-level field and the value is materialized from the | ||
| // partition path. Always keep them in the appended ("remaining") partition fields so they are | ||
| // not converted into a top-level Avro field below, which would fail Avro name validation. | ||
| val (remainingPartitionSchemaArr, fixedPartitionIndexesArr) = partitionSchema.fields.toSeq.zipWithIndex.filter(p => !mandatoryFields.contains(p._1.name) || isNestedPartitionField(p._1.name)).unzip | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Now that a nested partition field can be appended (remaining) while a top-level partition field is still read from the file, the snapshot/MOR path hits
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both To cover exactly the mixed scenario you describe (a top-level partition column read from the file + a nested partition column appended from the path), I added |
||
|
|
||
| // The schema of the partition cols we want to append the value instead of reading from the file | ||
| val remainingPartitionSchema = StructType(remainingPartitionSchemaArr) | ||
|
|
@@ -265,9 +274,9 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String, | |
| val exclusionFields = new java.util.HashSet[String]() | ||
| exclusionFields.add("op") | ||
| partitionSchema.fields.foreach(f => exclusionFields.add(f.name)) | ||
| val requestedStructType = StructType(requiredSchema.fields ++ partitionSchema.fields.filter(f => mandatoryFields.contains(f.name))) | ||
| val requestedStructType = StructType(requiredSchema.fields ++ partitionSchema.fields.filter(f => mandatoryFields.contains(f.name) && !isNestedPartitionField(f.name))) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 A partition field lands in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question, but there is no regression here for a nested partition field specifically. A nested partition column ( The precombine/merge value is unaffected: it is read from the Note the guard is scoped to nested (dotted) names — top-level mandatory partition columns (incl. variable timestamp/custom-keygen columns) are still read from the file exactly as before. Covered by the two regression tests added in 30b1874.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @yihua Have a question to clarify here - required nested partitioning fields will be read via the data file or will be parsed from the partition path? That's the bug this PR is trying to fix.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The "mandatory AND not-nested" (i.e. read-from-file) predicate is now duplicated here, at L279, and in private def partitionFieldReadFromFile(field: StructField): Boolean =
mandatoryFields.contains(field.name) && !isNestedPartitionField(field.name)and reusing it in all four spots. That keeps the negated form at L265 from silently drifting out of sync with the positive forms if the condition ever changes. Non-blocking. |
||
| val requestedSchema = HoodieSchemaUtils.pruneDataSchema(schema, HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(requestedStructType, sanitizedTableName), exclusionFields) | ||
| val dataStructTypeWithMandatoryPartitionFields = StructType(dataStructType.fields ++ partitionSchema.fields.filter(f => mandatoryFields.contains(f.name))) | ||
| val dataStructTypeWithMandatoryPartitionFields = StructType(dataStructType.fields ++ partitionSchema.fields.filter(f => mandatoryFields.contains(f.name) && !isNestedPartitionField(f.name))) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the nested partition column is also the precombine/ordering field, it's now excluded from the |
||
| val dataSchema = HoodieSchemaUtils.pruneDataSchema(schema, HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(dataStructTypeWithMandatoryPartitionFields, sanitizedTableName), exclusionFields) | ||
|
|
||
| spark.sessionState.conf.setConfString("spark.sql.parquet.enableVectorizedReader", supportVectorizedRead.toString) | ||
|
|
@@ -548,6 +557,14 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath: String, | |
| }.asInstanceOf[Iterator[InternalRow]] | ||
| } | ||
|
|
||
| /** | ||
| * A partition column whose name is a nested field path (e.g. "nested_record.level") cannot be | ||
| * read from the data file as a flat top-level column, nor converted into a top-level Avro field | ||
| * (Avro rejects '.' in names). Its value is always materialized from the partition path, so such | ||
| * fields are treated as appended partition fields rather than read from the file. | ||
| */ | ||
| private def isNestedPartitionField(name: String): Boolean = name.contains(".") | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
|
|
||
| private def getFixedPartitionValues(allPartitionValues: InternalRow, partitionSchema: StructType, fixedPartitionIndexes: Set[Int]): InternalRow = { | ||
| InternalRow.fromSeq(allPartitionValues.toSeq(partitionSchema).zipWithIndex.filter(p => fixedPartitionIndexes.contains(p._2)).map(p => p._1)) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ package org.apache.hudi.functional | |
| import org.apache.hudi.testutils.SparkClientFunctionalTestHarness | ||
|
|
||
| import org.apache.spark.sql.{Row, SaveMode} | ||
| import org.apache.spark.sql.types.{DoubleType, LongType, StringType, StructField, StructType} | ||
| import org.apache.spark.sql.types.{DoubleType, IntegerType, LongType, StringType, StructField, StructType} | ||
| import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertNotNull} | ||
| import org.junit.jupiter.api.Test | ||
|
|
||
|
|
@@ -155,4 +155,132 @@ class TestFileGroupReaderPartitionColumn extends SparkClientFunctionalTestHarnes | |
| assertEquals("IN", rows(12L), "id=12 partition column must be IN") | ||
| assertEquals("IN", rows(14L), "id=14 partition column must be IN") | ||
| } | ||
|
|
||
| /** | ||
| * Regression test for reading a table partitioned on a nested column when that nested column is | ||
| * also a mandatory field (here, the precombine/ordering field). Being mandatory, the file group | ||
| * reader requests the partition column as a top-level field; for a nested path | ||
| * ("nested_record.level") this previously failed in `buildReaderWithPartitionValues` with | ||
| * `HoodieSchemaException: Illegal character in: nested_record.level` when converting the | ||
| * StructType into an Avro-backed HoodieSchema. | ||
| * | ||
| * The nested partition value is never a flat top-level column in the data file — it must be | ||
| * materialized from the partition path — so the fix keeps it out of the file-read schema and | ||
| * appends it from the path. The existing `TestCOWDataSource#testNestedFieldPartition` covers the | ||
| * common (non-mandatory) path where the field is already appended from the path and does not hit | ||
| * this case. | ||
| */ | ||
| @Test | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both new tests are COPY_ON_WRITE and only exercise the |
||
| def testReadTablePartitionedOnNestedColumnThatIsAlsoPrecombine(): Unit = { | ||
| val nestedSchema = StructType(Array( | ||
| StructField("nested_int", IntegerType, nullable = false), | ||
| StructField("level", StringType, nullable = false) | ||
| )) | ||
| val schema = StructType(Array( | ||
| StructField("id", LongType, nullable = false), | ||
| StructField("name", StringType, nullable = true), | ||
| StructField("nested_record", nestedSchema, nullable = true) | ||
| )) | ||
|
|
||
| val opts = Map( | ||
| "hoodie.table.name" -> "test_nested_partition_precombine", | ||
| "hoodie.datasource.write.table.type" -> "COPY_ON_WRITE", | ||
| "hoodie.datasource.write.recordkey.field" -> "id", | ||
| // partition column is ALSO the precombine field -> it becomes mandatory (read from file), | ||
| // which is what drives the nested-name conversion the fix guards against. | ||
| "hoodie.datasource.write.partitionpath.field" -> "nested_record.level", | ||
| "hoodie.datasource.write.precombine.field" -> "nested_record.level", | ||
| "hoodie.insert.shuffle.parallelism" -> "2", | ||
| "hoodie.upsert.shuffle.parallelism" -> "2" | ||
| ) | ||
|
|
||
| val rows = Seq( | ||
| Row(1L, "a", Row(10, "INFO")), | ||
| Row(2L, "b", Row(20, "ERROR")), | ||
| Row(3L, "c", Row(30, "INFO")), | ||
| Row(4L, "d", Row(40, "DEBUG")) | ||
| ) | ||
| spark.createDataFrame(spark.sparkContext.parallelize(rows, 1), schema) | ||
| .write.format("hudi") | ||
| .options(opts) | ||
| .mode(SaveMode.Overwrite) | ||
| .save(basePath) | ||
|
|
||
| // Full read: the nested partition column must materialize (from the partition path) for every | ||
| // row rather than crashing the scan. | ||
| val all = spark.read.format("hudi").load(basePath) | ||
| .select("id", "nested_record") | ||
| .collect() | ||
| .map(r => r.getLong(0) -> r.getStruct(1).getString(1)) | ||
| .toMap | ||
| assertEquals(4, all.size, "all rows must read back") | ||
| assertEquals("INFO", all(1L)) | ||
| assertEquals("ERROR", all(2L)) | ||
| assertEquals("INFO", all(3L)) | ||
| assertEquals("DEBUG", all(4L)) | ||
|
|
||
| // Filtered read on the nested partition column (partition pruning path). | ||
| val infoIds = spark.read.format("hudi").load(basePath) | ||
| .filter("nested_record.level = 'INFO'") | ||
| .select("id") | ||
| .collect() | ||
| .map(_.getLong(0)) | ||
| .toSet | ||
| assertEquals(Set(1L, 3L), infoIds, "filter on nested partition column must return INFO rows") | ||
| } | ||
|
|
||
| /** | ||
| * Mixed case: one top-level partition column read from the file and one nested partition column | ||
| * appended from the path, both mandatory (here via multiple ordering fields). Exercises the | ||
| * `readBaseFile` branch where `remainingPartitionSchema` (the appended nested field) differs from | ||
| * the full `partitionSchema`, confirming the nested value is materialized from the path while the | ||
| * top-level value is still read from the file. | ||
| */ | ||
| @Test | ||
| def testReadTablePartitionedOnNestedAndTopLevelColumns(): Unit = { | ||
| val nestedSchema = StructType(Array( | ||
| StructField("nested_int", IntegerType, nullable = false), | ||
| StructField("level", StringType, nullable = false) | ||
| )) | ||
| val schema = StructType(Array( | ||
| StructField("id", LongType, nullable = false), | ||
| StructField("country", StringType, nullable = false), | ||
| StructField("nested_record", nestedSchema, nullable = true) | ||
| )) | ||
|
|
||
| val opts = Map( | ||
| "hoodie.table.name" -> "test_nested_and_toplevel_partition", | ||
| "hoodie.datasource.write.table.type" -> "COPY_ON_WRITE", | ||
| "hoodie.datasource.write.recordkey.field" -> "id", | ||
| "hoodie.datasource.write.partitionpath.field" -> "country,nested_record.level", | ||
| "hoodie.datasource.write.keygenerator.class" -> "org.apache.hudi.keygen.ComplexKeyGenerator", | ||
| // both partition columns are ordering fields -> both mandatory; country is read from the file, | ||
| // the nested column is appended from the path. | ||
| "hoodie.table.ordering.fields" -> "country,nested_record.level", | ||
| "hoodie.datasource.write.hive_style_partitioning" -> "true", | ||
| "hoodie.insert.shuffle.parallelism" -> "2", | ||
| "hoodie.upsert.shuffle.parallelism" -> "2" | ||
| ) | ||
|
|
||
| val rows = Seq( | ||
| Row(1L, "US", Row(10, "INFO")), | ||
| Row(2L, "IN", Row(20, "ERROR")), | ||
| Row(3L, "US", Row(30, "DEBUG")) | ||
| ) | ||
| spark.createDataFrame(spark.sparkContext.parallelize(rows, 1), schema) | ||
| .write.format("hudi") | ||
| .options(opts) | ||
| .mode(SaveMode.Overwrite) | ||
| .save(basePath) | ||
|
|
||
| val all = spark.read.format("hudi").load(basePath) | ||
| .select("id", "country", "nested_record") | ||
| .collect() | ||
| .map(r => r.getLong(0) -> (r.getString(1), r.getStruct(2).getString(1))) | ||
| .toMap | ||
| assertEquals(3, all.size, "all rows must read back") | ||
| assertEquals(("US", "INFO"), all(1L)) | ||
| assertEquals(("IN", "ERROR"), all(2L)) | ||
| assertEquals(("US", "DEBUG"), all(3L)) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Line 429: Following up on the earlier thread — I don't think this call site passes the full
partitionSchema. At line 341appendPartitionAndProjectis invoked withremainingPartitionSchemaas itspartitionSchemaarg, so herepartitionSchemais the remaining schema, whereasreadBaseFile(line 532) passes the full one. With a mix of one top-level mandatory field (read from file) + one nested field (appended), e.g. partitioncountry,nested_record.levelboth ordering fields: remaining=[nested_record.level] (len 1), indices={1}, partitionValues.numFields=2 → line 424 is false →getFixedPartitionValues(partitionValues, remaining, {1})doestoSeq(remaining)which materializes only ordinal 0 (country's value, as StringType) then filters to {1} → empty, so the nested value is dropped. The new tests are COW-only so they hitreadBaseFile(correct) and never this MOR branch. Could you confirm whether this should pass the fullpartitionSchematogetFixedPartitionValueshere (while keepingremainingPartitionSchemafor the join projection)?