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

github-merge-queue[bot] 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 b9d285567 feat(scan): support timestamp-based time travel (#3260)
b9d285567 is described below

commit b9d285567526b384585b1613c76b46ab5ef063dd
Author: Dhruv Arya <[email protected]>
AuthorDate: Wed Oct 21 07:58:40 2026 +0000

    feat(scan): support timestamp-based time travel (#3260)
    
    * feat(scan): support timestamp-based time travel
    
    * test(scan): verify timestamp scans read historical rows
    
    * docs: shorten timestamp time travel docstrings
    
    * fix: refine timestamp selection API and edge-case coverage
    
    * docs: add timestamp time travel changelog entry
    
    * test: consolidate timestamp time travel coverage
    
    * refactor: use a single snapshot selector with last-call-wins semantics
    
    * docs: clarify timestamp history selection and PyIceberg differences
    
    * test: share the Parquet fixture row count
    
    * docs: note Java timestamp ordering compatibility
    
    * docs: clarify minimum timestamp difference from Java
    
    * docs: describe snapshot selection enum
    
    * test: adapt timestamp time travel tests to relocated scan fixture
    
    Co-authored-by: Isaac <[email protected]>
    
    * fix: name the timestamp when an as-of-time snapshot is missing
    
    Co-authored-by: Isaac <[email protected]>
    
    * refactor: spell out the as-of-time tie-break and i64::MIN edge
    
    Co-authored-by: Isaac <[email protected]>
    
    ---------
    
    Co-authored-by: Dhruv Arya <[email protected]>
    Co-authored-by: Isaac <[email protected]>
---
 CHANGELOG.md                          |   4 +
 crates/iceberg/public-api.txt         |   2 +
 crates/iceberg/src/scan/mod.rs        | 456 ++++++++++++++++++++++++++++++++--
 crates/iceberg/src/test_utils/scan.rs |   7 +-
 crates/iceberg/src/util/snapshot.rs   | 125 ++++++++++
 5 files changed, 576 insertions(+), 18 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3076335c9..728b057e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,10 @@ and this project adheres to [Semantic 
Versioning](https://semver.org/).
 
 ## [Unreleased]
 
+### Added
+
+* Support timestamp-based time travel through `TableScanBuilder::as_of_time` 
and `snapshot_id_as_of_time`.
+
 ### Breaking Changes
 
 * `FileWrite::close`, `EncryptedOutputFile::write`, 
`ManifestListWriter::close`, and `PuffinWriter::close` now return 
`Result<io::FileMetadata>` instead of `Result<()>`. The returned size is the 
on-disk size, including encryption overhead. Custom `FileWrite` implementations 
must return the number of bytes stored.
diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index 9d65474ad..87fd3943c 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -1438,6 +1438,7 @@ impl core::fmt::Debug for iceberg::scan::TableScan
 pub fn iceberg::scan::TableScan::fmt(&self, f: &mut core::fmt::Formatter<'_>) 
-> core::fmt::Result
 pub struct iceberg::scan::TableScanBuilder<'a>
 impl<'a> iceberg::scan::TableScanBuilder<'a>
+pub fn iceberg::scan::TableScanBuilder<'a>::as_of_time(self, timestamp_ms: 
i64) -> Self
 pub fn iceberg::scan::TableScanBuilder<'a>::build(self) -> 
iceberg::Result<iceberg::scan::TableScan>
 pub fn iceberg::scan::TableScanBuilder<'a>::select(self, column_names: impl 
core::iter::traits::collect::IntoIterator<Item = impl alloc::string::ToString>) 
-> Self
 pub fn iceberg::scan::TableScanBuilder<'a>::select_all(self) -> Self
@@ -3514,6 +3515,7 @@ pub mod iceberg::util::location
 pub mod iceberg::util::snapshot
 pub fn iceberg::util::snapshot::ancestors_between(table_metadata: 
&iceberg::spec::TableMetadataRef, latest_snapshot_id: i64, oldest_snapshot_id: 
core::option::Option<i64>) -> impl core::iter::traits::iterator::Iterator<Item 
= iceberg::spec::SnapshotRef> + core::marker::Send
 pub fn iceberg::util::snapshot::ancestors_of(table_metadata: 
&iceberg::spec::TableMetadataRef, snapshot_id: i64) -> impl 
core::iter::traits::iterator::Iterator<Item = iceberg::spec::SnapshotRef> + 
core::marker::Send
+pub fn iceberg::util::snapshot::snapshot_id_as_of_time(table_metadata: 
&iceberg::spec::TableMetadataRef, timestamp_ms: i64) -> iceberg::Result<i64>
 pub mod iceberg::writer
 pub mod iceberg::writer::base_writer
 pub mod iceberg::writer::base_writer::data_file_writer
diff --git a/crates/iceberg/src/scan/mod.rs b/crates/iceberg/src/scan/mod.rs
index 6d23e9470..59a353c5e 100644
--- a/crates/iceberg/src/scan/mod.rs
+++ b/crates/iceberg/src/scan/mod.rs
@@ -47,6 +47,7 @@ use crate::runtime::Runtime;
 use crate::spec::{DataContentType, Schema, SchemaRef, SnapshotRef, 
SortOrderRef, StructType};
 use crate::table::Table;
 use crate::util::available_parallelism;
+use crate::util::snapshot::snapshot_id_as_of_time;
 use crate::{Error, ErrorKind, Result};
 
 /// A stream of arrow [`RecordBatch`]es.
@@ -130,12 +131,18 @@ fn projected_partition_type(
         .map(Some)
 }
 
+/// Selects a snapshot by ID or timestamp.
+enum SnapshotSelection {
+    SnapshotId(i64),
+    AsOfTime(i64),
+}
+
 /// Builder to create table scan.
 pub struct TableScanBuilder<'a> {
     table: &'a Table,
     // Defaults to none which means select all columns
     column_names: Option<Vec<String>>,
-    snapshot_id: Option<i64>,
+    snapshot_selection: Option<SnapshotSelection>,
     batch_size: Option<usize>,
     case_sensitive: bool,
     filter: Option<Predicate>,
@@ -154,7 +161,7 @@ impl<'a> TableScanBuilder<'a> {
         Self {
             table,
             column_names: None,
-            snapshot_id: None,
+            snapshot_selection: None,
             batch_size: None,
             case_sensitive: true,
             filter: None,
@@ -212,10 +219,38 @@ impl<'a> TableScanBuilder<'a> {
     }
 
     /// Set the snapshot to scan. The scan projects the schema that snapshot 
was
-    /// written with. When not set, the scan reads the current snapshot with 
the
-    /// table's current schema.
+    /// written with.
+    ///
+    /// The last call to this or [`Self::as_of_time`] takes precedence.
+    /// If neither is set, the scan reads the current snapshot with the table's
+    /// current schema.
     pub fn snapshot_id(mut self, snapshot_id: i64) -> Self {
-        self.snapshot_id = Some(snapshot_id);
+        self.snapshot_selection = 
Some(SnapshotSelection::SnapshotId(snapshot_id));
+        self
+    }
+
+    /// Select a snapshot at or before a timestamp in milliseconds since the 
Unix epoch.
+    ///
+    /// Uses the table's main history, and projects the schema the selected 
snapshot
+    /// was written with. The last call to this or [`Self::snapshot_id`] takes 
precedence.
+    ///
+    /// ```
+    /// # use iceberg::TableIdent;
+    /// # use iceberg::io::FileIO;
+    /// # use iceberg::table::StaticTable;
+    /// # #[tokio::main]
+    /// # async fn main() -> iceberg::Result<()> {
+    /// # let location = concat!(env!("CARGO_MANIFEST_DIR"), 
"/testdata/example_table_metadata_v2.json");
+    /// # let ident = TableIdent::from_strs(["ns", "t"])?;
+    /// # let table = StaticTable::from_metadata_file(location, ident, 
FileIO::new_with_fs()).await?;
+    /// // The snapshot log has entries at 1515100955770 and 1555100955770.
+    /// let scan = table.scan().as_of_time(1_555_100_955_769).build()?;
+    /// assert_eq!(scan.snapshot().unwrap().snapshot_id(), 
3051729675574597004);
+    /// # Ok(())
+    /// # }
+    /// ```
+    pub fn as_of_time(mut self, timestamp_ms: i64) -> Self {
+        self.snapshot_selection = 
Some(SnapshotSelection::AsOfTime(timestamp_ms));
         self
     }
 
@@ -288,12 +323,29 @@ impl<'a> TableScanBuilder<'a> {
 
     /// Build the table scan.
     pub fn build(self) -> Result<TableScan> {
-        let snapshot = match self.snapshot_id {
-            Some(snapshot_id) => self
+        let selected_snapshot = match self.snapshot_selection {
+            Some(SnapshotSelection::SnapshotId(id)) => {
+                Some((id, format!("Snapshot with id {id} not found")))
+            }
+            Some(SnapshotSelection::AsOfTime(timestamp_ms)) => {
+                let id = snapshot_id_as_of_time(&self.table.metadata_ref(), 
timestamp_ms)?;
+                // History outlives expired snapshots; name the timestamp that 
resolved to `id`.
+                Some((
+                    id,
+                    format!(
+                        "Snapshot with id {id} resolved from timestamp 
{timestamp_ms} ms not found; it may have expired"
+                    ),
+                ))
+            }
+            None => None,
+        };
+        let is_time_travel = selected_snapshot.is_some();
+        let snapshot = match selected_snapshot {
+            Some((snapshot_id, not_found_message)) => self
                 .table
                 .metadata()
                 .snapshot_by_id(snapshot_id)
-                .ok_or_else(|| invalid_data!("Snapshot with id {snapshot_id} 
not found"))?
+                .ok_or_else(|| invalid_data!(not_found_message))?
                 .clone(),
             None => {
                 let Some(current_snapshot_id) = 
self.table.metadata().current_snapshot() else {
@@ -317,9 +369,10 @@ impl<'a> TableScanBuilder<'a> {
 
         // A scan of the current state uses the table's current schema; time 
travel uses the
         // schema its snapshot was written with.
-        let schema = match self.snapshot_id {
-            Some(_) => snapshot.schema(self.table.metadata())?,
-            None => self.table.metadata().current_schema().clone(),
+        let schema = if is_time_travel {
+            snapshot.schema(self.table.metadata())?
+        } else {
+            self.table.metadata().current_schema().clone()
         };
         let field_ids =
             collect_scan_field_ids(&schema, self.column_names.as_deref(), 
self.case_sensitive)?;
@@ -692,14 +745,18 @@ mod tests {
     //! shared tests for the table scan API
 
     use std::collections::HashMap;
+    use std::fs;
+    use std::fs::File;
     use std::sync::Arc;
 
     use arrow_array::cast::AsArray;
     use arrow_array::types::Int32Type;
     use arrow_array::{
-        Array, ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, 
RunArray, StringArray,
+        Array, ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, 
RecordBatch, RunArray,
+        StringArray,
     };
     use futures::{TryStreamExt, stream};
+    use parquet::arrow::ArrowWriter;
     use uuid::Uuid;
 
     use super::resolve_field_id;
@@ -712,14 +769,18 @@ mod tests {
     };
     use crate::scan::{FileScanTask, FileScanTaskDeleteFile};
     use crate::spec::{
-        DataContentType, DataFileFormat, Datum, Literal, MAIN_BRANCH, 
MappedField, NameMapping,
-        NestedField, NullOrder, Operation, PartitionSpec, PrimitiveType, 
Schema, Snapshot,
-        SortDirection, SortField, SortOrder, Struct, Summary, 
TableMetadataBuilder,
+        DataContentType, DataFileBuilder, DataFileFormat, Datum, Literal, 
MAIN_BRANCH,
+        ManifestEntry, ManifestListWriter, ManifestStatus, 
ManifestWriterBuilder, MappedField,
+        NameMapping, NestedField, NullOrder, Operation, PartitionSpec, 
PrimitiveType, Schema,
+        Snapshot, SortDirection, SortField, SortOrder, Struct, Summary, 
TableMetadataBuilder,
         TableProperties, Transform, Type, UnboundPartitionSpec,
     };
     use crate::table::Table;
-    use crate::test_utils::scan::{TableTestFixture, 
assert_last_updated_seq_all};
+    use crate::test_utils::scan::{
+        ROWS_PER_FIXTURE_FILE, TableTestFixture, assert_last_updated_seq_all,
+    };
     use crate::test_utils::test_runtime;
+    use crate::util::snapshot::snapshot_id_as_of_time;
     use crate::{ErrorKind, TableIdent};
 
     #[tokio::test]
@@ -906,6 +967,369 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_table_scan_as_of_time_after_rollback() {
+        for elapsed_ms in [0, 1000] {
+            let table = TableTestFixture::new().table;
+            let mut metadata = table.metadata().clone();
+            let original = metadata.history()[0].snapshot_id;
+            let previous = metadata.history()[1].clone();
+            let rollback_time = previous.timestamp_ms + elapsed_ms;
+            metadata.snapshot_log.push(crate::spec::SnapshotLog {
+                timestamp_ms: rollback_time,
+                snapshot_id: original,
+            });
+            metadata.current_snapshot_id = Some(original);
+            metadata.refs.get_mut(MAIN_BRANCH).unwrap().snapshot_id = original;
+            let table = table.with_metadata(Arc::new(metadata));
+            let scan = table.scan().as_of_time(rollback_time).build().unwrap();
+            // A rollback at the same timestamp keeps the earlier history 
entry.
+            let expected = if elapsed_ms == 0 {
+                previous.snapshot_id
+            } else {
+                original
+            };
+            assert_eq!(scan.snapshot().unwrap().snapshot_id(), expected);
+        }
+    }
+
+    #[test]
+    fn test_table_scan_as_of_time_rejects_empty_history() {
+        let table = TableTestFixture::new_empty().table;
+        assert!(table.scan().build().unwrap().snapshot().is_none());
+        let err = table.scan().as_of_time(0).build().unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("No snapshot history"));
+    }
+
+    #[test]
+    fn test_table_scan_as_of_time_rejects_before_history() {
+        let table = TableTestFixture::new().table;
+        let before = table.metadata().history()[0].timestamp_ms - 1;
+        let err = table.scan().as_of_time(before).build().unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains(&before.to_string()));
+    }
+
+    #[test]
+    fn test_table_scan_as_of_time_rejects_all_qualifying_snapshots_expired() {
+        let table = TableTestFixture::new_with_deep_history().table;
+        let mut metadata = table.metadata().clone();
+        let entry = metadata.history()[3].clone();
+        let expired_ids: Vec<_> = metadata
+            .history()
+            .iter()
+            .filter(|log| log.timestamp_ms <= entry.timestamp_ms)
+            .map(|log| log.snapshot_id)
+            .collect();
+        assert_eq!(expired_ids.len(), 4);
+        for snapshot_id in expired_ids {
+            metadata.snapshots.remove(&snapshot_id);
+        }
+        let table = table.with_metadata(Arc::new(metadata));
+        // History resolution succeeds even though every qualifying snapshot 
expired.
+        assert_eq!(
+            snapshot_id_as_of_time(&table.metadata_ref(), 
entry.timestamp_ms).unwrap(),
+            entry.snapshot_id
+        );
+        assert!(table.scan().build().unwrap().snapshot().is_some());
+        let err = table
+            .scan()
+            .as_of_time(entry.timestamp_ms)
+            .build()
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert_eq!(
+            err.message(),
+            format!(
+                "Snapshot with id {} resolved from timestamp {} ms not found; 
it may have expired",
+                entry.snapshot_id, entry.timestamp_ms
+            )
+        );
+    }
+
+    #[test]
+    fn test_table_scan_last_snapshot_selection_wins() {
+        let table = TableTestFixture::new().table;
+        let first = &table.metadata().history()[0];
+        let last = &table.metadata().history()[1];
+        let cases = [
+            (
+                table
+                    .scan()
+                    .snapshot_id(last.snapshot_id)
+                    .as_of_time(first.timestamp_ms),
+                first.snapshot_id,
+            ),
+            (
+                table
+                    .scan()
+                    .as_of_time(first.timestamp_ms)
+                    .snapshot_id(last.snapshot_id),
+                last.snapshot_id,
+            ),
+            // An overwritten selector is not resolved or validated.
+            (
+                table.scan().snapshot_id(-1).as_of_time(first.timestamp_ms),
+                first.snapshot_id,
+            ),
+            (
+                table
+                    .scan()
+                    .as_of_time(i64::MIN)
+                    .snapshot_id(last.snapshot_id),
+                last.snapshot_id,
+            ),
+            (
+                table
+                    .scan()
+                    .snapshot_id(first.snapshot_id)
+                    .snapshot_id(last.snapshot_id),
+                last.snapshot_id,
+            ),
+            (
+                table
+                    .scan()
+                    .as_of_time(i64::MAX)
+                    .as_of_time(first.timestamp_ms),
+                first.snapshot_id,
+            ),
+        ];
+        for (builder, expected) in cases {
+            let scan = builder.build().unwrap();
+            assert_eq!(scan.snapshot().unwrap().snapshot_id(), expected);
+        }
+    }
+
+    #[test]
+    fn test_table_scan_invalid_last_snapshot_selection() {
+        let table = TableTestFixture::new().table;
+        let first = &table.metadata().history()[0];
+        let cases = [
+            (
+                table
+                    .scan()
+                    .snapshot_id(first.snapshot_id)
+                    .as_of_time(first.timestamp_ms - 1),
+                "No snapshot history",
+            ),
+            (
+                table.scan().as_of_time(first.timestamp_ms).snapshot_id(-1),
+                "Snapshot with id -1 not found",
+            ),
+        ];
+        for (builder, expected) in cases {
+            let err = builder.build().unwrap_err();
+            assert_eq!(err.kind(), ErrorKind::DataInvalid);
+            assert!(err.message().contains(expected));
+        }
+    }
+
+    #[test]
+    fn test_table_scan_as_of_time_uses_snapshot_schema() {
+        for select_current in [false, true] {
+            let table = TableTestFixture::new().table;
+            let mut metadata = table.metadata().clone();
+            let entry = 
metadata.history()[usize::from(select_current)].clone();
+            // Simulate a schema-only update after this snapshot: schema 0 has 
x only,
+            // while the current table schema also contains y and other 
columns.
+            
Arc::make_mut(metadata.snapshots.get_mut(&entry.snapshot_id).unwrap()).schema_id
 =
+                Some(0);
+            let table = table.with_metadata(Arc::new(metadata));
+            let scan = table
+                .scan()
+                .as_of_time(entry.timestamp_ms)
+                .select(["x"])
+                .with_filter(Reference::new("x").greater_than(Datum::long(0)))
+                .build()
+                .unwrap();
+            let context = scan.plan_context.as_ref().unwrap();
+            assert_eq!(context.snapshot_schema.schema_id(), 0);
+            assert!(context.snapshot_bound_predicate.is_some());
+            assert!(
+                table
+                    .scan()
+                    .as_of_time(entry.timestamp_ms)
+                    .select(["y"])
+                    .build()
+                    .is_err()
+            );
+            assert!(
+                table
+                    .scan()
+                    .as_of_time(entry.timestamp_ms)
+                    
.with_filter(Reference::new("y").greater_than(Datum::long(0)))
+                    .build()
+                    .is_err()
+            );
+        }
+    }
+
+    #[test]
+    fn test_table_scan_as_of_time_schema_compatibility() {
+        let table = TableTestFixture::new().table;
+        let entry = table.metadata().history()[0].clone();
+        // Older snapshots may omit schema-id and use the current-schema 
fallback.
+        assert!(
+            table
+                .metadata()
+                .snapshot_by_id(entry.snapshot_id)
+                .unwrap()
+                .schema_id()
+                .is_none()
+        );
+        let scan = 
table.scan().as_of_time(entry.timestamp_ms).build().unwrap();
+        assert_eq!(
+            scan.plan_context.as_ref().unwrap().snapshot_schema,
+            *table.metadata().current_schema()
+        );
+
+        let mut metadata = table.metadata().clone();
+        
Arc::make_mut(metadata.snapshots.get_mut(&entry.snapshot_id).unwrap()).schema_id
 =
+            Some(1234);
+        let table = table.with_metadata(Arc::new(metadata));
+        let err = table
+            .scan()
+            .as_of_time(entry.timestamp_ms)
+            .build()
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("Schema with id 1234 not found"));
+    }
+
+    #[tokio::test]
+    async fn test_table_scan_as_of_time_reads_historical_rows() {
+        let mut fixture = TableTestFixture::new();
+        fixture.setup_manifest_files().await;
+        let entry = fixture.table.metadata().history()[0].clone();
+
+        // The older snapshot has only x, while the current snapshot has eight 
columns.
+        let mut metadata = fixture.table.metadata().clone();
+        
Arc::make_mut(metadata.snapshots.get_mut(&entry.snapshot_id).unwrap()).schema_id
 = Some(0);
+        fixture.table = fixture.table.with_metadata(Arc::new(metadata));
+        let snapshot = fixture
+            .table
+            .metadata()
+            .snapshot_by_id(entry.snapshot_id)
+            .unwrap();
+        let schema = snapshot.schema(fixture.table.metadata()).unwrap();
+        let arrow_schema = 
Arc::new(crate::arrow::schema_to_arrow_schema(&schema).unwrap());
+
+        // Write distinct historical data: x = 100, versus x = 1 in the 
current files.
+        let path = format!("{}/historical.parquet", fixture.table_location);
+        let batch = RecordBatch::try_new(arrow_schema.clone(), 
vec![Arc::new(Int64Array::from(
+            vec![100],
+        ))])
+        .unwrap();
+        let mut writer =
+            ArrowWriter::try_new(File::create(&path).unwrap(), arrow_schema, 
None).unwrap();
+        writer.write(&batch).unwrap();
+        writer.close().unwrap();
+
+        let mut manifest_writer = ManifestWriterBuilder::new(
+            fixture.next_manifest_file(),
+            Some(snapshot.snapshot_id()),
+            schema,
+            fixture
+                .table
+                .metadata()
+                .default_partition_spec()
+                .as_ref()
+                .clone(),
+        )
+        .build_v2_data();
+        manifest_writer
+            .add_entry(
+                ManifestEntry::builder()
+                    .status(ManifestStatus::Added)
+                    .data_file(
+                        DataFileBuilder::default()
+                            .partition_spec_id(0)
+                            .content(DataContentType::Data)
+                            .file_format(DataFileFormat::Parquet)
+                            
.file_size_in_bytes(fs::metadata(&path).unwrap().len())
+                            .file_path(path)
+                            .record_count(1)
+                            
.partition(Struct::from_iter([Some(Literal::long(100))]))
+                            .build()
+                            .unwrap(),
+                    )
+                    .build(),
+            )
+            .unwrap();
+        let manifest = manifest_writer.write_manifest_file().await.unwrap();
+        let output = fixture
+            .table
+            .file_io()
+            .new_output(snapshot.manifest_list())
+            .unwrap()
+            .writer()
+            .await
+            .unwrap();
+        let mut list_writer = ManifestListWriter::v2(
+            output,
+            snapshot.snapshot_id(),
+            snapshot.parent_snapshot_id(),
+            snapshot.sequence_number(),
+        );
+        list_writer.add_manifests([manifest].into_iter()).unwrap();
+        list_writer.close().await.unwrap();
+
+        let table = fixture.table;
+        let [by_time, by_id] = [
+            table.scan().as_of_time(entry.timestamp_ms),
+            table.scan().snapshot_id(entry.snapshot_id),
+        ]
+        .map(|scan| {
+            
scan.with_filter(Reference::new("x").greater_than_or_equal_to(Datum::long(100)))
+                .build()
+                .unwrap()
+        });
+        let current = table.scan().build().unwrap();
+        let mut planned_files = Vec::new();
+        let mut rows = Vec::new();
+        let mut schemas = Vec::new();
+        for scan in [by_time, by_id, current] {
+            let mut tasks: Vec<_> = scan
+                .plan_files()
+                .await
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            tasks.sort_by_key(|task| task.data_file_path().to_string());
+            planned_files.push(tasks);
+            let batches: Vec<_> = 
scan.to_arrow().await.unwrap().try_collect().await.unwrap();
+            assert!(!batches.is_empty());
+            schemas.push(batches[0].schema());
+            let mut values: Vec<i64> = batches
+                .iter()
+                .flat_map(|batch| {
+                    batch
+                        .column_by_name("x")
+                        .unwrap()
+                        .as_any()
+                        .downcast_ref::<Int64Array>()
+                        .unwrap()
+                        .values()
+                        .iter()
+                        .copied()
+                })
+                .collect();
+            values.sort_unstable();
+            rows.push(values);
+        }
+        assert!(!planned_files[0].is_empty());
+        assert_eq!(planned_files[0], planned_files[1]);
+        assert_eq!(rows[0], vec![100]);
+        assert_eq!(rows[0], rows[1]);
+        // The current snapshot contains two live fixture files.
+        assert_eq!(rows[2], vec![1; 2 * ROWS_PER_FIXTURE_FILE]);
+        assert_eq!(schemas[0], schemas[1]);
+        assert_eq!(schemas[0].fields().len(), 1);
+        assert_eq!(schemas[2].fields().len(), 8);
+    }
+
     /// A scan of the current state reads the current snapshot with the table's
     /// current schema, not the older one that snapshot was written with.
     #[test]
diff --git a/crates/iceberg/src/test_utils/scan.rs 
b/crates/iceberg/src/test_utils/scan.rs
index 63f6418ef..2f96e61ad 100644
--- a/crates/iceberg/src/test_utils/scan.rs
+++ b/crates/iceberg/src/test_utils/scan.rs
@@ -49,6 +49,8 @@ use crate::spec::{
 use crate::table::Table;
 use crate::test_utils::test_runtime;
 
+pub(crate) const ROWS_PER_FIXTURE_FILE: usize = 1024;
+
 fn render_template(template: &str, ctx: Value) -> String {
     let mut env = Environment::new();
     env.set_auto_escape_callback(|_| AutoEscape::None);
@@ -293,7 +295,7 @@ impl TableTestFixture {
         }
     }
 
-    fn next_manifest_file(&self) -> OutputFile {
+    pub(crate) fn next_manifest_file(&self) -> OutputFile {
         self.table
             .file_io()
             .new_output(format!(
@@ -647,7 +649,8 @@ impl TableTestFixture {
             Arc::new(arrow_schema::Schema::new(fields))
         };
         // x: [1, 1, 1, 1, ...]
-        let col1 = Arc::new(Int64Array::from_iter_values(vec![1; 1024])) as 
ArrayRef;
+        let col1 =
+            Arc::new(Int64Array::from_iter_values(vec![1; 
ROWS_PER_FIXTURE_FILE])) as ArrayRef;
 
         let mut values = vec![2; 512];
         values.append(vec![3; 200].as_mut());
diff --git a/crates/iceberg/src/util/snapshot.rs 
b/crates/iceberg/src/util/snapshot.rs
index deac53ef8..c5f307d61 100644
--- a/crates/iceberg/src/util/snapshot.rs
+++ b/crates/iceberg/src/util/snapshot.rs
@@ -18,6 +18,7 @@
 use std::collections::HashSet;
 
 use crate::spec::{SnapshotRef, TableMetadataRef};
+use crate::{Error, ErrorKind, Result};
 
 struct Ancestors {
     next: Option<SnapshotRef>,
@@ -76,9 +77,62 @@ pub fn ancestors_between(
     })
 }
 
+/// Resolve the snapshot ID from the main-history entry with the greatest 
timestamp
+/// at or before `timestamp_ms` (milliseconds since the Unix epoch), taking 
the first
+/// entry on ties.
+///
+/// This matches Java's ordering, except that an entry at `i64::MIN` can match 
here
+/// but never matches in Java, which seeds its search with `Long.MIN_VALUE`.
+/// PyIceberg takes the last qualifying log entry, so ties or out-of-order
+/// timestamps can produce different results.
+///
+/// Returns [`ErrorKind::DataInvalid`] if no matching history exists.
+/// The returned snapshot may have expired, so
+/// [`snapshot_by_id`](crate::spec::TableMetadata::snapshot_by_id) can still 
return `None`.
+///
+/// ```
+/// # use iceberg::TableIdent;
+/// # use iceberg::io::FileIO;
+/// # use iceberg::table::StaticTable;
+/// use iceberg::util::snapshot::snapshot_id_as_of_time;
+/// # #[tokio::main]
+/// # async fn main() -> iceberg::Result<()> {
+/// # let location = concat!(env!("CARGO_MANIFEST_DIR"), 
"/testdata/example_table_metadata_v2.json");
+/// # let ident = TableIdent::from_strs(["ns", "t"])?;
+/// # let table = StaticTable::from_metadata_file(location, ident, 
FileIO::new_with_fs()).await?;
+/// let metadata = table.metadata();
+/// // The snapshot log has entries at 1515100955770 and 1555100955770.
+/// let snapshot_id = snapshot_id_as_of_time(&metadata, 1_555_100_955_770)?;
+/// assert_eq!(snapshot_id, 3055729675574597004);
+/// assert!(snapshot_id_as_of_time(&metadata, 1_515_100_955_769).is_err());
+/// # Ok(())
+/// # }
+/// ```
+pub fn snapshot_id_as_of_time(table_metadata: &TableMetadataRef, timestamp_ms: 
i64) -> Result<i64> {
+    table_metadata
+        .history()
+        .iter()
+        .enumerate()
+        .filter(|(_, entry)| entry.timestamp_ms() <= timestamp_ms)
+        .max_by(|(idx_a, a), (idx_b, b)| {
+            a.timestamp_ms()
+                .cmp(&b.timestamp_ms())
+                // Earlier entries win ties, matching Java's timestamp 
selection.
+                .then(idx_b.cmp(idx_a))
+        })
+        .map(|(_, entry)| entry.snapshot_id)
+        .ok_or_else(|| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!("No snapshot history at or before timestamp 
{timestamp_ms} ms"),
+            )
+        })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::spec::SnapshotLog;
     use crate::test_utils::scan::TableTestFixture;
 
     // Five snapshots chained as: S1 (root) -> S2 -> S3 -> S4 -> S5 (current)
@@ -93,6 +147,77 @@ mod tests {
         std::sync::Arc::new(fixture.table.metadata().clone())
     }
 
+    type History = [(i64, i64)];
+
+    fn metadata_with_history(history: &History) -> TableMetadataRef {
+        let mut metadata = metadata().as_ref().clone();
+        metadata.snapshot_log = history
+            .iter()
+            .map(|&(timestamp_ms, snapshot_id)| SnapshotLog {
+                timestamp_ms,
+                snapshot_id,
+            })
+            .collect();
+        metadata.into()
+    }
+
+    #[test]
+    fn test_snapshot_id_as_of_time() {
+        let cases: &[(&History, i64, i64)] = &[
+            (&[(1000, S1), (2000, S2)], 1000, S1),
+            (&[(1000, S1), (2000, S2)], 1500, S1),
+            (&[(1000, S1), (2000, S2)], 2000, S2),
+            (&[(1000, S1), (2000, S2)], 3000, S2),
+            // Rollback records when an existing snapshot becomes current 
again.
+            (&[(1000, S1), (2000, S2), (3000, S1)], 3500, S1),
+            // The first entry wins on ties.
+            (&[(1000, S1), (2000, S2), (2000, S3)], 2000, S2),
+            // Clock skew means the log need not be sorted by timestamp.
+            (&[(1000, S1), (3000, S2), (2500, S3)], 3500, S2),
+            (&[(1000, S1), (3000, S2), (2500, S3)], 2600, S3),
+            (&[(-2000, S1), (-1000, S2)], -1500, S1),
+            // Java returns no match here; see the docstring.
+            (&[(i64::MIN, S1), (0, S2)], i64::MIN, S1),
+            (&[(0, S1), (i64::MAX, S2)], i64::MAX, S2),
+        ];
+        for &(history, timestamp_ms, expected) in cases {
+            let metadata = metadata_with_history(history);
+            assert_eq!(
+                snapshot_id_as_of_time(&metadata, timestamp_ms).unwrap(),
+                expected,
+                "timestamp {timestamp_ms}, history {history:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_snapshot_id_as_of_time_without_history_at_timestamp() {
+        let cases: &[(&History, i64)] = &[
+            (&[], 1000),
+            (&[(1000, S1)], 999),
+            (&[(3000, S1)], 1500), // Expired prefix; S1 is still retained.
+        ];
+        for &(history, timestamp_ms) in cases {
+            let metadata = metadata_with_history(history);
+            assert!(metadata.snapshot_by_id(S1).is_some());
+            let err = snapshot_id_as_of_time(&metadata, 
timestamp_ms).unwrap_err();
+            assert_eq!(err.kind(), ErrorKind::DataInvalid);
+            assert!(err.message().contains(&timestamp_ms.to_string()));
+        }
+    }
+
+    #[test]
+    fn test_snapshot_id_as_of_time_ignores_snapshots_outside_main_history() {
+        let mut metadata = metadata().as_ref().clone();
+        metadata.snapshot_log.truncate(2);
+        // Newer retained snapshots may be staged or belong only to another 
branch.
+        assert!(metadata.snapshot_by_id(S5).is_some());
+        assert_eq!(
+            snapshot_id_as_of_time(&metadata.into(), i64::MAX).unwrap(),
+            S2
+        );
+    }
+
     // --- ancestors_of ---
 
     #[test]

Reply via email to