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

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


The following commit(s) were added to refs/heads/main by this push:
     new 0d880959 feat: expose point-query primitives in paimon (#739)
0d880959 is described below

commit 0d880959f86d7c29c5d90d9ebf07dd8ebe1a7424
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Aug 23 18:22:30 2026 +0800

    feat: expose point-query primitives in paimon (#739)
---
 crates/paimon/src/error.rs                   |  2 ++
 crates/paimon/src/spec/blob_descriptor.rs    | 52 ++++++++++++++++++++++------
 crates/paimon/src/table/format_table_scan.rs |  1 +
 crates/paimon/src/table/mod.rs               | 29 ++++++++++++++--
 crates/paimon/src/table/scan_trace.rs        |  7 ++--
 crates/paimon/src/table/snapshot_manager.rs  |  5 +--
 crates/paimon/src/table/source.rs            | 30 ++++++++++++++++
 crates/paimon/src/table/table_scan.rs        |  1 +
 8 files changed, 107 insertions(+), 20 deletions(-)

diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 39969ee8..a8be68f1 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -102,6 +102,8 @@ pub enum Error {
     TableAlreadyExist { full_name: String },
     #[snafu(display("Table {} does not exist.", full_name))]
     TableNotExist { full_name: String },
+    #[snafu(display("Snapshot {} does not exist.", snapshot_id))]
+    SnapshotNotExist { snapshot_id: i64 },
     #[snafu(display("View {} already exists.", full_name))]
     ViewAlreadyExist { full_name: String },
     #[snafu(display("View {} does not exist.", full_name))]
diff --git a/crates/paimon/src/spec/blob_descriptor.rs 
b/crates/paimon/src/spec/blob_descriptor.rs
index f65b293d..8f32f04b 100644
--- a/crates/paimon/src/spec/blob_descriptor.rs
+++ b/crates/paimon/src/spec/blob_descriptor.rs
@@ -70,6 +70,10 @@ impl BlobDescriptor {
         &self.uri
     }
 
+    pub fn version(&self) -> u8 {
+        self.version
+    }
+
     pub fn offset(&self) -> i64 {
         self.offset
     }
@@ -78,7 +82,12 @@ impl BlobDescriptor {
         self.length
     }
 
-    pub(crate) fn range_spec(&self) -> crate::Result<BlobRangeSpec> {
+    /// Validate that this descriptor represents a usable byte range.
+    ///
+    /// A length of `-1` means reading from `offset` to the end of the object.
+    /// Other negative lengths, negative offsets, and overflowing bounded 
ranges
+    /// are rejected.
+    pub fn validate(&self) -> crate::Result<()> {
         if self.offset < 0 {
             return Err(Error::DataInvalid {
                 message: format!(
@@ -98,22 +107,29 @@ impl BlobDescriptor {
             });
         }
 
-        let offset = self.offset as u64;
-        let length = if self.length == -1 {
-            None
-        } else {
-            Some(self.length as u64)
-        };
-        if let Some(length) = length {
-            offset
-                .checked_add(length)
+        if self.length >= 0 {
+            (self.offset as u64)
+                .checked_add(self.length as u64)
                 .ok_or_else(|| Error::DataInvalid {
                     message: format!(
-                        "BlobDescriptor range overflows u64: offset={offset}, 
length={length}"
+                        "BlobDescriptor range overflows u64: offset={}, 
length={}",
+                        self.offset, self.length
                     ),
                     source: None,
                 })?;
         }
+        Ok(())
+    }
+
+    pub(crate) fn range_spec(&self) -> crate::Result<BlobRangeSpec> {
+        self.validate()?;
+
+        let offset = self.offset as u64;
+        let length = if self.length == -1 {
+            None
+        } else {
+            Some(self.length as u64)
+        };
 
         Ok(BlobRangeSpec { offset, length })
     }
@@ -231,6 +247,20 @@ mod tests {
         let bytes = desc.serialize();
         let deserialized = BlobDescriptor::deserialize(&bytes).unwrap();
         assert_eq!(desc, deserialized);
+        assert_eq!(deserialized.version(), CURRENT_VERSION);
+        deserialized.validate().unwrap();
+    }
+
+    #[test]
+    fn test_validate_rejects_invalid_ranges() {
+        let negative_offset = BlobDescriptor::new("file:///tmp/a".to_string(), 
-1, 1);
+        assert!(negative_offset.validate().is_err());
+
+        let negative_length = BlobDescriptor::new("file:///tmp/a".to_string(), 
0, -2);
+        assert!(negative_length.validate().is_err());
+
+        let to_end = BlobDescriptor::new("file:///tmp/a".to_string(), 
i64::MAX, -1);
+        to_end.validate().unwrap();
     }
 
     #[test]
diff --git a/crates/paimon/src/table/format_table_scan.rs 
b/crates/paimon/src/table/format_table_scan.rs
index 224ef509..5a6a00c8 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -64,6 +64,7 @@ impl<'a> FormatTableScan<'a> {
         self.ensure_query_auth_allowed()?;
         let mut trace = ScanTrace::default();
         let plan = self.plan_inner(Some(&mut trace)).await?;
+        trace.planned_data_file_bytes = plan.planned_data_file_bytes();
         Ok((plan, trace))
     }
 
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 31f7be47..163bd0b1 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -434,19 +434,42 @@ impl Table {
     /// schema (the `if let Ok` below swallows them); an invalid selector
     /// still fails later at scan planning.
     pub async fn copy_with_time_travel(&self, extra: HashMap<String, String>) 
-> Result<Self> {
+        self.copy_with_time_travel_mode(extra, false).await
+    }
+
+    /// Like [`Self::copy_with_time_travel`], but propagates selector 
resolution
+    /// failures. Services should use this variant so a missing or unreadable
+    /// snapshot cannot silently fall back to the current schema.
+    pub async fn copy_with_time_travel_strict(
+        &self,
+        extra: HashMap<String, String>,
+    ) -> Result<Self> {
+        self.copy_with_time_travel_mode(extra, true).await
+    }
+
+    async fn copy_with_time_travel_mode(
+        &self,
+        extra: HashMap<String, String>,
+        strict: bool,
+    ) -> Result<Self> {
         let mut table = self.copy_with_options(extra);
         // Reject unimplemented scan options on the merged view before any IO, 
so
         // both table-level and per-read options are covered.
         CoreOptions::new(table.schema().options()).validate_scan_options()?;
         // travel_to_snapshot returns Ok(None) without IO when the merged
         // options contain no selector.
-        if let Ok(Some(snapshot)) = time_travel::travel_to_snapshot(
+        let resolved = time_travel::travel_to_snapshot(
             &table.snapshot_manager(),
             &table.tag_manager(),
             table.schema.options(),
         )
-        .await
-        {
+        .await;
+        let snapshot = if strict {
+            resolved?
+        } else {
+            resolved.ok().flatten()
+        };
+        if let Some(snapshot) = snapshot {
             if snapshot.schema_id() != table.schema.id() {
                 let snapshot_schema = 
table.schema_manager.schema(snapshot.schema_id()).await?;
                 table.schema =
diff --git a/crates/paimon/src/table/scan_trace.rs 
b/crates/paimon/src/table/scan_trace.rs
index 5aec3ac9..45063800 100644
--- a/crates/paimon/src/table/scan_trace.rs
+++ b/crates/paimon/src/table/scan_trace.rs
@@ -53,6 +53,8 @@ pub struct ScanTrace {
     pub splits_after_limit: usize,
     pub final_splits: usize,
     pub final_files: usize,
+    /// Sum of known data-file sizes referenced by the final plan.
+    pub planned_data_file_bytes: u64,
     pub limit: Option<usize>,
 }
 
@@ -99,7 +101,7 @@ impl fmt::Display for ScanTrace {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(
             f,
-            "snapshot={:?}, manifests={}/{}, manifest_row_range_pruned={}, 
entries_read={}, bucket_pruned={}, partition_pruned={}, 
entry_row_range_pruned={}, data_stats_pruned={}, cross_schema_pruned={}, 
split_candidates_built={}, limit_early_stopped={}, splits_before_limit={}, 
splits_after_limit={}, files={}",
+            "snapshot={:?}, manifests={}/{}, manifest_row_range_pruned={}, 
entries_read={}, bucket_pruned={}, partition_pruned={}, 
entry_row_range_pruned={}, data_stats_pruned={}, cross_schema_pruned={}, 
split_candidates_built={}, limit_early_stopped={}, splits_before_limit={}, 
splits_after_limit={}, files={}, data_file_bytes={}",
             self.snapshot_id,
             self.manifest_files_after_partition_pruning,
             self.manifest_files_before_partition_pruning,
@@ -114,7 +116,8 @@ impl fmt::Display for ScanTrace {
             self.limit_early_stopped,
             self.splits_before_limit,
             self.splits_after_limit,
-            self.final_files
+            self.final_files,
+            self.planned_data_file_bytes
         )
     }
 }
diff --git a/crates/paimon/src/table/snapshot_manager.rs 
b/crates/paimon/src/table/snapshot_manager.rs
index cb71b31c..1de8baa9 100644
--- a/crates/paimon/src/table/snapshot_manager.rs
+++ b/crates/paimon/src/table/snapshot_manager.rs
@@ -209,10 +209,7 @@ impl SnapshotManager {
         let snapshot_path = self.snapshot_path(snapshot_id);
         let snap_input = self.file_io.new_input(&snapshot_path)?;
         if !snap_input.exists().await? {
-            return Err(crate::Error::DataInvalid {
-                message: format!("snapshot file does not exist: 
{snapshot_path}"),
-                source: None,
-            });
+            return Err(crate::Error::SnapshotNotExist { snapshot_id });
         }
         let snap_bytes = snap_input.read().await?;
         let snapshot: Snapshot =
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 2c1e39bc..c1e697ec 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -1295,6 +1295,20 @@ impl Plan {
         &self.splits
     }
 
+    /// Sum of data-file bytes referenced by this plan.
+    ///
+    /// Negative file sizes are treated as unknown and do not contribute. The
+    /// result is therefore a lower bound when a connector cannot provide every
+    /// file size. Totals larger than [`u64::MAX`] saturate at that value so
+    /// callers cannot under-count an oversized plan due to integer overflow.
+    pub fn planned_data_file_bytes(&self) -> u64 {
+        self.splits
+            .iter()
+            .flat_map(DataSplit::data_files)
+            .filter_map(|file| u64::try_from(file.file_size).ok())
+            .fold(0, u64::saturating_add)
+    }
+
     /// Consume this plan and return its splits without cloning their file 
metadata.
     #[must_use = "consuming a plan without using its splits drops the planned 
work"]
     pub fn into_splits(self) -> Vec<DataSplit> {
@@ -1390,6 +1404,22 @@ mod tests {
         assert!(Arc::ptr_eq(&data_files, &splits[0].data_files));
     }
 
+    #[test]
+    fn planned_data_file_bytes_saturates_on_overflow() {
+        let mut first = file("a.parquet", 10, Some(0));
+        first.file_size = i64::MAX;
+        let mut second = file("b.parquet", 10, Some(10));
+        second.file_size = i64::MAX;
+        let mut overflow = file("c.parquet", 10, Some(20));
+        overflow.file_size = 2;
+        let mut unknown = file("unknown.parquet", 10, Some(30));
+        unknown.file_size = -1;
+
+        let plan = Plan::new(vec![split(vec![first, second, overflow, 
unknown], true)]);
+
+        assert_eq!(plan.planned_data_file_bytes(), u64::MAX);
+    }
+
     #[test]
     fn data_split_serde_json_round_trip() {
         let split = DataSplit::builder()
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 5c121922..b28915f6 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1101,6 +1101,7 @@ impl<'a> PaimonTableScan<'a> {
                 Some(&mut trace),
             )
             .await?;
+        trace.planned_data_file_bytes = plan.planned_data_file_bytes();
         Ok((plan, trace))
     }
 

Reply via email to