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 0216923f [rust] Parallelize OR global-index planning across files 
(#1011)
0216923f is described below

commit 0216923f649a4dcf53853cb79ddfd19ee578c8c7
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Oct 3 18:07:00 2026 +0800

    [rust] Parallelize OR global-index planning across files (#1011)
---
 crates/paimon/src/table/global_index_scanner.rs    |  28 ++-
 .../src/table/global_index_scanner/evaluator.rs    |  19 +-
 .../src/table/global_index_scanner/reader.rs       |   5 +
 .../paimon/src/table/global_index_scanner/tests.rs | 235 +++++++++++++++++++++
 4 files changed, 283 insertions(+), 4 deletions(-)

diff --git a/crates/paimon/src/table/global_index_scanner.rs 
b/crates/paimon/src/table/global_index_scanner.rs
index fdd21a4e..09f838c3 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -52,7 +52,9 @@ use std::sync::{Arc, Mutex};
 use tokio::sync::Semaphore;
 
 #[cfg(test)]
-use std::sync::atomic::{AtomicUsize as TestAtomicUsize, Ordering as 
TestOrdering};
+use std::sync::atomic::{
+    AtomicBool as TestAtomicBool, AtomicUsize as TestAtomicUsize, Ordering as 
TestOrdering,
+};
 
 type BoxedCmp = Box<dyn Fn(&[u8], &[u8]) -> Result<Ordering> + Send + Sync>;
 
@@ -63,8 +65,13 @@ const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
 struct QueryIoProbe {
     active: TestAtomicUsize,
     peak: TestAtomicUsize,
+    evaluate_futures: TestAtomicUsize,
     predicate_queries: TestAtomicUsize,
     range_queries: TestAtomicUsize,
+    btree_opens: TestAtomicUsize,
+    pause_on_enter: TestAtomicBool,
+    entered: tokio::sync::Notify,
+    resume: tokio::sync::Notify,
 }
 
 #[cfg(test)]
@@ -73,6 +80,10 @@ impl QueryIoProbe {
         let current = self.active.fetch_add(1, TestOrdering::SeqCst) + 1;
         self.peak.fetch_max(current, TestOrdering::SeqCst);
         let guard = QueryIoProbeGuard { probe: self };
+        if self.pause_on_enter.load(TestOrdering::SeqCst) {
+            self.entered.notify_one();
+            self.resume.notified().await;
+        }
         tokio::task::yield_now().await;
         guard
     }
@@ -118,8 +129,10 @@ pub(crate) struct GlobalIndexScanner {
     coverage_by_field: HashMap<i32, Vec<RowRange>>,
     /// Schema fields for field_id lookup.
     schema_fields: Vec<DataField>,
-    /// Cache of opened BTree readers, keyed by file name.
+    /// Cache of opened BTree readers, keyed by resolved path.
     reader_cache: Mutex<HashMap<String, BTreeIndexReader<BoxedCmp>>>,
+    /// Serialize use of each BTree reader across concurrent predicate 
branches.
+    btree_file_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
     #[cfg(test)]
     query_io_probe: Option<Arc<QueryIoProbe>>,
 }
@@ -319,10 +332,21 @@ impl GlobalIndexScanner {
             coverage_by_field,
             schema_fields: schema_fields.to_vec(),
             reader_cache: Mutex::new(HashMap::new()),
+            btree_file_locks: Mutex::new(HashMap::new()),
             #[cfg(test)]
             query_io_probe: None,
         }))
     }
+
+    fn btree_file_lock(&self, entry: &GlobalIndexEntry) -> 
Arc<tokio::sync::Mutex<()>> {
+        let path = entry.resolved_path(&self.table_path);
+        let mut locks = self.btree_file_locks.lock().unwrap();
+        Arc::clone(
+            locks
+                .entry(path)
+                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
+        )
+    }
 }
 
 /// Create a GlobalIndexScanner and evaluate predicates, returning row ranges.
