-
Notifications
You must be signed in to change notification settings - Fork 573
feat(iceberg): thread per-file sort order into FileScanTask #3128
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ mod context; | |
| use context::*; | ||
| mod task; | ||
|
|
||
| use std::collections::HashMap; | ||
| use std::sync::Arc; | ||
|
|
||
| use arrow_array::RecordBatch; | ||
|
|
@@ -41,7 +42,7 @@ use crate::metadata_columns::{ | |
| RESERVED_FIELD_ID_PARTITION, get_metadata_field_id, is_metadata_column_name, | ||
| }; | ||
| use crate::runtime::Runtime; | ||
| use crate::spec::{DataContentType, Schema, SchemaRef, SnapshotRef, StructType}; | ||
| use crate::spec::{DataContentType, Schema, SchemaRef, SnapshotRef, SortOrderRef, StructType}; | ||
| use crate::table::Table; | ||
| use crate::util::available_parallelism; | ||
| use crate::{Error, ErrorKind, Result}; | ||
|
|
@@ -311,6 +312,16 @@ impl<'a> TableScanBuilder<'a> { | |
| .map(Arc::new); | ||
| let unified_partition_type = projected_partition_type(self.table, &schema, &field_ids)?; | ||
|
|
||
| // Precompute the table's sort orders once, keyed by id, so each manifest-file | ||
| // context carries only this narrow map instead of the full table metadata. | ||
| let sort_orders = Arc::new( | ||
| self.table | ||
| .metadata() | ||
| .sort_orders_iter() | ||
| .map(|order| (order.order_id, order.clone())) | ||
| .collect::<HashMap<i64, SortOrderRef>>(), | ||
| ); | ||
|
|
||
| let plan_context = PlanContext { | ||
| snapshot, | ||
| table_metadata: self.table.metadata_ref(), | ||
|
|
@@ -325,6 +336,7 @@ impl<'a> TableScanBuilder<'a> { | |
| manifest_evaluator_cache: Arc::new(ManifestEvaluatorCache::new()), | ||
| expression_evaluator_cache: Arc::new(ExpressionEvaluatorCache::new()), | ||
| unified_partition_type, | ||
| sort_orders, | ||
| }; | ||
|
|
||
| Ok(TableScan { | ||
|
|
@@ -664,9 +676,10 @@ pub mod tests { | |
| use crate::spec::{ | ||
| DataContentType, DataFileBuilder, DataFileFormat, Datum, FormatVersion, Literal, | ||
| MAIN_BRANCH, ManifestEntry, ManifestListWriter, ManifestStatus, ManifestWriterBuilder, | ||
| MappedField, NameMapping, NestedField, Operation, PartitionSpec, PrimitiveType, Schema, | ||
| Snapshot, Struct, StructType, Summary, TableMetadata, TableMetadataBuilder, | ||
| TableProperties, Transform, Type, UnboundPartitionSpec, | ||
| MappedField, NameMapping, NestedField, NullOrder, Operation, PartitionSpec, PrimitiveType, | ||
| Schema, Snapshot, SortDirection, SortField, SortOrder, Struct, StructType, Summary, | ||
| TableMetadata, TableMetadataBuilder, TableProperties, Transform, Type, | ||
| UnboundPartitionSpec, | ||
| }; | ||
| use crate::table::Table; | ||
| use crate::test_utils::test_runtime; | ||
|
|
@@ -1638,6 +1651,76 @@ pub mod tests { | |
| manifest_list_write.close().await.unwrap(); | ||
| } | ||
|
|
||
| /// Writes a manifest with three live "Added" data-file entries (partitioned on `x` | ||
| /// = 100, 200, 300), each with the given `sort_order_id` set on its `DataFile` | ||
| /// (`None` leaves the field unset). Used to test how `sort_order_id` resolution | ||
| /// against the table's sort orders flows into each entry's `FileScanTask`. | ||
| pub async fn setup_manifest_files_with_sort_order_ids( | ||
| &mut self, | ||
| sort_order_ids: [Option<i32>; 4], | ||
| ) { | ||
| let current_snapshot = self.table.metadata().current_snapshot().unwrap(); | ||
| let current_schema = current_snapshot.schema(self.table.metadata()).unwrap(); | ||
| let current_partition_spec = self.table.metadata().default_partition_spec(); | ||
| let parquet_file_size = self.write_parquet_data_files(); | ||
|
|
||
| let mut writer = ManifestWriterBuilder::new( | ||
| self.next_manifest_file(), | ||
| Some(current_snapshot.snapshot_id()), | ||
| current_schema.clone(), | ||
| current_partition_spec.as_ref().clone(), | ||
| ) | ||
| .build_v2_data(); | ||
|
|
||
| for (i, sort_order_id) in sort_order_ids.into_iter().enumerate() { | ||
| let mut data_file_builder = DataFileBuilder::default(); | ||
| data_file_builder | ||
| .partition_spec_id(0) | ||
| .content(DataContentType::Data) | ||
| .file_path(format!("{}/{}.parquet", &self.table_location, i + 1)) | ||
| .file_format(DataFileFormat::Parquet) | ||
| .file_size_in_bytes(parquet_file_size) | ||
| .record_count(1) | ||
| .partition(Struct::from_iter([Some(Literal::long( | ||
| 100 * (i as i64 + 1), | ||
| ))])); | ||
| if let Some(id) = sort_order_id { | ||
| data_file_builder.sort_order_id(id); | ||
| } | ||
| let data_file = data_file_builder.build().unwrap(); | ||
|
|
||
| writer | ||
| .add_entry( | ||
| ManifestEntry::builder() | ||
| .status(ManifestStatus::Added) | ||
| .data_file(data_file) | ||
| .build(), | ||
| ) | ||
| .unwrap(); | ||
| } | ||
|
|
||
| let data_file_manifest = writer.write_manifest_file().await.unwrap(); | ||
|
|
||
| let manifest_list_writer = self | ||
| .table | ||
| .file_io() | ||
| .new_output(current_snapshot.manifest_list()) | ||
| .unwrap() | ||
| .writer() | ||
| .await | ||
| .unwrap(); | ||
| let mut manifest_list_write = ManifestListWriter::v2( | ||
| manifest_list_writer, | ||
| current_snapshot.snapshot_id(), | ||
| current_snapshot.parent_snapshot_id(), | ||
| current_snapshot.sequence_number(), | ||
| ); | ||
| manifest_list_write | ||
| .add_manifests(std::iter::once(data_file_manifest)) | ||
| .unwrap(); | ||
| manifest_list_write.close().await.unwrap(); | ||
| } | ||
|
|
||
| /// Writes `mrg.parquet` with three 100-row row groups. Columns `x` (field | ||
| /// id `1`) and `y` (field id `2`) both run 1000..1300, so row position `p` | ||
| /// carries `x = y = 1000 + p`. Returns `(path, file_size_in_bytes)`. | ||
|
|
@@ -1969,6 +2052,104 @@ pub mod tests { | |
| } | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_plan_files_carries_sort_order_into_file_scan_task() { | ||
| let mut fixture = TableTestFixture::new(); | ||
|
|
||
| // Inject the reserved unsorted order (id 0) inline rather than editing the shared | ||
| // testdata fixture, so the id-0 file below exercises the `!is_unsorted()` filter | ||
| // branch instead of the `and_then` short-circuit an absent entry would take. | ||
| let mut metadata = fixture.table.metadata().clone(); | ||
| metadata | ||
| .sort_orders | ||
| .insert(0, Arc::new(SortOrder::unsorted_order())); | ||
| fixture.table = fixture.table.with_metadata(Arc::new(metadata)); | ||
|
|
||
| let expected_sort_order = fixture | ||
| .table | ||
| .metadata() | ||
| .sort_order_by_id(3) | ||
| .unwrap() | ||
| .clone(); | ||
|
|
||
| // sort_order_ids: resolvable (3), absent, unresolvable (99), reserved unsorted (0). | ||
| fixture | ||
| .setup_manifest_files_with_sort_order_ids([Some(3), None, Some(99), Some(0)]) | ||
| .await; | ||
|
|
||
| let tasks: Vec<_> = fixture | ||
| .table | ||
| .scan() | ||
| .build() | ||
| .unwrap() | ||
| .plan_files() | ||
| .await | ||
| .unwrap() | ||
| .try_collect() | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| assert_eq!(tasks.len(), 4, "expected all four FileScanTasks"); | ||
|
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. The per-file assertions are thorough, but nothing asserts the aggregate — a regression that resolved every entry to id 3, or dropped resolution entirely, would still pass three of the four checks. A single
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. Added two: an assertion that exactly one task resolves to |
||
|
|
||
| // Aggregates catch a systemic regression (every entry resolving to id 3, or | ||
| // resolution dropping entirely) that the per-file checks below would each still pass. | ||
| assert_eq!( | ||
| tasks.iter().filter(|t| t.sort_order().is_some()).count(), | ||
| 1, | ||
| "exactly one file resolves to a sort order" | ||
| ); | ||
| assert_eq!( | ||
| tasks.iter().filter(|t| t.sort_order_id().is_some()).count(), | ||
| 3, | ||
| "three files carry a raw sort_order_id" | ||
| ); | ||
|
|
||
| let resolved = tasks | ||
| .iter() | ||
| .find(|t| t.data_file_path().ends_with("1.parquet")) | ||
| .unwrap(); | ||
| assert_eq!(resolved.sort_order_id(), Some(3)); | ||
| assert_eq!( | ||
| resolved.sort_order(), | ||
| Some(&expected_sort_order), | ||
| "sort_order_id 3 should resolve to the table's sort order at id 3" | ||
| ); | ||
|
|
||
| let missing = tasks | ||
| .iter() | ||
| .find(|t| t.data_file_path().ends_with("2.parquet")) | ||
| .unwrap(); | ||
| assert_eq!(missing.sort_order_id(), None); | ||
| assert!( | ||
| missing.sort_order().is_none(), | ||
| "a file with no sort_order_id carries no sort_order" | ||
| ); | ||
|
|
||
| let unresolvable = tasks | ||
| .iter() | ||
| .find(|t| t.data_file_path().ends_with("3.parquet")) | ||
| .unwrap(); | ||
| assert_eq!( | ||
| unresolvable.sort_order_id(), | ||
| Some(99), | ||
| "the raw id is preserved even when it does not resolve" | ||
| ); | ||
| assert!( | ||
| unresolvable.sort_order().is_none(), | ||
| "an unresolvable sort_order_id resolves to no sort_order" | ||
| ); | ||
|
|
||
| let unsorted = tasks | ||
| .iter() | ||
| .find(|t| t.data_file_path().ends_with("4.parquet")) | ||
| .unwrap(); | ||
| assert_eq!(unsorted.sort_order_id(), Some(0)); | ||
| assert!( | ||
| unsorted.sort_order().is_none(), | ||
| "the reserved unsorted order (id 0) resolves to no sort_order" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_plan_files_on_table_without_any_snapshots() { | ||
| let table = TableTestFixture::new_empty().table; | ||
|
|
@@ -2746,6 +2927,20 @@ pub mod tests { | |
| .unwrap(), | ||
| ); | ||
| let unified_partition_type = Arc::new(partition_spec.partition_type(&schema).unwrap()); | ||
| let sort_order = Arc::new( | ||
| SortOrder::builder() | ||
| .with_order_id(1) | ||
| .with_sort_field( | ||
| SortField::builder() | ||
| .source_id(1) | ||
| .transform(Transform::Identity) | ||
| .direction(SortDirection::Ascending) | ||
| .null_order(NullOrder::First) | ||
| .build(), | ||
| ) | ||
| .build(&schema) | ||
| .unwrap(), | ||
| ); | ||
| let task = FileScanTask::builder() | ||
| .with_data_file_path("data_file_path".to_string()) | ||
| .with_file_size_in_bytes(123) | ||
|
|
@@ -2777,6 +2972,8 @@ pub mod tests { | |
| vec![], | ||
| )])))) | ||
| .with_unified_partition_type(Some(unified_partition_type)) | ||
| .with_sort_order_id(Some(1)) | ||
| .with_sort_order(Some(sort_order)) | ||
| .with_case_sensitive(true) | ||
| .with_key_metadata(Some(vec![1, 2, 3].into_boxed_slice())) | ||
| .build() | ||
|
|
||
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.
Small thing:
!order.is_unsorted()keys offfields.is_empty()rather than the order id, so a malformed or forward-compat order with a non-zero id and empty fields would also be treated as unsorted. In a valid table that's equivalent toorder_id == 0and it matches Java'sisUnsorted(), so it's fine as-is.The field doc over in
task.rssays it resolves "to the reserved unsorted order (id 0, per the spec)", which is a bit more specific than what the check actually does — worth either softening the wording or comparing againstSortOrder::UNSORTED_ORDER_IDif we want the doc to be literally true.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.
Went with softening the doc rather than switching to
UNSORTED_ORDER_ID, since checkingis_unsorted()on the resolved order is the behavior I actually want (matches Java'sisSorted()gate). Reworded so the doc leads with "an order with no sort fields" and mentions id 0 only as the spec-defined example, rather than implying the check keys off the id.