laskoviymishka commented on code in PR #3128:
URL: https://github.com/apache/iceberg-rust/pull/3128#discussion_r3972092775
##########
crates/iceberg/src/scan/context.rs:
##########
@@ -86,11 +88,19 @@ impl ManifestFileContext {
case_sensitive,
partition_spec,
unified_partition_type,
+ table_metadata,
} = self;
let manifest = object_cache.get_manifest(&manifest_file).await?;
for manifest_entry in manifest.entries() {
+ let sort_order = manifest_entry
+ .data_file()
+ .sort_order_id()
+ .and_then(|id| table_metadata.sort_order_by_id(id as i64))
Review Comment:
This resolves and then discards the raw `sort_order_id`, which collapses
three different situations into a single `None`: no id at all, the reserved
unsorted order, and an id that doesn't resolve against the table's sort orders.
For reading that's spec-compliant, but for the Part 2 sorted-scan work it's
the difference between "this file was never sorted" and "this file is
physically sorted but its order definition was dropped" — both arrive as `None`
and the optimizer can't tell them apart.
I'd carry the raw `sort_order_id: Option<i32>` on `FileScanTask` alongside
the resolved `sort_order` so a caller can still distinguish those cases. At
minimum, a `tracing::warn!` when a non-zero id fails to resolve would keep this
from silently degrading with no signal. wdyt?
##########
crates/iceberg/src/scan/context.rs:
##########
@@ -86,11 +88,19 @@ impl ManifestFileContext {
case_sensitive,
partition_spec,
unified_partition_type,
+ table_metadata,
} = self;
let manifest = object_cache.get_manifest(&manifest_file).await?;
for manifest_entry in manifest.entries() {
+ let sort_order = manifest_entry
+ .data_file()
+ .sort_order_id()
+ .and_then(|id| table_metadata.sort_order_by_id(id as i64))
+ .filter(|order| !order.is_unsorted())
Review Comment:
Small thing: `!order.is_unsorted()` keys off `fields.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 to `order_id == 0` and it matches Java's `isUnsorted()`, so it's
fine as-is.
The field doc over in `task.rs` says 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 against
`SortOrder::UNSORTED_ORDER_ID` if we want the doc to be literally true.
##########
crates/iceberg/src/scan/mod.rs:
##########
@@ -1640,6 +1640,76 @@ pub mod tests {
manifest_list_write.close().await.unwrap();
}
+ /// Writes a manifest with four live "Added" data-file entries
(partitioned on `x`
+ /// = 100, 200, 300, 400), 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(vec![data_file_manifest].into_iter())
Review Comment:
`std::iter::once(data_file_manifest)` avoids the throwaway heap `Vec` here —
the sibling helpers use `vec![...]` because they genuinely have multiple
manifests, but this one only ever has the one.
##########
crates/iceberg/src/scan/task.rs:
##########
@@ -150,6 +150,22 @@ pub struct FileScanTask {
#[builder(default)]
pub unified_partition_type: Option<Arc<StructType>>,
+ /// The sort order that this file's rows are sorted by, resolved from the
data file's
+ /// `sort_order_id` against the table's known sort orders. `Some` only if
the id resolves
+ /// to a sort order with at least one field. `None` if the file has no
sort order id, the
+ /// id doesn't resolve against the table's sort orders, or it resolves to
the reserved
+ /// unsorted order (id 0, per the spec).
+ ///
+ /// Note: this reflects only the file's own recorded sort order id, not
necessarily the
+ /// table's current default sort order — see
[`crate::spec::DataFile::sort_order_id`].
+ /// Serde: not yet implemented.
+ #[serde(default)]
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(serialize_with = "serialize_not_implemented")]
Review Comment:
This is the one I'd really want to settle before merge. Unlike `partition` /
`partition_spec` / `unified_partition_type`, `SortOrder` already derives
`Serialize`/`Deserialize` and gets round-tripped in table metadata JSON today —
so there's no technical reason for the `not_implemented` guard here.
The effect is that any `FileScanTask` whose `sort_order` is `Some(_)` errors
on serialize. `skip_serializing_if` covers the `None` case, but the moment Part
2 populates this field — which is the whole point of the PR — every task with a
resolved order silently fails serde on a public field.
I'd drop the `serialize_with`/`deserialize_with` guards and use plain
`#[serde(default, skip_serializing_if = "Option::is_none")]`, which just works
for a round-trippable `SortOrder`. If there's a wire-format reason to omit it,
I'd love a comment spelling that out specifically rather than the
`not_implemented` stub. wdyt?
##########
crates/iceberg/testdata/example_table_metadata_v2.json:
##########
@@ -41,6 +41,10 @@
"last-partition-id": 1000,
"default-sort-order-id": 3,
"sort-orders": [
+ {
+ "order-id": 0,
Review Comment:
Adding order-id 0 here works, but this fixture is shared by four test
modules (transaction/append, io/object_cache, the scan tests), and the change
flips `sort_order_by_id(0)` from `None` to `Some(unsorted)` for all of them — a
silent behavioral change for one test's benefit.
It also makes the file-4 assertion a bit of an illusion: without this entry,
`sort_order_by_id(0)` returns `None`, `and_then` short-circuits, and
`sort_order.is_none()` passes either way — the `!is_unsorted()` filter never
actually fires. And the more common Java-written case (id 0 with no explicit
order-0 entry) stays untested.
I'd either drop this change and add a dedicated fixture that omits order-id
0 (proving the real short-circuit path), or mutate the metadata clone inline in
the helper the way `new_unpartitioned` does, so the filter branch gets
exercised without touching a shared fixture. wdyt?
##########
crates/iceberg/src/scan/context.rs:
##########
@@ -50,6 +50,7 @@ pub(crate) struct ManifestFileContext {
case_sensitive: bool,
partition_spec: Option<PartitionSpecRef>,
unified_partition_type: Option<Arc<StructType>>,
+ table_metadata: TableMetadataRef,
Review Comment:
Carrying the whole `TableMetadataRef` into every `ManifestFileContext`
works, but it hands each context a much wider capability than it needs — the
existing `unified_partition_type` pattern precomputes the derived value in
`PlanContext` and carries only that.
Sort-order resolution is genuinely per-entry, so I get why full metadata is
tempting. But we could build an `Arc<HashMap<i64, SortOrderRef>>` once in
`create_manifest_file_context` and carry that instead, which keeps the context
narrow and matches the surrounding pattern. wdyt?
##########
crates/iceberg/src/scan/task.rs:
##########
@@ -150,6 +150,22 @@ pub struct FileScanTask {
#[builder(default)]
pub unified_partition_type: Option<Arc<StructType>>,
+ /// The sort order that this file's rows are sorted by, resolved from the
data file's
+ /// `sort_order_id` against the table's known sort orders. `Some` only if
the id resolves
+ /// to a sort order with at least one field. `None` if the file has no
sort order id, the
+ /// id doesn't resolve against the table's sort orders, or it resolves to
the reserved
+ /// unsorted order (id 0, per the spec).
+ ///
+ /// Note: this reflects only the file's own recorded sort order id, not
necessarily the
+ /// table's current default sort order — see
[`crate::spec::DataFile::sort_order_id`].
Review Comment:
The intra-doc link `crate::spec::DataFile::sort_order_id` points at a method
but omits the `()`, so rustdoc may resolve it as a field or emit a
`broken_intra_doc_links` warning — writing it as `sort_order_id()` fixes it.
##########
crates/iceberg/src/scan/mod.rs:
##########
@@ -1968,6 +2038,73 @@ pub mod tests {
}
}
+ #[tokio::test]
+ async fn test_plan_files_carries_sort_order_into_file_scan_task() {
+ let mut fixture = TableTestFixture::new();
+
+ let expected_sort_order = fixture
+ .table
+ .metadata()
+ .sort_order_by_id(3)
+ .unwrap()
+ .clone();
+
+ 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");
Review Comment:
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 `assert_eq!(tasks.iter().filter(|t|
t.sort_order.is_some()).count(), 1)` up top would catch that class of systemic
regression cheaply.
--
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]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]