This is an automated email from the ASF dual-hosted git repository.

laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new c68428df5 feat: carry first_row_id and data sequence number on 
FileScanTask (#2952)
c68428df5 is described below

commit c68428df59cbffa2c18a738a2dda4cd5a0e94885
Author: Anoop Johnson <[email protected]>
AuthorDate: Tue Aug 4 10:35:16 2026 -0700

    feat: carry first_row_id and data sequence number on FileScanTask (#2952)
    
    ## Which issue does this PR close?
    
    Refs https://github.com/apache/iceberg-rust/issues/2879
    
    ## What changes are included in this PR?
    
    Manifest read now inherits a data file's first_row_id (#2922)
    
    Add both fields to FileScanTask and populate them in
    `ManifestEntryContext::into_file_scan_task` from the manifest entry's
    `data_file().first_row_id()` and `sequence_number()`.
    
    This is step 2 of reading the row-lineage metadata columns; positional
    materialization in the arrow pipeline follows.
    
    ## Are these changes tested?
    
    Added tests.
    
    ## AI Disclosure
    
    Assisted with Claude Code (Opus 4.8). I have reviewed and iterated to
    get to this shape.
---
 crates/iceberg/public-api.txt                 |   4 +-
 crates/iceberg/src/arrow/reader/row_filter.rs |   4 +
 crates/iceberg/src/scan/context.rs            |   2 +
 crates/iceberg/src/scan/mod.rs                | 148 +++++++++++++++++++++++++-
 crates/iceberg/src/scan/task.rs               |  18 ++++
 5 files changed, 174 insertions(+), 2 deletions(-)

diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index 12f844b36..d88ffa1b9 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -1266,8 +1266,10 @@ pub struct iceberg::scan::FileScanTask
 pub iceberg::scan::FileScanTask::case_sensitive: bool
 pub iceberg::scan::FileScanTask::data_file_format: 
iceberg::spec::DataFileFormat
 pub iceberg::scan::FileScanTask::data_file_path: alloc::string::String
+pub iceberg::scan::FileScanTask::data_sequence_number: 
core::option::Option<i64>
 pub iceberg::scan::FileScanTask::deletes: 
alloc::vec::Vec<iceberg::scan::FileScanTaskDeleteFile>
 pub iceberg::scan::FileScanTask::file_size_in_bytes: u64
+pub iceberg::scan::FileScanTask::first_row_id: core::option::Option<i64>
 pub iceberg::scan::FileScanTask::key_metadata: 
core::option::Option<alloc::boxed::Box<[u8]>>
 pub iceberg::scan::FileScanTask::length: u64
 pub iceberg::scan::FileScanTask::name_mapping: 
core::option::Option<alloc::sync::Arc<iceberg::spec::NameMapping>>
@@ -1293,7 +1295,7 @@ impl core::fmt::Debug for iceberg::scan::FileScanTask
 pub fn iceberg::scan::FileScanTask::fmt(&self, f: &mut 
core::fmt::Formatter<'_>) -> core::fmt::Result
 impl core::marker::StructuralPartialEq for iceberg::scan::FileScanTask
 impl iceberg::scan::FileScanTask
-pub fn iceberg::scan::FileScanTask::builder() -> FileScanTaskBuilder<((), (), 
(), (), (), (), (), (), (), (), (), (), (), (), (), ())>
+pub fn iceberg::scan::FileScanTask::builder() -> FileScanTaskBuilder<((), (), 
(), (), (), (), (), (), (), (), (), (), (), (), (), (), (), ())>
 impl serde_core::ser::Serialize for iceberg::scan::FileScanTask
 pub fn iceberg::scan::FileScanTask::serialize<__S>(&self, __serializer: __S) 
-> core::result::Result<<__S as serde_core::ser::Serializer>::Ok, <__S as 
serde_core::ser::Serializer>::Error> where __S: serde_core::ser::Serializer
 impl<'de> serde_core::de::Deserialize<'de> for iceberg::scan::FileScanTask
diff --git a/crates/iceberg/src/arrow/reader/row_filter.rs 
b/crates/iceberg/src/arrow/reader/row_filter.rs
index d82b9a0af..2e6b37ba3 100644
--- a/crates/iceberg/src/arrow/reader/row_filter.rs
+++ b/crates/iceberg/src/arrow/reader/row_filter.rs
@@ -1138,6 +1138,8 @@ mod tests {
             start: 0,
             length: 0,
             record_count: None,
+            first_row_id: None,
+            data_sequence_number: None,
             data_file_path: file_path.clone(),
             data_file_format: DataFileFormat::Parquet,
             schema: iceberg_schema.clone(),
@@ -1231,6 +1233,8 @@ mod tests {
             start: 0,
             length: 0,
             record_count: None,
+            first_row_id: None,
+            data_sequence_number: None,
             data_file_path: file_path.clone(),
             data_file_format: DataFileFormat::Parquet,
             schema: iceberg_schema.clone(),
diff --git a/crates/iceberg/src/scan/context.rs 
b/crates/iceberg/src/scan/context.rs
index 176bcba45..67b8cde4d 100644
--- a/crates/iceberg/src/scan/context.rs
+++ b/crates/iceberg/src/scan/context.rs
@@ -133,6 +133,8 @@ impl ManifestEntryContext {
             .with_start(0)
             .with_length(self.manifest_entry.file_size_in_bytes())
             .with_record_count(Some(self.manifest_entry.record_count()))
+            .with_first_row_id(self.manifest_entry.data_file().first_row_id())
+            .with_data_sequence_number(self.manifest_entry.sequence_number())
             .with_data_file_path(self.manifest_entry.file_path().to_string())
             .with_data_file_format(self.manifest_entry.file_format())
             .with_schema(self.snapshot_schema)
diff --git a/crates/iceberg/src/scan/mod.rs b/crates/iceberg/src/scan/mod.rs
index a1c85c711..561b41696 100644
--- a/crates/iceberg/src/scan/mod.rs
+++ b/crates/iceberg/src/scan/mod.rs
@@ -669,7 +669,7 @@ pub mod tests {
     use crate::scan::FileScanTask;
     use crate::spec::{
         DEFAULT_SCHEMA_NAME_MAPPING, DataContentType, DataFileBuilder, 
DataFileFormat, Datum,
-        Literal, MAIN_BRANCH, ManifestEntry, ManifestListWriter, 
ManifestStatus,
+        FormatVersion, Literal, MAIN_BRANCH, ManifestEntry, 
ManifestListWriter, ManifestStatus,
         ManifestWriterBuilder, NestedField, Operation, PartitionSpec, 
PrimitiveType, Schema,
         Snapshot, Struct, StructType, Summary, TableMetadata, 
TableMetadataBuilder, Type,
         UnboundPartitionSpec,
@@ -996,6 +996,84 @@ pub mod tests {
             manifest_list_write.close().await.unwrap();
         }
 
+        /// Writes a v3 data manifest with a manifest-level `first_row_id` of 
42,
+        /// so live entries inherit a per-file `first_row_id` on read. 
Upgrades the
+        /// table to v3 first, so the manifest list is read as v3.
+        pub async fn setup_v3_manifest_files(&mut self) {
+            let metadata = TableMetadataBuilder::new_from_metadata(
+                self.table.metadata().clone(),
+                self.table.metadata_location().map(str::to_string),
+            )
+            .upgrade_format_version(FormatVersion::V3)
+            .unwrap()
+            .build()
+            .unwrap()
+            .metadata;
+            self.table = Table::builder()
+                .metadata(metadata)
+                .identifier(self.table.identifier().clone())
+                .file_io(self.table.file_io().clone())
+                
.metadata_location(self.table.metadata_location().unwrap().to_string())
+                .runtime(test_runtime())
+                .build()
+                .unwrap();
+
+            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_v3_data();
+            writer
+                .add_entry(
+                    ManifestEntry::builder()
+                        .status(ManifestStatus::Added)
+                        .data_file(
+                            DataFileBuilder::default()
+                                .partition_spec_id(0)
+                                .content(DataContentType::Data)
+                                .file_path(format!("{}/1.parquet", 
&self.table_location))
+                                .file_format(DataFileFormat::Parquet)
+                                .file_size_in_bytes(parquet_file_size)
+                                .record_count(1)
+                                
.partition(Struct::from_iter([Some(Literal::long(100))]))
+                                .key_metadata(None)
+                                .build()
+                                .unwrap(),
+                        )
+                        .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::v3(
+                manifest_list_writer,
+                current_snapshot.snapshot_id(),
+                current_snapshot.parent_snapshot_id(),
+                current_snapshot.sequence_number(),
+                Some(42),
+            );
+            manifest_list_write
+                .add_manifests(vec![data_file_manifest].into_iter())
+                .unwrap();
+            manifest_list_write.close().await.unwrap();
+        }
+
         pub async fn setup_manifest_files_with_partition_evolution(&mut self) {
             let current_snapshot = 
self.table.metadata().current_snapshot().unwrap();
             let parent_snapshot = current_snapshot
@@ -1862,6 +1940,70 @@ pub mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_plan_files_carries_row_lineage_into_file_scan_task() {
+        let mut fixture = TableTestFixture::new();
+        fixture.setup_manifest_files().await;
+
+        let mut tasks: Vec<_> = fixture
+            .table
+            .scan()
+            .build()
+            .unwrap()
+            .plan_files()
+            .await
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+
+        assert_eq!(tasks.len(), 2);
+        tasks.sort_by_key(|task| task.data_file_path.to_string());
+
+        // The added file inherits the current snapshot's data sequence number,
+        // the existing file keeps the one it was written with.
+        assert_eq!(
+            tasks[0].data_file_path,
+            format!("{}/1.parquet", &fixture.table_location)
+        );
+        assert_eq!(tasks[0].data_sequence_number, Some(1));
+        assert_eq!(
+            tasks[1].data_file_path,
+            format!("{}/3.parquet", &fixture.table_location)
+        );
+        assert_eq!(tasks[1].data_sequence_number, Some(0));
+
+        // first_row_id is a v3 concept; a v2 manifest carries none.
+        assert!(tasks.iter().all(|task| task.first_row_id.is_none()));
+    }
+
+    #[tokio::test]
+    async fn test_plan_files_carries_row_lineage_from_v3_manifest() {
+        let mut fixture = TableTestFixture::new();
+        fixture.setup_v3_manifest_files().await;
+
+        let task = fixture
+            .table
+            .scan()
+            .build()
+            .unwrap()
+            .plan_files()
+            .await
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap()
+            .into_iter()
+            .next()
+            .expect("expected one FileScanTask");
+
+        // The manifest-level first_row_id (42) is inherited onto the entry on
+        // read, then carried onto the task.
+        assert_eq!(task.first_row_id, Some(42));
+        // The data sequence number is threaded through the same v3 read path.
+        assert_eq!(task.data_sequence_number, Some(1));
+    }
+
     #[tokio::test]
     async fn test_filtered_scan_with_dropped_partition_source_column() {
         let mut fixture = TableTestFixture::new();
@@ -2441,6 +2583,8 @@ pub mod tests {
             assert_eq!(task.project_field_ids, deserialized.project_field_ids);
             assert_eq!(task.predicate, deserialized.predicate);
             assert_eq!(task.schema, deserialized.schema);
+            assert_eq!(task.first_row_id, deserialized.first_row_id);
+            assert_eq!(task.data_sequence_number, 
deserialized.data_sequence_number);
         };
 
         // without predicate
@@ -2462,6 +2606,8 @@ pub mod tests {
             .with_project_field_ids(vec![1, 2, 3])
             .with_schema(schema.clone())
             .with_record_count(Some(100))
+            .with_first_row_id(Some(1000))
+            .with_data_sequence_number(Some(5))
             .with_data_file_format(DataFileFormat::Parquet)
             .with_case_sensitive(false)
             .build();
diff --git a/crates/iceberg/src/scan/task.rs b/crates/iceberg/src/scan/task.rs
index 24a603687..e69397b06 100644
--- a/crates/iceberg/src/scan/task.rs
+++ b/crates/iceberg/src/scan/task.rs
@@ -67,6 +67,24 @@ pub struct FileScanTask {
     #[builder(default)]
     pub record_count: Option<u64>,
 
+    /// The first row id assigned to the data file.
+    ///
+    /// Used to derive the `_row_id` metadata column: for a row without an
+    /// explicit `_row_id`, it is this value plus the row's ordinal position.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[builder(default)]
+    pub first_row_id: Option<i64>,
+
+    /// The data sequence number of the file, as opposed to its file sequence
+    /// number: the sequence number preserved when a file is carried forward
+    /// across a rewrite. May be null for an existing entry in a malformed
+    /// manifest that lacks one.
+    ///
+    /// Used to derive the `_last_updated_sequence_number` metadata column.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[builder(default)]
+    pub data_sequence_number: Option<i64>,
+
     /// The data file path corresponding to the task.
     pub data_file_path: String,
 

Reply via email to