diff --git a/crates/paimon/src/table/global_index_scanner/evaluator.rs 
b/crates/paimon/src/table/global_index_scanner/evaluator.rs
index b2ef50e5..6af6d379 100644
--- a/crates/paimon/src/table/global_index_scanner/evaluator.rs
+++ b/crates/paimon/src/table/global_index_scanner/evaluator.rs
@@ -82,6 +82,12 @@ impl GlobalIndexScanner {
     /// Evaluate a predicate against the global indexes and return matching 
row ranges.
     /// Returns `None` if the predicate cannot be evaluated by the global 
index.
     pub(super) fn evaluate<'a>(&'a self, predicate: &'a Predicate) -> 
EvaluateFuture<'a> {
+        #[cfg(test)]
+        if let Some(probe) = &self.query_io_probe {
+            probe
+                .evaluate_futures
+                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+        }
         Box::pin(async move {
             match predicate {
                 Predicate::Leaf {
@@ -239,8 +245,12 @@ impl GlobalIndexScanner {
                     let mut all_ranges: Vec<RowRange> = Vec::new();
                     let mut evaluated_field_ids = HashSet::new();
                     let mut indexed_coverage: Option<Vec<RowRange>> = None;
-                    for child in children {
-                        match self.evaluate(child).await? {
+                    let stream = futures::stream::iter(0..children.len())
+                        .map(|index| self.evaluate(&children[index]))
+                        .buffered(self.global_index_thread_num);
+                    futures::pin_mut!(stream);
+                    while let Some(result) = stream.try_next().await? {
+                        match result {
                             Some(child_result) => {
                                 all_ranges.extend(child_result.row_ranges);
                                 
evaluated_field_ids.extend(child_result.evaluated_field_ids);
@@ -563,6 +573,11 @@ impl GlobalIndexScanner {
         let futures =
             query_plans.into_iter().map(|plan| async move {
                 let entry = &entries[plan.entry_idx];
+                let _file_guard = if matches!(entry.index_type, 
GlobalIndexFileKind::BTree) {
+                    Some(self.btree_file_lock(entry).lock_owned().await)
+                } else {
+                    None
+                };
                 let _permit = 
self.query_semaphore.acquire().await.map_err(|error| {
                     Error::UnexpectedError {
                         message: "global-index query concurrency budget was 
closed".to_string(),
diff --git a/crates/paimon/src/table/global_index_scanner/reader.rs 
b/crates/paimon/src/table/global_index_scanner/reader.rs
index e877176f..dec996b1 100644
--- a/crates/paimon/src/table/global_index_scanner/reader.rs
+++ b/crates/paimon/src/table/global_index_scanner/reader.rs
@@ -235,6 +235,11 @@ impl GlobalIndexScanner {
             }
         }
 
+        #[cfg(test)]
+        if let Some(probe) = &self.query_io_probe {
+            probe.btree_opens.fetch_add(1, super::TestOrdering::SeqCst);
+        }
+
         // Open new reader
         let input = self.file_io.new_input(&resolved_path)?;
         let file_size = if entry.file_size > 0 {
diff --git a/crates/paimon/src/table/global_index_scanner/tests.rs 
b/crates/paimon/src/table/global_index_scanner/tests.rs
index c28a59b3..84ebbacd 100644
--- a/crates/paimon/src/table/global_index_scanner/tests.rs
+++ b/crates/paimon/src/table/global_index_scanner/tests.rs
@@ -2831,6 +2831,241 @@ async fn 
test_multi_field_and_shares_query_concurrency_budget() {
     }
 }
 
+#[tokio::test]
+async fn test_or_and_nested_predicates_share_query_budget() {
+    let src = format!(
+        "{}/testdata/btree/btree_int_100_no_compress.bin",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false);
+    let fields: Vec<_> = (0..4)
+        .map(|index| {
+            DataField::new(
+                index + 1,
+                format!("field{}", index + 1),
+                DataType::Int(crate::spec::IntType::new()),
+            )
+        })
+        .collect();
+    let entries: Vec<_> = (0..4)
+        .map(|index| {
+            let name = format!("index_field{index}.bin");
+            std::fs::copy(&src, index_dir.join(&name)).unwrap();
+            make_global_index_entry(&name, index + 1, 0, 99, &meta)
+        })
+        .collect();
+    let leaves: Vec<_> = (0..4)
+        .map(|index| int_eq(&format!("field{}", index + 1), index as usize, 
50))
+        .collect();
+    let predicates = [
+        Predicate::or(leaves.clone()),
+        Predicate::or(vec![
+            Predicate::and(leaves[..2].to_vec()),
+            Predicate::and(leaves[2..].to_vec()),
+        ]),
+    ];
+
+    for predicate in &predicates {
+        for limit in [1, 2, 4] {
+            let mut scanner = GlobalIndexScanner::create(
+                &file_io,
+                &table_path,
+                limit,
+                i64::MAX,
+                i64::MAX,
+                &entries,
+                &fields,
+            )
+            .unwrap()
+            .unwrap();
+            let probe = Arc::new(QueryIoProbe::default());
+            scanner.query_io_probe = Some(Arc::clone(&probe));
+
+            let result = scanner.evaluate(predicate).await.unwrap().unwrap();
+            assert_eq!(result.row_ranges, vec![RowRange::new(25, 25)]);
+            assert_eq!(result.indexed_coverage, vec![RowRange::new(0, 99)]);
+            assert_eq!(result.evaluated_field_ids, HashSet::from([1, 2, 3, 
4]));
+            assert_eq!(probe.peak(), limit);
+            assert_eq!(probe.btree_opens.load(TestOrdering::SeqCst), 4);
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_or_reuses_one_btree_reader_for_same_file() {
+    let (io, path, name, _tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false);
+    let entries = vec![make_global_index_entry(&name, 1, 0, 99, &meta)];
+    let mut scanner = GlobalIndexScanner::create(
+        &io,
+        &path,
+        4,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &int_schema_fields(),
+    )
+    .unwrap()
+    .unwrap();
+    let probe = Arc::new(QueryIoProbe::default());
+    scanner.query_io_probe = Some(Arc::clone(&probe));
+    let predicate = Predicate::or(
+        [10, 20, 30, 40]
+            .map(|value| int_eq("id", 0, value))
+            .to_vec(),
+    );
+
+    let result = scanner.evaluate(&predicate).await.unwrap().unwrap();
+    assert_eq!(
+        result.row_ranges,
+        [5, 10, 15, 20].map(|row| RowRange::new(row, row)).to_vec()
+    );
+    assert_eq!(probe.peak(), 1);
+    assert_eq!(probe.btree_opens.load(TestOrdering::SeqCst), 1);
+}
+
+#[tokio::test]
+async fn test_wide_or_fallback_creates_only_bounded_child_futures() {
+    let (io, path, name, _tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false);
+    let entries = vec![make_global_index_entry(&name, 1, 0, 99, &meta)];
+    let mut scanner = GlobalIndexScanner::create(
+        &io,
+        &path,
+        4,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &int_schema_fields(),
+    )
+    .unwrap()
+    .unwrap();
+    let probe = Arc::new(QueryIoProbe::default());
+    scanner.query_io_probe = Some(Arc::clone(&probe));
+
+    let mut children = vec![int_eq("missing", 1, 50)];
+    children.extend((0..1024).map(|_| int_eq("id", 0, 50)));
+    assert!(scanner
+        .evaluate(&Predicate::or(children))
+        .await
+        .unwrap()
+        .is_none());
+    assert!(probe.evaluate_futures.load(TestOrdering::SeqCst) <= 5);
+}
+
+#[tokio::test]
+async fn test_or_fallback_and_cancel_leave_reader_usable() {
+    let (io, path, name, _tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false);
+    let entries = vec![make_global_index_entry(&name, 1, 0, 99, &meta)];
+    let mut scanner = GlobalIndexScanner::create(
+        &io,
+        &path,
+        2,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &int_schema_fields(),
+    )
+    .unwrap()
+    .unwrap();
+    let probe = Arc::new(QueryIoProbe::default());
+    scanner.query_io_probe = Some(Arc::clone(&probe));
+    let scanner = Arc::new(scanner);
+    let valid = int_eq("id", 0, 50);
+    let unsupported = int_eq("missing", 1, 50);
+
+    for position in 0..3 {
+        let mut children = vec![valid.clone(), valid.clone()];
+        children.insert(position, unsupported.clone());
+        assert!(scanner
+            .evaluate(&Predicate::or(children))
+            .await
+            .unwrap()
+            .is_none());
+    }
+
+    probe.pause_on_enter.store(true, TestOrdering::SeqCst);
+    let entered = probe.entered.notified();
+    let cancelled_scanner = Arc::clone(&scanner);
+    let cancelled_predicate = Predicate::or(vec![valid.clone(), 
valid.clone()]);
+    let task = tokio::spawn(async move { 
cancelled_scanner.evaluate(&cancelled_predicate).await });
+    tokio::time::timeout(std::time::Duration::from_secs(5), entered)
+        .await
+        .unwrap();
+    task.abort();
+    assert!(matches!(task.await, Err(error) if error.is_cancelled()));
+    probe.pause_on_enter.store(false, TestOrdering::SeqCst);
+
+    let result = tokio::time::timeout(std::time::Duration::from_secs(5), 
scanner.evaluate(&valid))
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    assert_eq!(result.row_ranges, vec![RowRange::new(25, 25)]);
+}
+
+#[tokio::test]
+async fn test_or_index_open_error_releases_file_lock_and_budget() {
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false);
+    let entries = vec![make_global_index_entry("late.bin", 1, 0, 99, &meta)];
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        &table_path,
+        2,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &int_schema_fields(),
+    )
+    .unwrap()
+    .unwrap();
+    let predicate = Predicate::or(vec![int_eq("id", 0, 50), int_eq("id", 0, 
60)]);
+    let unsupported = int_eq("unindexed", 1, 50);
+
+    assert!(scanner
+        .evaluate(&Predicate::or(vec![
+            unsupported.clone(),
+            int_eq("id", 0, 50)
+        ]))
+        .await
+        .unwrap()
+        .is_none());
+    assert!(scanner
+        .evaluate(&Predicate::or(vec![int_eq("id", 0, 50), unsupported]))
+        .await
+        .is_err());
+
+    assert!(scanner.evaluate(&predicate).await.is_err());
+    let src = format!(
+        "{}/testdata/btree/btree_int_100_no_compress.bin",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    std::fs::copy(src, index_dir.join("late.bin")).unwrap();
+    let result = tokio::time::timeout(
+        std::time::Duration::from_secs(5),
+        scanner.evaluate(&predicate),
+    )
+    .await
+    .unwrap()
+    .unwrap()
+    .unwrap();
+    assert_eq!(
+        result.row_ranges,
+        vec![RowRange::new(25, 25), RowRange::new(30, 30)]
+    );
+}
+
 /// Regression for the Between+remaining bug in `evaluate_leaf`. When a
 /// native `Between` leaf is paired with another conjunct (e.g. `id >= 0`),
 /// and the file's b-tree key range falls **outside** the Between range

Reply via email to