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 4f5e3abd fix(read): stop BLOB predicate reads at LIMIT (#903)
4f5e3abd is described below

commit 4f5e3abd41ab44c470b6c0ffbf17a29548b47676
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 21 19:10:44 2026 +0800

    fix(read): stop BLOB predicate reads at LIMIT (#903)
---
 crates/paimon/src/table/data_evolution_reader.rs | 96 +++++++++++++++++++++++-
 crates/paimon/tests/rest_catalog_test.rs         | 45 ++++++++++-
 2 files changed, 136 insertions(+), 5 deletions(-)

diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 52de22c5..fb775ae0 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -248,6 +248,12 @@ impl DataEvolutionReader {
     }
 
     fn effective_batch_size(&self) -> Option<usize> {
+        if self.limit.is_some() && self.predicate_needs_blob_resolution() {
+            // A predicate on the resolved payload must inspect candidates in
+            // order. Reading more than one before the quota is updated can
+            // fetch a later, unselected payload (or invalid view reference).
+            return Some(1);
+        }
         match (self.batch_size, self.limit) {
             (Some(size), Some(limit)) if limit > 0 => Some(size.min(limit)),
             (None, Some(limit)) if limit > 0 => Some(limit),
@@ -367,6 +373,26 @@ impl DataEvolutionReader {
         Ok(selected)
     }
 
+    fn predicate_needs_blob_resolution(&self) -> bool {
+        if self.predicates.is_empty() {
+            return false;
+        }
+        let mut resolved_fields = HashSet::new();
+        if self.blob_view_resolve_enabled && self.blob_view_rest_env.is_some() 
{
+            resolved_fields.extend(self.blob_view_fields.iter().cloned());
+        }
+        if !self.blob_as_descriptor {
+            
resolved_fields.extend(self.blob_descriptor_fields.iter().cloned());
+            resolved_fields.extend(
+                self.table_fields
+                    .iter()
+                    .filter(|field| field.data_type().is_blob_file_field())
+                    .map(|field| field.name().to_string()),
+            );
+        }
+        predicates_reference_any_field(&self.predicates, &resolved_fields, 
&self.table_fields)
+    }
+
     pub(crate) fn with_parquet_read_budget(
         mut self,
         parquet_read_budget: Option<Arc<ReadBudget>>,
@@ -3033,6 +3059,54 @@ mod tests {
         assert!(prefix_row_ranges(None, 10, 4, 0).is_empty());
     }
 
+    #[test]
+    fn test_limit_uses_single_candidate_only_for_payload_predicates() {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let fields = vec![
+            DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+            DataField::new(1, "payload".to_string(), 
DataType::Blob(BlobType::new())),
+        ];
+        let predicate_builder = PredicateBuilder::new(&fields);
+        let reader = |predicate, blob_as_descriptor| {
+            DataEvolutionReader::new(
+                file_io.clone(),
+                SchemaManager::new(file_io.clone(), 
"memory:/blob_batch_size".to_string()),
+                1,
+                fields.clone(),
+                fields.clone(),
+                vec![predicate],
+                blob_as_descriptor,
+                HashSet::new(),
+                HashSet::new(),
+                false,
+                None,
+            )
+            .unwrap()
+            .with_batch_size(Some(8))
+            .with_limit(Some(3))
+        };
+
+        assert_eq!(
+            reader(predicate_builder.is_not_null("payload").unwrap(), 
false).effective_batch_size(),
+            Some(1)
+        );
+        assert_eq!(
+            reader(predicate_builder.equal("id", Datum::Int(1)).unwrap(), 
false)
+                .effective_batch_size(),
+            Some(3)
+        );
+        assert_eq!(
+            reader(predicate_builder.is_not_null("payload").unwrap(), 
true).effective_batch_size(),
+            Some(3)
+        );
+        assert_eq!(
+            reader(predicate_builder.is_not_null("payload").unwrap(), false)
+                .with_limit(None)
+                .effective_batch_size(),
+            Some(8)
+        );
+    }
+
     #[tokio::test]
     async fn test_descriptor_columns_resolve_concurrently_and_preserve_order() 
{
         let schema = Arc::new(arrow_schema::Schema::new(vec![
@@ -5185,13 +5259,33 @@ mod tests {
             let no_match = no_match_builder
                 .new_read()
                 .unwrap()
-                .to_arrow(&[split])
+                .to_arrow(std::slice::from_ref(&split))
                 .unwrap()
                 .try_collect::<Vec<_>>()
                 .await
                 .unwrap();
             assert!(collect_int_values(&no_match, "id").is_empty());
 
+            // A predicate on the BLOB value must inspect each candidate, but
+            // the next batch may not fetch row 4 after three matches satisfy
+            // the quota. Its payload has a deliberately invalid checksum.
+            let mut blob_filter_builder = table.new_read_builder();
+            blob_filter_builder.with_limit(3);
+            blob_filter_builder.with_filter(
+                PredicateBuilder::new(table.schema().fields())
+                    .is_not_null("payload")
+                    .unwrap(),
+            );
+            let filtered = blob_filter_builder
+                .new_read()
+                .unwrap()
+                .to_arrow(&[split])
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            assert_eq!(collect_int_values(&filtered, "id"), vec![1, 2, 3]);
+
             // A BLOB-only raw-convertible split must obey the same quota.
             builder.with_projection(&["payload"]).unwrap();
             let raw_batches = builder
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index baa48cfe..d23309da 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -947,9 +947,14 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
     write_batch(
         &view,
         blob_batch(
-            vec![1, 2, 3],
-            vec!["Kept", "Repeated", "Filtered"],
-            vec![kept_ref.clone(), kept_ref, filtered_out_bad_ref],
+            vec![1, 2, 3, 4],
+            vec!["Kept", "Repeated", "Repeated again", "Filtered"],
+            vec![
+                kept_ref.clone(),
+                kept_ref.clone(),
+                kept_ref,
+                filtered_out_bad_ref,
+            ],
         ),
         "view-writer",
     )
@@ -998,7 +1003,7 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
         vec![(1, "Kept".to_string(), Some(b"bob".to_vec()))]
     );
 
-    // LIMIT alone must not resolve the invalid reference in the third row.
+    // LIMIT alone must not resolve the invalid reference in the fourth row.
     // The repeated reference is read from the lookup cache in a later batch.
     let mut limited_builder = rest_view.new_read_builder();
     limited_builder.with_limit(2);
@@ -1018,6 +1023,38 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
             (2, "Repeated".to_string(), Some(b"bob".to_vec())),
         ]
     );
+
+    // A predicate on the resolved view needs candidate lookups. With batches
+    // of two, the final batch must not resolve the invalid fourth reference
+    // after the third match reaches LIMIT.
+    let predicate_view = rest_view.copy_with_options(HashMap::from([(
+        "read.batch-size".to_string(),
+        "2".to_string(),
+    )]));
+    let mut filtered_builder = predicate_view.new_read_builder();
+    filtered_builder.with_limit(3);
+    filtered_builder.with_filter(
+        PredicateBuilder::new(predicate_view.schema().fields())
+            .is_not_null("picture")
+            .unwrap(),
+    );
+    let filtered_plan = filtered_builder.new_scan().plan().await.unwrap();
+    let filtered = filtered_builder
+        .new_read()
+        .unwrap()
+        .to_arrow(filtered_plan.splits())
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    assert_eq!(
+        collect_blob_rows(&filtered),
+        vec![
+            (1, "Kept".to_string(), Some(b"bob".to_vec())),
+            (2, "Repeated".to_string(), Some(b"bob".to_vec())),
+            (3, "Repeated again".to_string(), Some(b"bob".to_vec())),
+        ]
+    );
 }
 
 #[cfg(not(windows))]

Reply via email to