feat: add support for _partition metadata column - #2668
Conversation
|
@advancedxy fyi |
074f903 to
caa7fb0
Compare
| /// # Arguments | ||
| /// * `partition_specs` - Iterator over all partition specs in the table | ||
| /// * `schema` - The current table schema (needed to determine result types of transforms) | ||
| pub fn compute_unified_partition_type<'a>( |
There was a problem hiding this comment.
nit: it's a bit of odd that this function is added in metadata_column.rs. Do you think it's a good idea to create partitioning.rs and put this function into partitioning.rs?
There was a problem hiding this comment.
Created crates/iceberg/src/partitioning.rs, moved the function there, registered as pub mod partitioning in lib.rs
| let mut seen_field_ids = std::collections::HashSet::new(); | ||
| let mut struct_fields: Vec<NestedFieldRef> = Vec::new(); | ||
|
|
||
| for spec in partition_specs { |
There was a problem hiding this comment.
I notice some inconsistent with java's impl:
- unknown specs are rejected in java.
- specs are sorted by spec id first(in reverse order), which means newer partition spec's field name will be picked fist.
- V1 table's void transform field is also handled in java: the partition field that was dropped later.
There was a problem hiding this comment.
Good point!
Updated the implementation (partitioning.rs) with -
- Sort specs by spec_id descending (newer field names take precedence)
- Skips Transform::Void fields (dropped partition columns)
- Deduplicates by field_id
| let partition_column_constant = | ||
| if let Some(ref unified_partition_type) = self.unified_partition_type { | ||
| let partition_spec = self | ||
| .table_metadata | ||
| .partition_spec_by_id(self.partition_spec_id); | ||
| if let Some(spec) = partition_spec { | ||
| let constant = build_partition_column_constant( | ||
| unified_partition_type, | ||
| spec, | ||
| &self.manifest_entry.data_file.partition, | ||
| )?; | ||
| Some(Arc::new(constant)) | ||
| } else { | ||
| None | ||
| } | ||
| } else { | ||
| None | ||
| }; |
There was a problem hiding this comment.
I'm not sure this is a good idea to calculate the partition column in the plan/scan phase and it adds build_partition_column_constant dep from record_batch_transformer' mod into the scan side.
There was a problem hiding this comment.
Fair point. Removed build_partition_column_constant from scan/context.rs. The scan phase only passes unified_partition_type through to the task. The actual struct constant is computed lazily at read time in pipeline.rs.
| #[serde(serialize_with = "serialize_not_implemented")] | ||
| #[serde(deserialize_with = "deserialize_not_implemented")] | ||
| #[builder(default)] | ||
| pub partition_column_constant: Option<Arc<PartitionColumnConstant>>, |
There was a problem hiding this comment.
Like the comment in https://github.com/apache/iceberg-rust/pull/2668/changes#r3448373215, I think it's better to pass the unified partition type here rather than passing the actual unified partition value. It would be easier for comet to pooling the type rather than the actual value.
BTW, this would be unnecessary if we can rebuild/access the table/metadata when reading on the executor side. Java archives this by SerializableTable and with Spark's broadcast. We don't have similar thing on the rust yet.
There was a problem hiding this comment.
FileScanTask now carries unified_partition_type: Option<Arc<StructType>> instead of partition_column_constant: Option<Arc<PartitionColumnConstant>>. The reader computes the value from type + spec + partition data.
Also removed the table_metadata field from ManifestEntryContext and ManifestFileContext (it was only used for the old precomputation path)
| let mut ids: Vec<i32> = value.identifier_field_ids.into_iter().collect(); | ||
| ids.sort_unstable(); | ||
| Some(ids) | ||
| }, |
There was a problem hiding this comment.
the changes in this file seem unrelated? And I don't think the spec requires sorting identifier field ids.
There was a problem hiding this comment.
You're right, this got left over by accident. Removed.
|
@parthchandra thanks for pinging me and working on this. I think I'm concerned that the unified partition value is carried in the file scan task, which seems a bit of odd. |
caa7fb0 to
33b690a
Compare
|
@parthchandra I did another round and left some follow-up comments. I think it's in good shape overall. |
|
Comet generates the FileScanTasks from Iceberg Java. Does Iceberg Java provide |
Good point! Iceberg Java doesn't provide unified_partition_type on individual FileScanTasks — Java computes it at a higher level via Partitioning.partitionType(table) We can do the equivalent in Comet - https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/native/core/src/execution/planner.rs#L3409-L3440 |
| { | ||
| let struct_type = DataType::Struct(pc.fields.clone()); | ||
| let nullable = pc.fields.is_empty(); | ||
| let arrow_field = Field::new("_partition", struct_type, nullable) |
There was a problem hiding this comment.
nit: should use RESERVED_COL_NAME_PARTITION in metadata_columns directly.
| } | ||
|
|
||
| // Skip void transforms (dropped partition columns) and unknown transforms | ||
| if matches!(field.transform, Transform::Void | Transform::Unknown) { |
There was a problem hiding this comment.
hmm. I don't think this is consistent with iceberg-java.
Unknown transform should be checked rather than ignored.
Considering a new transform introduced in iceberg-spec but not adapted by iceberg-rust yet should be considered as unknown transform.
Are you able to demonstrate this feature working in Comet? Usually when I bring an enhancement to iceberg-rust motivated by Comet usage, I create a draft PR on the Comet repo with tests and the feature built out (using the PR branch here) to demonstrate it works with the Iceberg Java suites. |
|
Thanks @advancedxy @mbutrovich I'm on the road atm, will address these in a few days. |
4df3d67 to
8da37c9
Compare
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks for this @parthchandra! Computing the unified type once per scan and threading it through the context is a nice shape, and matching Java's "empty partition struct becomes null" choice is a good call for engine compatibility. A few things I'd like to talk through before it lands, mostly around matching Partitioning.partitionType exactly and trimming some surface area:
- There looks to be an ordering dependency on #2695 (the
partition_specwiring). Could you confirm? See thecontext.rsnote. - Two spots where I think we may diverge from
Partitioning.partitionType(dropped source column; result field ordering), flagged as questions inline. - Some of the array construction could lean on arrow-rs helpers, plus a couple of API-surface trims.
- Coverage is identity-transform-only right now; I listed the cases I'd find most reassuring.
Re: tests:
The two new unit tests are identity-only. Cases that would most increase my confidence:
- non-identity transform (
bucket/truncate), confirming the transformed partition value lands in_partition, consistent withPartitionUtil.constantsMapreadingpartitionData.get(pos); - unpartitioned table end-to-end (exercises the empty-struct-to-null branch);
- explicit null partition value (distinct from evolution-missing);
_partitionselected alongside_file/_pos/_spec_idin one scan, since they share the projection/transform path.
|
|
||
| seen_field_ids.insert(field.field_id); | ||
|
|
||
| let source_field = schema.field_by_id(field.source_id).ok_or_else(|| { |
There was a problem hiding this comment.
Java's Partitioning.partitionType only considers allActiveFieldIds, which filters out fields whose source column is gone (schema.findField(field.sourceId()) != null in Partitioning.java). Here we return ErrorKind::Unexpected instead. If I'm reading both right, a table that dropped a column it used to partition on (allowed under https://iceberg.apache.org/spec/#partition-evolution) would error the whole scan when _partition is projected. Would continue-ing on a missing source column match Java's intent better? A test that drops a partition source column would settle it either way.
There was a problem hiding this comment.
Changed to skip fields where the source column gone so this should match Java's behavior. However, proving it with a test turned out to be tricky. The test failed; turns out in Spark/Iceberg DDL PartitionSpec.bind() rejects specs referencing dropped columns during writes so the condition cannot arise.
| } | ||
| } | ||
|
|
||
| fn create_struct_column( |
There was a problem hiding this comment.
create_struct_column is hand-rolled struct assembly that arrow-rs already provides: the empty/all-null path is StructArray::new_null(fields, len) / new_null_array (arrow-array/src/array/struct_array.rs:192), and the populated path is StructArray::try_new(fields, arrays, nulls), which validates fields.len() == arrays.len() and child-length agreement (struct_array.rs:106). I'd reuse those rather than maintain our own constructor: try_new also enforces the fields/child_values length invariant that the current zip silently truncates. Same principle as the value.rs note: prefer the library's validated constructors over duplicating them here.
There was a problem hiding this comment.
Done. Thanks for pointing this one out!
| partition_specs: impl Iterator<Item = &'a PartitionSpec>, | ||
| schema: &Schema, | ||
| ) -> Result<StructType> { | ||
| let mut seen_field_ids = std::collections::HashSet::new(); |
There was a problem hiding this comment.
Small consistency thing: use std::collections::HashSet; / use std::cmp::Reverse; up top matches the surrounding style.
| let struct_type = DataType::Struct(pc.fields.clone()); | ||
| let nullable = pc.fields.is_empty(); | ||
| let arrow_field = | ||
| Field::new(RESERVED_COL_NAME_PARTITION, struct_type, nullable) |
There was a problem hiding this comment.
This exact block recurs several times across these PRs. A small fn field_with_id(name, dt, nullable, field_id) -> Arc<Field> helper would remove the repetition and keep the field-id metadata format in one place.
There was a problem hiding this comment.
Done. Created a new field_with_id helper function
| @@ -138,6 +142,7 @@ impl ManifestEntryContext { | |||
| // TODO: Pass actual PartitionSpec through context chain for native flow | |||
| .with_partition_spec(None) | |||
There was a problem hiding this comment.
build_partition_column_constant needs task.partition_spec to be Some for partitioned tables, but this PR keeps partition_spec(None); #2695 is the one that wires the real spec through context.rs. Is the plan to rebase on #2695? If so, an end-to-end test asserting non-null partition values would confirm the wiring once rebased.
There was a problem hiding this comment.
The plan is for this PR to go in first and have #2695 rebase. I'll add end to end tests for all metadata fields in the Comet PR
I've opened a draft PR in Comet: apache/datafusion-comet#4752 based on the same branch I referenced above. All existing tests pass. Working on addressing your newer comments (thanks for the detailed review!). |
|
@mbutrovich @advancedxy addressed all your review comments. |
|
Comet (draft) PR with @hsiang-c 's PRs included. CI is green. apache/datafusion-comet#4752 |
|
@mbutrovich could you re-review once @hsiang-c 's PRs are merged and I rebase on top of them? |
|
@parthchandra since other prs are merged, would you like to rebase the PR on the master and let's continue. |
b91fbd2 to
ec53b41
Compare
89d5001 to
f70931a
Compare
|
@advancedxy, @mbutrovich, @CTTY rebased this PR after #2695 and The corresponding PR in Comet: apache/datafusion-comet#4752 adds tests as well. (Note the CI failure is an issue in Comet main and not related to this) PTAL |
advancedxy
left a comment
There was a problem hiding this comment.
Left some style comments, the code should be correct as far as I can see.
|
|
||
| // Compute unified partition type if _partition is projected | ||
| let unified_partition_type = if field_ids.contains(&RESERVED_FIELD_ID_PARTITION) { | ||
| let upt = compute_unified_partition_type( |
There was a problem hiding this comment.
nit: upt seems a bit of unusual in the iceberg-rust repo. how about simply call it partition_type or unified_type?
| snapshot_schema: Arc<IcebergSchema>, | ||
| projected_iceberg_field_ids: Vec<i32>, | ||
| constant_fields: HashMap<i32, Datum>, | ||
| metadata_columns: HashMap<i32, MetadataColumnSource>, |
There was a problem hiding this comment.
hmm, I'd prefer to use constant fields here, which aligns with java's concept. Although, the constant fields are almost always metadata columns. But the identity partition source field is also injected into the constant map.
There was a problem hiding this comment.
changed. Also renamed the enum from MetadataColumnSource to ColumnConstant since the values aren't exclusively metadata
|
|
||
| /// Pre-computed data for the _partition struct constant. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub struct PartitionColumnConstant { |
There was a problem hiding this comment.
this could be called as StructConstant? It could cover other struct constant if needed, not just the partition column.
| let struct_type = DataType::Struct(pc.fields().clone()); | ||
| let nullable = pc.fields().is_empty(); | ||
| let arrow_field = field_with_id( | ||
| RESERVED_COL_NAME_PARTITION, |
There was a problem hiding this comment.
I think this branch specialized/assumed the MetadataColumnSource::Struct must come from the _partition field, which is true for current code but might change when the spec evolves. It would be better to check the field_id is indeed the partition col field first.
There was a problem hiding this comment.
added a check for field id. Also for unexpected struct constants, we now return an error.
advancedxy
left a comment
There was a problem hiding this comment.
LGTM now, thanks for the effort, it's great work.
|
Thanks @advancedxy. @CTTY could we get a committer review/approval? |
hsiang-c
left a comment
There was a problem hiding this comment.
Thank you @parthchandra LGTM
CTTY
left a comment
There was a problem hiding this comment.
Hi, thanks for your contribution!
| Some(ColumnConstant::Struct(_)) => { | ||
| return Err(Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("Unexpected struct constant for field id {field_id}"), |
There was a problem hiding this comment.
nit: can we make this error message more explicit? Like
format!(
"Struct column constants are only supported for the `_partition` \
metadata column (field id {RESERVED_FIELD_ID_PARTITION}), but one \
was set for field id {field_id}"
)
| Some(ColumnConstant::Struct(_)) => { | ||
| return Err(Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("Unexpected struct constant for field id {field_id}"), |
There was a problem hiding this comment.
nit: same here, the error message is a bit fuzzy
| /// For each field in the unified partition type: | ||
| /// - If it corresponds to a field in this file's partition spec, use the value from partition_data | ||
| /// - Otherwise (partition evolution), use null | ||
| pub fn build_partition_column_constant( |
There was a problem hiding this comment.
nit: similarly, build_partition_constant could be better. We can make the naming consistent
There was a problem hiding this comment.
does this really need to be pub? I think a pub(crate) is more suitable but not sure if I overlooked anything.
Having 3 loosely coupled arguments for a pub function makes it easier for users mistakes
There was a problem hiding this comment.
This feels unneeded since we now have field_with_id
| /// Covers both scalar constants (metadata columns like `_file` and `_spec_id`, | ||
| /// as well as identity partition source fields) and the struct `_partition` column. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub enum ColumnConstant { |
|
|
||
| /// Pre-computed data for a struct constant column. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub struct StructConstant { |
| } | ||
|
|
||
| /// Set a pre-computed _partition column constant directly. | ||
| pub(crate) fn with_partition_column_precomputed( |
There was a problem hiding this comment.
nit: I think with_partition_constant is more straightforward
|
|
||
| for spec in specs { | ||
| for field in spec.fields() { | ||
| if seen_field_ids.contains(&field.field_id) { |
There was a problem hiding this comment.
Can we revisit this? Just read the java impl (Partitioning::buildPartitionProjectionType), and my understanding for the void transform handling is:
- If it's new field, we record its id, type, and name. If we have an existing newer field and the current field is the older field, go to 2
- We check if the newer field is Void transform AND if the older field is not Void transform, which implies that the field was dropped. In this case, java would preserve the type of the older field. But I don't see this happening in the current function?
There was a problem hiding this comment.
Great point @CTTY. This did not match the java version. Redid this to match java's buildPartitionProjectionType. so that when a newer spec marks a field Void but an older spec has it with a real transform, the older spec's type is preserved while the newer spec's name is kept. Introduced all_active_field_ids helper matching Java's allActiveFieldIds.
| /// # Arguments | ||
| /// * `partition_specs` - Iterator over all partition specs in the table | ||
| /// * `schema` - The current table schema (needed to determine result types of transforms) | ||
| pub fn compute_unified_partition_type<'a>( |
There was a problem hiding this comment.
We should add unit tests for this function
There was a problem hiding this comment.
added unit tests -
single spec identity,
year transform,
unpartitioned,
multiple fields sorted by id,
newer name precedence, void replaced by older non-void,
dropped source column skipped, and partition evolution adding new fields
parthchandra
left a comment
There was a problem hiding this comment.
@CTTY thank you for your review. Addressed your comments. Also, added some tests to cover compute_unified_partition_type in the Comet tests (draft PR: apache/datafusion-comet#4752) which compare with Spark results
| /// # Arguments | ||
| /// * `partition_specs` - Iterator over all partition specs in the table | ||
| /// * `schema` - The current table schema (needed to determine result types of transforms) | ||
| pub fn compute_unified_partition_type<'a>( |
There was a problem hiding this comment.
added unit tests -
single spec identity,
year transform,
unpartitioned,
multiple fields sorted by id,
newer name precedence, void replaced by older non-void,
dropped source column skipped, and partition evolution adding new fields
|
|
||
| for spec in specs { | ||
| for field in spec.fields() { | ||
| if seen_field_ids.contains(&field.field_id) { |
There was a problem hiding this comment.
Great point @CTTY. This did not match the java version. Redid this to match java's buildPartitionProjectionType. so that when a newer spec marks a field Void but an older spec has it with a real transform, the older spec's type is preserved while the newer spec's name is kept. Introduced all_active_field_ids helper matching Java's allActiveFieldIds.
| /// Covers both scalar constants (metadata columns like `_file` and `_spec_id`, | ||
| /// as well as identity partition source fields) and the struct `_partition` column. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub enum ColumnConstant { |
|
|
||
| /// Pre-computed data for a struct constant column. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub struct StructConstant { |
| } | ||
|
|
||
| /// Set a pre-computed _partition column constant directly. | ||
| pub(crate) fn with_partition_column_precomputed( |
| Some(ColumnConstant::Struct(_)) => { | ||
| return Err(Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("Unexpected struct constant for field id {field_id}"), |
| Some(ColumnConstant::Struct(_)) => { | ||
| return Err(Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("Unexpected struct constant for field id {field_id}"), |
| /// For each field in the unified partition type: | ||
| /// - If it corresponds to a field in this file's partition spec, use the value from partition_data | ||
| /// - Otherwise (partition evolution), use null | ||
| pub fn build_partition_column_constant( |
|
Hi @parthchandra , looks like the code is not updated yet. Maybe you have commits that are not pushed to the remote? |
6bf928a to
46bc0fb
Compare
|
@CTTY updated (had to force push) |
|
Comet's apache/datafusion-comet#4752 CI is green with this PR. |
CTTY
left a comment
There was a problem hiding this comment.
Thanks for working on this! Just took another pass
| // For identity-partitioned fields, the Iceberg spec's "Column Projection" rules | ||
| // only apply to "field ids which are not present in a data file". When the column | ||
| // IS present in the Parquet file, it must be read from the file; the partition | ||
| // metadata constant is only a fallback for when the column is absent (e.g. add_files). |
There was a problem hiding this comment.
this comment still holds true, can we keep this?
There was a problem hiding this comment.
Added back on the scalar constant arm
|
|
||
| let active_field_ids = all_active_field_ids(specs.iter().copied(), schema); | ||
|
|
||
| let mut field_map: HashMap<i32, &crate::spec::PartitionField> = HashMap::new(); |
There was a problem hiding this comment.
nit: we can just import this
| let mut field_map: HashMap<i32, &crate::spec::PartitionField> = HashMap::new(); | |
| let mut field_map: HashMap<i32, &PartitionField> = HashMap::new(); |
|
|
||
| if matches!(field.transform, Transform::Unknown) { | ||
| return Err(Error::new( | ||
| ErrorKind::FeatureUnsupported, |
There was a problem hiding this comment.
I'd say it's data invalid error. We can't support unknown transform since we don't know the result type
There was a problem hiding this comment.
Fair point. Changed to ErrorKind::DataInvalid
| name_map.insert(field_id, field.name.clone()); | ||
| } | ||
| Some(existing) => { | ||
| if is_void_transform(existing) && !is_void_transform(field) { |
There was a problem hiding this comment.
Java does a compatibility check before this to compare the existing field and the new field, and we need this because V1 doesn't ensure the field id is unique across specs: https://github.com/apache/iceberg/blob/main/core/src/main/java/org/apache/iceberg/Partitioning.java#L295
There was a problem hiding this comment.
Thank you for catching this! Added the compatibility check.
| continue; | ||
| } | ||
|
|
||
| if matches!(field.transform, Transform::Unknown) { |
There was a problem hiding this comment.
This check should be moved before the continue above, otherwise it may be skipped
| (Some(spec), Some(data)) => (spec.clone(), data.clone()), | ||
| // Unpartitioned table or missing spec: build_partition_constant | ||
| // handles the empty unified_type case with an early return. | ||
| _ => (Arc::new(PartitionSpec::unpartition_spec()), Struct::empty()), |
There was a problem hiding this comment.
We should fail if !unified_type.fields().is_empty()
There was a problem hiding this comment.
Done. Now errors if the unified type has fields but spec/data is missing.
| let name = &name_map[&fid]; | ||
| let ty = type_map.remove(&fid).unwrap(); |
There was a problem hiding this comment.
I understand that this is unlikely to fail due to the way it was constructed. But I still think we should not unwrap here, how about the below?
let struct_fields = field_ids
.into_iter()
.map(|fid| -> Result<NestedFieldRef> {
let name = name_map.get(&fid).ok_or_else(|| {
Error::new(
ErrorKind::Unexpected,
format!("Missing name for partition field {fid}"),
)
})?;
let ty = type_map.remove(&fid).ok_or_else(|| {
Error::new(
ErrorKind::Unexpected,
format!("Missing type for partition field {fid}"),
)
})?;
Ok(NestedField::optional(fid, name, ty).into())
})
.collect::<Result<Vec<_>>>()?;There was a problem hiding this comment.
Fair enough. Replaced with ok_or_else.
parthchandra
left a comment
There was a problem hiding this comment.
Thank you for the very thorough review @CTTY!
| (Some(spec), Some(data)) => (spec.clone(), data.clone()), | ||
| // Unpartitioned table or missing spec: build_partition_constant | ||
| // handles the empty unified_type case with an early return. | ||
| _ => (Arc::new(PartitionSpec::unpartition_spec()), Struct::empty()), |
There was a problem hiding this comment.
Done. Now errors if the unified type has fields but spec/data is missing.
|
|
||
| let active_field_ids = all_active_field_ids(specs.iter().copied(), schema); | ||
|
|
||
| let mut field_map: HashMap<i32, &crate::spec::PartitionField> = HashMap::new(); |
| continue; | ||
| } | ||
|
|
||
| if matches!(field.transform, Transform::Unknown) { |
|
|
||
| if matches!(field.transform, Transform::Unknown) { | ||
| return Err(Error::new( | ||
| ErrorKind::FeatureUnsupported, |
There was a problem hiding this comment.
Fair point. Changed to ErrorKind::DataInvalid
| name_map.insert(field_id, field.name.clone()); | ||
| } | ||
| Some(existing) => { | ||
| if is_void_transform(existing) && !is_void_transform(field) { |
There was a problem hiding this comment.
Thank you for catching this! Added the compatibility check.
| let name = &name_map[&fid]; | ||
| let ty = type_map.remove(&fid).unwrap(); |
There was a problem hiding this comment.
Fair enough. Replaced with ok_or_else.
| // For identity-partitioned fields, the Iceberg spec's "Column Projection" rules | ||
| // only apply to "field ids which are not present in a data file". When the column | ||
| // IS present in the Parquet file, it must be read from the file; the partition | ||
| // metadata constant is only a fallback for when the column is absent (e.g. add_files). |
There was a problem hiding this comment.
Added back on the scalar constant arm
CTTY
left a comment
There was a problem hiding this comment.
LGTM! Thanks for contributing this feature
|
Thank you @CTTY @advancedxy @mbutrovich @hsiang-c ! |
Which issue does this PR close?
What changes are included in this PR?
Implements the _partition metadata column for table scans. This is a struct column whose type is the union of all partition fields across all partition specs (handling partition evolution). Each row gets the
partition values for its data file.
Are these changes tested?
Because we do not have write support yet, I made the corresponding change to comet and then tested by adding tests in Comet which uses iceberg-java to write files and then iceberg-rust to read them back.
https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/spark/src/test/resources/sql-tests/iceberg/metadata_column_partition.sql