Repository navigation
feat: write top-level Iceberg unknown columns natively #7100
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: main
Are you sure you want to change the base?
Changes from all commits
bfb13e3
30e99c5
d84a039
9335ea3
caa4f47
ecb69ea
351f2ea
d4c6b0b
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 |
|---|---|---|
|
|
@@ -58,8 +58,8 @@ use iceberg::arrow::arrow_struct_to_literal; | |
| use iceberg::io::{FileIO, FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage}; | ||
| use iceberg::spec::{ | ||
| DataFile, DataFileBuilder, DataFileFormat, Literal, ManifestWriterBuilder, PartitionKey, | ||
| PartitionSpec, PartitionSpecRef, Schema as IcebergSchema, SchemaRef as IcebergSchemaRef, | ||
| Struct as IcebergStruct, StructType, | ||
| PartitionSpec, PartitionSpecRef, PrimitiveType, Schema as IcebergSchema, | ||
| SchemaRef as IcebergSchemaRef, Struct as IcebergStruct, StructType, Type, | ||
| }; | ||
| use iceberg::writer::base_writer::data_file_writer::{DataFileWriter, DataFileWriterBuilder}; | ||
| use iceberg::writer::file_writer::location_generator::{ | ||
|
|
@@ -1488,6 +1488,22 @@ fn parse_iceberg_schema(json: &str) -> DFResult<IcebergSchemaRef> { | |
| let schema: IcebergSchema = serde_json::from_str(json).map_err(|e| { | ||
| DataFusionError::Internal(format!("Failed to parse iceberg schema JSON: {e}")) | ||
| })?; | ||
| // iceberg-java writes no column for a top-level `unknown` field. | ||
|
yunchipang marked this conversation as resolved.
Resolved
|
||
| let fields = schema.as_struct().fields(); | ||
| let kept: Vec<_> = fields | ||
| .iter() | ||
| .filter(|f| *f.field_type != Type::Primitive(PrimitiveType::Unknown)) | ||
|
Member
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. [P2] Preserve or decline Evidence: Created a v3 table using
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. thanks for catching this! fixed in the latest commit: the gate now falls back to |
||
| .cloned() | ||
| .collect(); | ||
| if kept.len() == fields.len() { | ||
| return Ok(Arc::new(schema)); | ||
| } | ||
| let schema = IcebergSchema::builder() | ||
| .with_schema_id(schema.schema_id()) | ||
| .with_identifier_field_ids(schema.identifier_field_ids()) | ||
| .with_fields(kept) | ||
| .build() | ||
| .map_err(|e| DataFusionError::Internal(format!("Failed to prune iceberg schema: {e}")))?; | ||
| Ok(Arc::new(schema)) | ||
| } | ||
|
|
||
|
|
@@ -1597,10 +1613,16 @@ fn decorate_batch_with_field_ids( | |
| batch: RecordBatch, | ||
| target_schema: &SchemaRef, | ||
| ) -> DFResult<RecordBatch> { | ||
| if batch.num_columns() != target_schema.fields().len() { | ||
| // `unknown` columns arrive as Null; `parse_iceberg_schema` dropped their fields. | ||
| let columns: Vec<&ArrayRef> = batch | ||
| .columns() | ||
| .iter() | ||
| .filter(|c| c.data_type() != &DataType::Null) | ||
| .collect(); | ||
| if columns.len() != target_schema.fields().len() { | ||
| return Err(DataFusionError::Plan(format!( | ||
| "Iceberg write column count mismatch: arrow batch has {} columns but schema has {}", | ||
| batch.num_columns(), | ||
| columns.len(), | ||
| target_schema.fields().len() | ||
| ))); | ||
| } | ||
|
|
@@ -1609,9 +1631,8 @@ fn decorate_batch_with_field_ids( | |
| safe: false, | ||
| ..Default::default() | ||
| }; | ||
| let casted: Vec<ArrayRef> = batch | ||
| .columns() | ||
| .iter() | ||
| let casted: Vec<ArrayRef> = columns | ||
| .into_iter() | ||
| .zip(target_schema.fields().iter()) | ||
| .map(|(col, target)| { | ||
| arrow::compute::cast_with_options(col, target.data_type(), &cast_options) | ||
|
|
@@ -4584,6 +4605,21 @@ mod tests { | |
| assert!(format!("{err}").contains("column count mismatch")); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn decorate_batch_drops_null_columns() { | ||
| let schema = iceberg_user_schema(); | ||
| let target = Arc::new(iceberg::arrow::schema_to_arrow_schema(&schema).unwrap()); | ||
| let original = batch(&[1, 2], &["a", "b"]); | ||
| let mut fields = original.schema().fields().to_vec(); | ||
| fields.insert(1, Arc::new(Field::new("u", DataType::Null, true))); | ||
| let mut columns = original.columns().to_vec(); | ||
| columns.insert(1, arrow::array::new_null_array(&DataType::Null, 2)); | ||
| let with_null = | ||
| RecordBatch::try_new(Arc::new(ArrowSchema::new(fields)), columns).unwrap(); | ||
| let decorated = decorate_batch_with_field_ids(with_null, &target).unwrap(); | ||
| assert_eq!(decorated.schema(), target); | ||
| } | ||
|
|
||
| /// A spec whose only partition field is a `void` transform, as iceberg-java's | ||
| /// `UpdatePartitionSpec` leaves a format-version-1 spec after `DROP PARTITION FIELD`. | ||
| /// Built by deserialising the spec JSON rather than through `PartitionSpec::builder`, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import scala.util.control.NonFatal | |
|
|
||
| import org.apache.hadoop.conf.Configuration | ||
| import org.apache.spark.sql.comet.{CometIcebergWriteExec, CometNativeExec, IcebergWriteExec} | ||
| import org.apache.spark.sql.types.NullType | ||
|
|
||
| import org.apache.comet.{CometConf, ConfigEntry} | ||
| import org.apache.comet.CometSparkSessionExtensions.withFallbackReason | ||
|
|
@@ -81,11 +82,11 @@ object CometIcebergNativeWrite extends CometOperatorSerde[IcebergWriteExec] { | |
| } | ||
|
|
||
| private val EncryptionPropertyPrefix = "encryption." | ||
| // `uuid` plus the v3-only types. Iceberg plans `variant` as Spark's VariantType and `unknown` | ||
| // as NullType, neither of which the native writer handles; Spark cannot plan a write to | ||
| // `timestamp_ns`, `geometry` or `geography` today, so those are declined in case it learns to. | ||
| // `uuid` plus the v3-only types. Iceberg plans `variant` as Spark's VariantType, which the | ||
| // native writer does not handle; Spark cannot plan a write to `timestamp_ns`, `geometry` or | ||
| // `geography` today, so those are declined in case it learns to. `unknown` is checked below. | ||
| private val UnsupportedWriteTypeIds: Set[String] = | ||
| Set("UUID", "VARIANT", "UNKNOWN", "TIMESTAMP_NANO", "GEOMETRY", "GEOGRAPHY") | ||
| Set("UUID", "VARIANT", "TIMESTAMP_NANO", "GEOMETRY", "GEOGRAPHY") | ||
| // Loaded from the native storage factory: `builtin_storage_schemes` in | ||
| // `native/core/src/execution/operators/iceberg_common.rs` is the single point of change and | ||
| // explains why `oss` is read-only and `memory` write-only. Lazy so constructing the serde does | ||
|
|
@@ -416,6 +417,7 @@ object CometIcebergNativeWrite extends CometOperatorSerde[IcebergWriteExec] { | |
| requireNoMetadataColumns, | ||
| requireSupportedColumnTypes, | ||
| requireNoVoidFieldWithDroppedSource, | ||
| requireNoUnknownPartitionSource, | ||
| requireNoEncryptionPrefix, | ||
| requireNoBloomFilterColumnsEnabled, | ||
| requireRowGroupCheckMinRecordCountAtDefault, | ||
|
|
@@ -524,10 +526,18 @@ object CometIcebergNativeWrite extends CometOperatorSerde[IcebergWriteExec] { | |
| case Some(schema) => | ||
| IcebergReflection | ||
| .findFieldWithTypeIds(schema, UnsupportedWriteTypeIds) | ||
| // Only top-level `unknown` fields are pruned, so a nested one would still | ||
| // get a Parquet column that iceberg-java does not write. | ||
| .orElse( | ||
| IcebergReflection.findFieldWithTypeIds(schema, Set("UNKNOWN"), nestedOnly = true)) | ||
| .map { case (name, typeId) => | ||
| s"column $name has Iceberg type ${typeId.toLowerCase(Locale.ROOT)}, " + | ||
| "which the native writer cannot reproduce" | ||
| } | ||
| // Pruning would leave no columns, which iceberg-java cannot write either. | ||
| .orElse(if (IcebergReflection.toSparkSchema(schema).forall(_.dataType == NullType)) { | ||
| Some("every column has Iceberg type unknown") | ||
| } else None) | ||
| } | ||
|
|
||
| // A format-version-1 spec keeps a dropped partition field as a `void` transform, and its source | ||
|
|
@@ -553,6 +563,24 @@ object CometIcebergNativeWrite extends CometOperatorSerde[IcebergWriteExec] { | |
| } | ||
| } | ||
|
|
||
| // Top-level `unknown` columns are pruned from the native schema, so a partition field sourced | ||
| // from one would fail the native partition calculator with `Field not found`. | ||
| private val requireNoUnknownPartitionSource: TriggerRule = ctx => | ||
|
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. This repeats |
||
| IcebergReflection | ||
| .getOutputSpecIdFromSparkWrite(ctx.sparkWrite) | ||
| .flatMap(IcebergReflection.getPartitionSpecById(ctx.table, _)) match { | ||
| case None => Some("could not resolve the output partition spec for unknown source checking") | ||
| case Some(spec) => | ||
| try { | ||
| IcebergReflection.partitionFieldsWithUnknownSource(spec).headOption.map { name => | ||
| s"partition field $name is sourced from a column of Iceberg type unknown" | ||
| } | ||
| } catch { | ||
| case e: Exception => | ||
| Some(s"could not inspect the output partition spec: ${e.getMessage}") | ||
| } | ||
| } | ||
|
|
||
| private val requireNoEncryptionPrefix: TriggerRule = ctx => | ||
| ctx.properties.keys | ||
| .find(_.startsWith(EncryptionPropertyPrefix)) | ||
|
|
||
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.
The "so" does not follow: the gate declines such a partition field because the partition calculator resolves its source by field id in the pruned schema and would fail with
Field not found. Could this say thatparse_iceberg_schemaprunes the fields and the calculator therefore cannot resolve a pruned source?