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 fb86c9a3 [core] Search primary-key vector buckets and files 
concurrently (#556)
fb86c9a3 is described below

commit fb86c9a3fcfa0d6d18bf18b29a8fcad0df551e97
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Jul 22 18:20:05 2026 +0800

    [core] Search primary-key vector buckets and files concurrently (#556)
---
 crates/paimon/src/spec/core_options.rs            |  58 +++
 crates/paimon/src/table/pk_vector_orchestrator.rs | 500 +++++++++++++++++++---
 crates/paimon/src/table/vector_search_builder.rs  |   4 +
 crates/paimon/src/vindex/pkvector/bucket.rs       | 463 +++++++++++++++++---
 4 files changed, 895 insertions(+), 130 deletions(-)

diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index fd4809bc..dd545f19 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -24,6 +24,7 @@ const DATA_EVOLUTION_ENABLED_OPTION: &str = 
"data-evolution.enabled";
 const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
 const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = "global-index.search-mode";
 const GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION: &str = 
"global-index.row-count-per-shard";
+const GLOBAL_INDEX_THREAD_NUM_OPTION: &str = "global-index.thread-num";
 const GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION: &str = 
"global-index.column-update-action";
 const SORTED_INDEX_RECORDS_PER_RANGE_OPTION: &str = 
"sorted-index.records-per-range";
 const BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE_OPTION: &str = 
"btree-index.fallback-scan-max-size";
@@ -114,6 +115,7 @@ const DEFAULT_READ_BATCH_SIZE: usize = 1024;
 const DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION: &str = 
"dynamic-bucket.target-row-num";
 const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
 const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
+const DEFAULT_GLOBAL_INDEX_THREAD_NUM: i64 = 32;
 const DEFAULT_GLOBAL_INDEX_FALLBACK_SCAN_MAX_SIZE: i64 = 256 * 1024 * 1024;
 const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
 pub(crate) const BLOB_FIELD_OPTION: &str = "blob-field";
@@ -574,6 +576,28 @@ impl<'a> CoreOptions<'a> {
         Ok(value)
     }
 
+    /// Maximum number of concurrent tasks for global-index I/O, mirroring Java
+    /// `CoreOptions.GLOBAL_INDEX_THREAD_NUM` (key `global-index.thread-num`,
+    /// default 32). Used as the fan-out limit for the primary-key vector 
search
+    /// (per-bucket and per-exact-file). A value of `1` reproduces strict
+    /// sequential execution. A non-positive value is a misconfiguration and 
fails
+    /// loud rather than being silently clamped.
+    pub fn global_index_thread_num(&self) -> crate::Result<usize> {
+        let value = self
+            .parse_i64_option(GLOBAL_INDEX_THREAD_NUM_OPTION)?
+            .unwrap_or(DEFAULT_GLOBAL_INDEX_THREAD_NUM);
+        if value <= 0 {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Option '{}' must be greater than 0, got: {}",
+                    GLOBAL_INDEX_THREAD_NUM_OPTION, value
+                ),
+                source: None,
+            });
+        }
+        Ok(value as usize)
+    }
+
     pub fn sorted_index_records_per_range(&self) -> crate::Result<i64> {
         let value = self
             .parse_i64_option(SORTED_INDEX_RECORDS_PER_RANGE_OPTION)?
@@ -1235,6 +1259,7 @@ mod tests {
             core_options.global_index_row_count_per_shard().unwrap(),
             100_000
         );
+        assert_eq!(core_options.global_index_thread_num().unwrap(), 32);
         assert_eq!(
             core_options.sorted_index_records_per_range().unwrap(),
             100_000
@@ -1365,6 +1390,39 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_global_index_thread_num_default_and_custom() {
+        // Default mirrors Java (32) when unset.
+        let empty = HashMap::new();
+        let core = CoreOptions::new(&empty);
+        assert_eq!(core.global_index_thread_num().unwrap(), 32);
+
+        // Explicit value is read back verbatim.
+        let options =
+            HashMap::from([(GLOBAL_INDEX_THREAD_NUM_OPTION.to_string(), 
"8".to_string())]);
+        let core = CoreOptions::new(&options);
+        assert_eq!(core.global_index_thread_num().unwrap(), 8);
+    }
+
+    #[test]
+    fn test_global_index_thread_num_rejects_invalid_values() {
+        // Non-positive values are a misconfiguration and must fail loud (never
+        // clamped to 1); an unparsable value is likewise rejected.
+        for value in ["0", "-1", "abc"] {
+            let options = HashMap::from([(
+                GLOBAL_INDEX_THREAD_NUM_OPTION.to_string(),
+                value.to_string(),
+            )]);
+            let core = CoreOptions::new(&options);
+
+            let err = core
+                .global_index_thread_num()
+                .expect_err("invalid thread-num should fail");
+            assert!(matches!(err, crate::Error::DataInvalid { message, .. }
+                    if message.contains(GLOBAL_INDEX_THREAD_NUM_OPTION)));
+        }
+    }
+
     #[test]
     fn test_sorted_index_records_per_range_rejects_invalid_values() {
         for value in ["0", "-1", "abc"] {
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs 
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index 572fa641..99ab5108 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -28,6 +28,9 @@ use std::sync::Arc;
 
 use roaring::RoaringTreemap;
 
+use futures::stream::{self, StreamExt, TryStreamExt};
+use tokio::sync::Semaphore;
+
 use crate::deletion_vector::DeletionVector;
 use crate::spec::BinaryRow;
 use crate::table::data_file_reader::DataFileReader;
@@ -391,6 +394,7 @@ impl PkVectorOrchestrator {
         search_options: &HashMap<String, String>,
         skip_exact_fallback: bool,
         residual_by_split: Option<&[HashMap<String, RoaringTreemap>]>,
+        concurrency: usize,
     ) -> crate::Result<OrchestratorSearchResult> {
         let mut results = self
             .search_candidates_batch(
@@ -404,6 +408,7 @@ impl PkVectorOrchestrator {
                 search_options,
                 skip_exact_fallback,
                 residual_by_split,
+                concurrency,
             )
             .await?;
         debug_assert_eq!(results.len(), 1);
@@ -422,8 +427,20 @@ impl PkVectorOrchestrator {
     /// The residual allow-list depends only on the filter and the plan, not 
the
     /// query vector, so the SAME `residual_by_split` slice is shared across 
every
     /// query. Input-shape validation (positive limits, non-empty query, 
residual
-    /// count) is applied per query / once as appropriate. Kept a sequential
-    /// per-split loop.
+    /// count) is applied per query / once as appropriate.
+    ///
+    /// `concurrency` is the global fan-out limit (Java 
`GLOBAL_INDEX_THREAD_NUM`):
+    /// `1` runs the buckets and their files strictly sequentially, larger 
values fan
+    /// them out with `buffer_unordered`. To match Java's single shared
+    /// `GlobalIndexReadThreadPool`, a single [`Semaphore`] budget of 
`concurrency`
+    /// permits is shared across BOTH the per-bucket and per-exact-file 
fan-outs and
+    /// acquired only around leaf exact-file I/O, so total in-flight exact-file
+    /// searches are capped at `concurrency` overall — not `concurrency` per 
bucket,
+    /// which would allow up to `concurrency * concurrency`. Each bucket's 
per-query
+    /// results feed per-query cross-bucket global Top-K heaps, which are
+    /// order-independent, so the output does not depend on which bucket 
finished
+    /// first; results are collected and merged into the correct per-query 
slot after
+    /// the parallel stage.
     #[allow(clippy::too_many_arguments)]
     #[allow(clippy::type_complexity)]
     pub(crate) async fn search_candidates_batch(
@@ -448,6 +465,7 @@ impl PkVectorOrchestrator {
         search_options: &HashMap<String, String>,
         skip_exact_fallback: bool,
         residual_by_split: Option<&[HashMap<String, RoaringTreemap>]>,
+        concurrency: usize,
     ) -> crate::Result<Vec<OrchestratorSearchResult>> {
         // Eager input-shape validation (Java checkArgument parity).
         if queries.is_empty() {
@@ -478,68 +496,121 @@ impl PkVectorOrchestrator {
             (0..queries.len()).map(|_| Vec::new()).collect();
         let mut exact_candidates: Vec<Vec<PkVectorCandidate>> =
             (0..queries.len()).map(|_| Vec::new()).collect();
-        for (split_index, split) in splits.iter().enumerate() {
-            let dvs = build_bucket_dv_map(&self.reader, split).await?;
-            // Adapt the split-scoped search closure to bucket_search's 
per-file
-            // closure by binding the current split index/split. The coercion 
helper
-            // ties the produced future's borrow to the arguments, which 
closure
-            // inference cannot express on its own.
-            let bucket_search_closure = as_bucket_exact_file_search(
-                |file: &BucketActiveFile,
-                 queries: &[&[f32]],
-                 metric: VectorSearchMetric,
-                 exact_limit: usize,
-                 is_excluded: &(dyn Fn(i64) -> bool + Sync)|
-                 -> ExactFileSearchFuture<'_> {
-                    exact_file_search(
-                        split_index,
-                        split,
-                        file,
-                        queries,
-                        metric,
-                        exact_limit,
-                        is_excluded,
-                    )
-                },
-            );
-            let residual_ranges = residual_by_split.map(|per_split| 
&per_split[split_index]);
-            let per_query = bucket_search_batch(
-                ann_searcher,
-                &split.ann_segments,
-                &split.active_files,
-                &dvs,
-                &bucket_search_closure,
-                queries,
-                metric,
-                indexed_limit,
-                limit,
-                search_options,
-                skip_exact_fallback,
-                residual_ranges,
-            )
-            .await?;
-            if per_query.len() != queries.len() {
-                return Err(data_invalid(format!(
-                    "bucket search returned {} result lists for {} queries",
-                    per_query.len(),
-                    queries.len()
-                )));
+
+        // One shared concurrency budget for the WHOLE search, mirroring 
Java's single
+        // `GlobalIndexReadThreadPool`: the per-bucket and per-exact-file 
fan-outs draw
+        // slots from the SAME N permits, so total in-flight exact-file I/O is 
capped at
+        // N across all buckets and files (not N per bucket, which would allow 
N*N).
+        // Only leaf exact-file work acquires a permit; bucket orchestration 
never holds
+        // one, so it cannot starve leaf work. `concurrency <= 1` takes the 
strictly
+        // sequential path at both levels and needs no budget.
+        let search_budget = (concurrency > 1).then(|| 
Arc::new(Semaphore::new(concurrency)));
+
+        // One lazy future per bucket. Each builds its own DV map + per-file 
search
+        // closure, searches all queries against the bucket, and returns 
per-query
+        // (indexed, exact) candidate lists already tagged with the bucket's
+        // partition/bucket/split_index. The futures are not polled until 
driven
+        // below, so the sequential branch observes buckets in strict split 
order.
+        let per_bucket = splits.iter().enumerate().map(|(split_index, split)| {
+            let search_budget = search_budget.clone();
+            async move {
+                let dvs = build_bucket_dv_map(&self.reader, split).await?;
+                // Adapt the split-scoped search closure to bucket_search's 
per-file
+                // closure by binding the current split index/split. The 
coercion helper
+                // ties the produced future's borrow to the arguments, which 
closure
+                // inference cannot express on its own.
+                let bucket_search_closure = as_bucket_exact_file_search(
+                    |file: &BucketActiveFile,
+                     queries: &[&[f32]],
+                     metric: VectorSearchMetric,
+                     exact_limit: usize,
+                     is_excluded: &(dyn Fn(i64) -> bool + Sync)|
+                     -> ExactFileSearchFuture<'_> {
+                        exact_file_search(
+                            split_index,
+                            split,
+                            file,
+                            queries,
+                            metric,
+                            exact_limit,
+                            is_excluded,
+                        )
+                    },
+                );
+                let residual_ranges = residual_by_split.map(|per_split| 
&per_split[split_index]);
+                let per_query = bucket_search_batch(
+                    ann_searcher,
+                    &split.ann_segments,
+                    &split.active_files,
+                    &dvs,
+                    &bucket_search_closure,
+                    queries,
+                    metric,
+                    indexed_limit,
+                    limit,
+                    search_options,
+                    skip_exact_fallback,
+                    residual_ranges,
+                    concurrency,
+                    search_budget,
+                )
+                .await?;
+                if per_query.len() != queries.len() {
+                    return Err(data_invalid(format!(
+                        "bucket search returned {} result lists for {} 
queries",
+                        per_query.len(),
+                        queries.len()
+                    )));
+                }
+                let tag = |PkVectorSearchResult {
+                               data_file_name,
+                               row_position,
+                               distance,
+                           }: PkVectorSearchResult| PkVectorCandidate {
+                    split_index,
+                    partition: split.data_split.partition().clone(),
+                    bucket: split.data_split.bucket(),
+                    data_file_name,
+                    row_position,
+                    distance,
+                };
+                let tagged: Vec<(Vec<PkVectorCandidate>, 
Vec<PkVectorCandidate>)> = per_query
+                    .into_iter()
+                    .map(|result| {
+                        (
+                            result.indexed.into_iter().map(&tag).collect(),
+                            result.exact.into_iter().map(&tag).collect(),
+                        )
+                    })
+                    .collect();
+                Ok::<_, crate::Error>(tagged)
             }
-            let tag = |PkVectorSearchResult {
-                           data_file_name,
-                           row_position,
-                           distance,
-                       }: PkVectorSearchResult| PkVectorCandidate {
-                split_index,
-                partition: split.data_split.partition().clone(),
-                bucket: split.data_split.bucket(),
-                data_file_name,
-                row_position,
-                distance,
+        });
+
+        // Drive the per-bucket futures. `concurrency == 1` uses a strictly
+        // sequential loop so buckets are searched in split order; larger 
values fan
+        // them out with `buffer_unordered`. Either way each bucket's 
per-query lists
+        // are collected and only then folded into the per-query candidate
+        // accumulators, and the final per-query `global_top_k` is 
order-independent
+        // (deterministic `candidate_cmp`), so the result does not depend on 
bucket
+        // completion order.
+        let collected: Vec<Vec<(Vec<PkVectorCandidate>, 
Vec<PkVectorCandidate>)>> =
+            if concurrency <= 1 {
+                let mut out = Vec::with_capacity(splits.len());
+                for fut in per_bucket {
+                    out.push(fut.await?);
+                }
+                out
+            } else {
+                stream::iter(per_bucket)
+                    .buffer_unordered(concurrency)
+                    .try_collect::<Vec<_>>()
+                    .await?
             };
-            for (query_index, result) in per_query.into_iter().enumerate() {
-                
indexed_candidates[query_index].extend(result.indexed.into_iter().map(&tag));
-                
exact_candidates[query_index].extend(result.exact.into_iter().map(&tag));
+        for tagged in collected {
+            for (query_index, (indexed, exact)) in 
tagged.into_iter().enumerate() {
+                indexed_candidates[query_index].extend(indexed);
+                exact_candidates[query_index].extend(exact);
             }
         }
 
@@ -1240,7 +1311,7 @@ mod e2e_tests {
         );
         let result = orch
             .search_candidates(
-                splits, query, metric, limit, limit, ann, &wrapped, opts, 
false, None,
+                splits, query, metric, limit, limit, ann, &wrapped, opts, 
false, None, 1,
             )
             .await?;
         // Merge the two bounded lists into the best-first survivors the
@@ -1277,6 +1348,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 None,
+                1,
             )
             .await
             .map(|_| ())
@@ -1303,6 +1375,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 None,
+                1,
             )
             .await
             .map(|_| ())
@@ -1647,6 +1720,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 None,
+                1,
             )
             .await
             .unwrap();
@@ -1739,6 +1813,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 Some(&residual_by_split),
+                1,
             )
             .await
             .unwrap();
@@ -1793,6 +1868,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 Some(&residual_by_split),
+                1,
             )
             .await
             .map(|_| ())
@@ -1836,6 +1912,7 @@ mod e2e_tests {
                 &opts,
                 true,
                 None,
+                1,
             )
             .await
             .unwrap();
@@ -1950,6 +2027,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 None,
+                1,
             )
             .await
             .unwrap();
@@ -1966,6 +2044,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 None,
+                1,
             )
             .await
             .unwrap();
@@ -2027,6 +2106,7 @@ mod e2e_tests {
                 &opts,
                 false,
                 None,
+                1,
             )
             .await
             .unwrap();
@@ -2038,4 +2118,298 @@ mod e2e_tests {
         assert_eq!(m1.len(), 1);
         assert_eq!(m1[0].row_position, 0);
     }
+
+    #[tokio::test]
+    async fn search_candidates_bucket_order_is_sequential_at_concurrency_one() 
{
+        // At concurrency == 1 the per-bucket loop must search buckets in 
strict
+        // split order. A split-scoped recording closure captures the visited
+        // split_index sequence; it must match the split order 
deterministically.
+        let table_path = "memory:/pkvo_bucket_order";
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let mut splits = Vec::new();
+        for bucket in 0..4i32 {
+            let bucket_path = format!("{table_path}/bucket-{bucket}");
+            let meta = write_file(&file_io, &bucket_path, "d.mosaic", 
vec![bucket]).await;
+            splits.push(PkVectorSearchSplit {
+                data_split: DataSplitBuilder::new()
+                    .with_snapshot(1)
+                    .with_partition(BinaryRow::new(0))
+                    .with_bucket(bucket)
+                    .with_bucket_path(bucket_path)
+                    .with_total_buckets(4)
+                    .with_data_files(vec![meta])
+                    .build()
+                    .unwrap(),
+                ann_segments: Vec::new(),
+                active_files: vec![active("d.mosaic", 1)],
+            });
+        }
+        let visited = std::sync::Mutex::new(Vec::<usize>::new());
+        let factory = as_split_search(
+            |split_index: usize,
+             _: &PkVectorSearchSplit,
+             file: &BucketActiveFile,
+             queries: &[&[f32]],
+             metric: VectorSearchMetric,
+             exact_limit: usize,
+             is_excluded: &(dyn Fn(i64) -> bool + Sync)|
+             -> ExactFileSearchFuture<'_> {
+                visited.lock().unwrap().push(split_index);
+                let file_name = file.file_name.clone();
+                let query = queries[0].to_vec();
+                Box::pin(async move {
+                    let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 
0.0])]);
+                    Ok(vec![exact_search(
+                        &file_name,
+                        &mut reader,
+                        &query,
+                        metric,
+                        exact_limit,
+                        is_excluded,
+                    )?])
+                })
+            },
+        );
+        let opts = HashMap::new();
+        PkVectorOrchestrator::new(make_reader(file_io, table_path))
+            .search_candidates(
+                &splits,
+                &[0.0, 0.0],
+                VectorSearchMetric::L2,
+                8,
+                8,
+                None,
+                &factory,
+                &opts,
+                false,
+                None,
+                1,
+            )
+            .await
+            .unwrap();
+        assert_eq!(
+            visited.lock().unwrap().as_slice(),
+            &[0usize, 1, 2, 3],
+            "concurrency == 1 must search buckets in strict split order"
+        );
+    }
+
+    #[tokio::test]
+    async fn search_candidates_serial_equals_parallel_with_ties() {
+        // Multiple buckets whose exact candidates share a distance (a tie 
decided by
+        // the cross-bucket candidate_cmp) plus NaN-distance candidates that 
must sort
+        // last. Running at concurrency == 1 and concurrency > 1 must yield 
the same
+        // deterministic best-first survivors regardless of bucket completion 
order.
+        let table_path = "memory:/pkvo_serial_parallel";
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let negative_nan = f32::from_bits(0xffc00000);
+        let mut splits = Vec::new();
+        for bucket in 0..4i32 {
+            let bucket_path = format!("{table_path}/bucket-{bucket}");
+            let meta = write_file(&file_io, &bucket_path, "d.mosaic", 
vec![bucket, bucket]).await;
+            splits.push(PkVectorSearchSplit {
+                data_split: DataSplitBuilder::new()
+                    .with_snapshot(1)
+                    .with_partition(BinaryRow::new(0))
+                    .with_bucket(bucket)
+                    .with_bucket_path(bucket_path)
+                    .with_total_buckets(4)
+                    .with_data_files(vec![meta])
+                    .build()
+                    .unwrap(),
+                ann_segments: Vec::new(),
+                active_files: vec![active("d.mosaic", 2)],
+            });
+        }
+        // Each bucket's exact file yields the same two candidates: pos0 
distance 1.0
+        // (tied across buckets) and pos1 a NaN distance (must sort last). 
Lower
+        // split_index buckets yield more, so they complete last under
+        // buffer_unordered.
+        let factory = as_split_search(
+            move |split_index: usize,
+                  _: &PkVectorSearchSplit,
+                  file: &BucketActiveFile,
+                  _: &[&[f32]],
+                  _: VectorSearchMetric,
+                  _: usize,
+                  _: &(dyn Fn(i64) -> bool + Sync)|
+                  -> ExactFileSearchFuture<'_> {
+                let file_name = file.file_name.clone();
+                let yields = (4 - split_index) * 2;
+                Box::pin(async move {
+                    for _ in 0..yields {
+                        tokio::task::yield_now().await;
+                    }
+                    Ok(vec![vec![
+                        PkVectorSearchResult {
+                            data_file_name: file_name.clone(),
+                            row_position: 0,
+                            distance: 1.0,
+                        },
+                        PkVectorSearchResult {
+                            data_file_name: file_name,
+                            row_position: 1,
+                            distance: negative_nan,
+                        },
+                    ]])
+                })
+            },
+        );
+        let opts = HashMap::new();
+        let serial_result = 
PkVectorOrchestrator::new(make_reader(file_io.clone(), table_path))
+            .search_candidates(
+                &splits,
+                &[0.0, 0.0],
+                VectorSearchMetric::L2,
+                8,
+                8,
+                None,
+                &factory,
+                &opts,
+                false,
+                None,
+                1,
+            )
+            .await
+            .unwrap();
+        let serial: Vec<(i32, i64, bool)> =
+            merge_candidates(serial_result.indexed, serial_result.exact, 8)
+                .iter()
+                .map(|c| (c.bucket, c.row_position, c.distance.is_nan()))
+                .collect();
+        let parallel_result = PkVectorOrchestrator::new(make_reader(file_io, 
table_path))
+            .search_candidates(
+                &splits,
+                &[0.0, 0.0],
+                VectorSearchMetric::L2,
+                8,
+                8,
+                None,
+                &factory,
+                &opts,
+                false,
+                None,
+                4,
+            )
+            .await
+            .unwrap();
+        let parallel: Vec<(i32, i64, bool)> =
+            merge_candidates(parallel_result.indexed, parallel_result.exact, 8)
+                .iter()
+                .map(|c| (c.bucket, c.row_position, c.distance.is_nan()))
+                .collect();
+        // Deterministic order: four tied distance-1.0 candidates by bucket 
asc, then
+        // four NaN candidates by bucket asc.
+        assert_eq!(
+            serial,
+            vec![
+                (0, 0, false),
+                (1, 0, false),
+                (2, 0, false),
+                (3, 0, false),
+                (0, 1, true),
+                (1, 1, true),
+                (2, 1, true),
+                (3, 1, true),
+            ]
+        );
+        assert_eq!(
+            parallel, serial,
+            "parallel survivors must equal serial survivors"
+        );
+    }
+
+    #[tokio::test]
+    async fn search_candidates_peak_concurrency_capped_across_buckets() {
+        // Reproduces the reviewer's scenario: 2 buckets x 2 exact files with
+        // concurrency = 2. The per-bucket and per-exact-file fan-outs draw 
from ONE
+        // shared budget, so at most `concurrency` (2) exact-file searches run 
at once
+        // across ALL buckets. Before the shared budget each level capped at N
+        // independently, so 2 buckets x 2 files = 4 file searches ran 
simultaneously.
+        use std::sync::atomic::{AtomicUsize, Ordering};
+        use std::sync::Arc;
+
+        let table_path = "memory:/pkvo_peak_concurrency";
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let mut splits = Vec::new();
+        for bucket in 0..2i32 {
+            let bucket_path = format!("{table_path}/bucket-{bucket}");
+            let meta_a = write_file(&file_io, &bucket_path, "a.mosaic", 
vec![bucket]).await;
+            let meta_b = write_file(&file_io, &bucket_path, "b.mosaic", 
vec![bucket]).await;
+            splits.push(PkVectorSearchSplit {
+                data_split: DataSplitBuilder::new()
+                    .with_snapshot(1)
+                    .with_partition(BinaryRow::new(0))
+                    .with_bucket(bucket)
+                    .with_bucket_path(bucket_path)
+                    .with_total_buckets(2)
+                    .with_data_files(vec![meta_a, meta_b])
+                    .build()
+                    .unwrap(),
+                ann_segments: Vec::new(),
+                active_files: vec![active("a.mosaic", 1), active("b.mosaic", 
1)],
+            });
+        }
+
+        // Shared (current, peak) in-flight counters. Each exact-file search 
increments
+        // on entry (after acquiring its budget slot), yields so overlapping 
searches
+        // are observable, then decrements; `peak` is the max simultaneous 
count.
+        let counters = Arc::new((AtomicUsize::new(0), AtomicUsize::new(0)));
+        let counters_in_closure = counters.clone();
+        let factory = as_split_search(
+            move |_: usize,
+                  _: &PkVectorSearchSplit,
+                  file: &BucketActiveFile,
+                  _: &[&[f32]],
+                  _: VectorSearchMetric,
+                  _: usize,
+                  _: &(dyn Fn(i64) -> bool + Sync)|
+                  -> ExactFileSearchFuture<'_> {
+                let counters = counters_in_closure.clone();
+                let file_name = file.file_name.clone();
+                Box::pin(async move {
+                    let current = counters.0.fetch_add(1, Ordering::SeqCst) + 
1;
+                    counters.1.fetch_max(current, Ordering::SeqCst);
+                    for _ in 0..8 {
+                        tokio::task::yield_now().await;
+                    }
+                    counters.0.fetch_sub(1, Ordering::SeqCst);
+                    Ok(vec![vec![PkVectorSearchResult {
+                        data_file_name: file_name,
+                        row_position: 0,
+                        distance: 1.0,
+                    }]])
+                })
+            },
+        );
+
+        let opts = HashMap::new();
+        PkVectorOrchestrator::new(make_reader(file_io, table_path))
+            .search_candidates(
+                &splits,
+                &[0.0],
+                VectorSearchMetric::L2,
+                8,
+                8,
+                None,
+                &factory,
+                &opts,
+                false,
+                None,
+                2,
+            )
+            .await
+            .unwrap();
+
+        let peak = counters.1.load(Ordering::SeqCst);
+        assert!(
+            peak <= 2,
+            "shared budget must cap concurrent exact-file searches at 
concurrency (2); \
+             observed peak {peak} (independent per-level fan-out would reach 
4)"
+        );
+        assert!(
+            peak >= 2,
+            "test must actually exercise cross-bucket overlap; observed peak 
{peak}"
+        );
+    }
 }
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index f14c08e9..9f88558e 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -629,6 +629,9 @@ async fn plan_and_search_pk_candidates_batch(
     // `primary_key_vector_distance_metric` returns a validated name; re-parse 
into
     // the enum for the numeric semantics.
     let metric = 
VectorSearchMetric::parse(&core.primary_key_vector_distance_metric(pk_col)?)?;
+    // Fan-out limit for the per-bucket and per-exact-file search (Java
+    // `GLOBAL_INDEX_THREAD_NUM`); `1` reproduces strictly sequential 
execution.
+    let concurrency = core.global_index_thread_num()?;
     let index_type = core.primary_key_vector_index_type(pk_col)?;
     let field_id = find_field_id_by_name(table.schema().fields(), 
pk_col).ok_or_else(|| {
         crate::Error::DataInvalid {
@@ -862,6 +865,7 @@ async fn plan_and_search_pk_candidates_batch(
             &search_options,
             skip_exact_fallback,
             residual_by_split.as_deref(),
+            concurrency,
         )
         .await?;
 
diff --git a/crates/paimon/src/vindex/pkvector/bucket.rs 
b/crates/paimon/src/vindex/pkvector/bucket.rs
index 56900e07..b946918f 100644
--- a/crates/paimon/src/vindex/pkvector/bucket.rs
+++ b/crates/paimon/src/vindex/pkvector/bucket.rs
@@ -20,6 +20,8 @@ use std::collections::{BinaryHeap, HashMap, HashSet};
 use std::sync::Arc;
 
 use futures::future::BoxFuture;
+use futures::stream::{self, StreamExt, TryStreamExt};
+use tokio::sync::{OwnedSemaphorePermit, Semaphore};
 
 use super::ann::PkVectorAnnSearcher;
 use super::data_invalid;
@@ -117,6 +119,64 @@ fn add_candidate(heap: &mut BinaryHeap<WorstFirst>, 
candidate: PkVectorSearchRes
     }
 }
 
+/// Extract the sole per-query list from a single-query exact-file search 
result,
+/// failing loud if the closure returned no lists.
+fn single_query_result(
+    per_query: Vec<Vec<PkVectorSearchResult>>,
+) -> crate::Result<Vec<PkVectorSearchResult>> {
+    per_query
+        .into_iter()
+        .next()
+        .ok_or_else(|| data_invalid("exact file search returned no per-query 
results"))
+}
+
+/// Verify a multi-query exact-file search returned exactly one list per query,
+/// failing loud on an arity mismatch.
+fn validate_per_query_len(
+    per_query: Vec<Vec<PkVectorSearchResult>>,
+    expected: usize,
+) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
+    if per_query.len() != expected {
+        return Err(data_invalid(format!(
+            "exact file search returned {} result lists for {} queries",
+            per_query.len(),
+            expected
+        )));
+    }
+    Ok(per_query)
+}
+
+/// Build the per-position exclusion predicate for one uncovered exact file: a
+/// physical position is excluded if the deletion vector marks it deleted, or
+/// (when a residual allow-list is present) if it is not in the allow-list. 
Folds
+/// the residual ∩ DV exclusion the exact search applies per row. The returned
+/// closure borrows the allow-list for its lifetime.
+fn position_excluder(
+    dv: Option<Arc<DeletionVector>>,
+    residual_allowed: Option<&roaring::RoaringTreemap>,
+) -> impl Fn(i64) -> bool + Sync + '_ {
+    move |position: i64| -> bool {
+        let dv_deleted = match &dv {
+            Some(dv) => u64::try_from(position)
+                .map(|p| dv.is_deleted(p))
+                .unwrap_or(false),
+            None => false,
+        };
+        if dv_deleted {
+            return true;
+        }
+        match residual_allowed {
+            // No residual restriction: the row is allowed.
+            None => false,
+            // Residual present: exclude positions outside the allow-list.
+            Some(allowed) => match u64::try_from(position) {
+                Ok(p) => !allowed.contains(p),
+                Err(_) => true,
+            },
+        }
+    }
+}
+
 /// Active data files whose rows are already covered by an ANN segment's source
 /// metadata, matched by both file name AND row count. The bucket exact 
fallback
 /// skips these files (an ANN segment already covers their rows), so they never
@@ -145,6 +205,37 @@ pub(crate) fn covered_source_files(
     covered
 }
 
+/// Acquire one slot from the shared global-index search concurrency budget, 
if a
+/// budget is set. The returned guard holds the slot until it is dropped, so 
the
+/// caller must keep it alive for the duration of the leaf exact-file I/O it 
gates.
+///
+/// A `None` budget means the leaf runs ungated (no cap) — the function does 
not
+/// require any particular `concurrency` value; the orchestrator simply passes
+/// `None` on the strictly sequential `concurrency <= 1` path (which needs no
+/// gating). A `Some` budget is a single [`Semaphore`] shared across every 
bucket
+/// and every exact file of one search, so total in-flight exact-file I/O is 
capped
+/// at N regardless of how many buckets and files fan out — mirroring Java's 
single
+/// shared `GlobalIndexReadThreadPool`. Only leaf exact-file work acquires a 
permit;
+/// bucket orchestration never holds one, so it cannot starve leaf work (the 
async
+/// analogue of Java's "start from the caller" note in
+/// `PrimaryKeyVectorRead.searchBuckets`).
+async fn acquire_search_permit(
+    budget: &Option<Arc<Semaphore>>,
+) -> crate::Result<Option<OwnedSemaphorePermit>> {
+    match budget {
+        Some(semaphore) => {
+            let permit = semaphore.clone().acquire_owned().await.map_err(|e| {
+                crate::Error::UnexpectedError {
+                    message: "global-index search concurrency budget was 
closed".to_string(),
+                    source: Some(Box::new(e)),
+                }
+            })?;
+            Ok(Some(permit))
+        }
+        None => Ok(None),
+    }
+}
+
 /// Separately bounded approximate-index and exact-fallback candidates for one
 /// bucket. The approximate list may be over-fetched (for later exact 
reranking)
 /// while the exact-fallback list stays bounded to the caller's final limit.
@@ -189,6 +280,8 @@ pub(crate) async fn bucket_search(
     search_options: &HashMap<String, String>,
     skip_exact_fallback: bool,
     residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
+    concurrency: usize,
+    search_budget: Option<Arc<Semaphore>>,
 ) -> crate::Result<BucketSearchResult> {
     if indexed_limit == 0 {
         return Err(data_invalid("vector search limit must be positive"));
@@ -260,6 +353,9 @@ pub(crate) async fn bucket_search(
     // skips them, so the lazy exact-reader factory is never invoked for those 
files.
     let covered = covered_source_files(ann_segments, active_files);
 
+    // The ANN searcher is synchronous CPU work (no `.await`), so iterating 
segments
+    // sequentially is intentional: fanning it out would need 
`spawn_blocking`. Only
+    // the exact-fallback file reads below (which are async I/O) are 
parallelized.
     for segment in ann_segments {
         // An active ANN source with a mismatched row count is corruption (the
         // ordinal-to-position mapping would be wrong). An inactive source (no
@@ -292,6 +388,11 @@ pub(crate) async fn bucket_search(
     }
 
     if !skip_exact_fallback {
+        // Collect the eligible uncovered files (active-file order preserved) 
with
+        // their per-position exclusion predicate. A file with no 
residual-allowed
+        // rows is skipped without reading.
+        #[allow(clippy::type_complexity)]
+        let mut tasks: Vec<(&BucketActiveFile, Box<dyn Fn(i64) -> bool + 
Sync>)> = Vec::new();
         for file in active_files {
             if covered.contains(&file.file_name) {
                 continue;
@@ -307,41 +408,44 @@ pub(crate) async fn bucket_search(
                 None => None,
             };
             let dv = deletion_vectors.get(&file.file_name).cloned();
-            let is_excluded = move |position: i64| -> bool {
-                let dv_deleted = match &dv {
-                    Some(dv) => u64::try_from(position)
-                        .map(|p| dv.is_deleted(p))
-                        .unwrap_or(false),
-                    None => false,
-                };
-                if dv_deleted {
-                    return true;
-                }
-                match residual_allowed {
-                    // No residual restriction: the row is allowed.
-                    None => false,
-                    // Residual present: exclude positions outside the 
allow-list.
-                    Some(allowed) => match u64::try_from(position) {
-                        Ok(p) => !allowed.contains(p),
-                        Err(_) => true,
-                    },
+            tasks.push((file, Box::new(position_excluder(dv, 
residual_allowed))));
+        }
+
+        // Search each uncovered file for its exact Top-K. The caller passes a
+        // single-query slice and each search returns one per-query list. The
+        // per-file results feed the bounded heap, which is order-independent, 
so
+        // the merge does not depend on which file finished first. 
`concurrency == 1`
+        // takes a plain sequential loop so the file visit order is strictly
+        // deterministic; larger values fan the file searches out with
+        // `buffer_unordered`, each acquiring one slot of the shared 
`search_budget`
+        // so total in-flight exact-file I/O across all buckets is capped at N.
+        let queries: [&[f32]; 1] = [query];
+        let per_file: Vec<Vec<PkVectorSearchResult>> = if concurrency <= 1 {
+            let mut out = Vec::with_capacity(tasks.len());
+            for (file, is_excluded) in &tasks {
+                let per_query =
+                    exact_file_search(file, &queries, metric, exact_limit, 
is_excluded.as_ref())
+                        .await?;
+                out.push(single_query_result(per_query)?);
+            }
+            out
+        } else {
+            stream::iter(tasks.iter().map(|(file, is_excluded)| {
+                let queries = &queries;
+                let budget = search_budget.clone();
+                async move {
+                    let _permit = acquire_search_permit(&budget).await?;
+                    let per_query =
+                        exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref())
+                            .await?;
+                    single_query_result(per_query)
                 }
-            };
-            // Search this one file for its per-query exact Top-K. The caller 
passes
-            // a single-query slice and reads the sole returned list.
-            let queries: [&[f32]; 1] = [query];
-            let per_query = exact_file_search(
-                file,
-                &queries,
-                metric,
-                exact_limit,
-                &is_excluded as &(dyn Fn(i64) -> bool + Sync),
-            )
-            .await?;
-            let results = per_query
-                .into_iter()
-                .next()
-                .ok_or_else(|| data_invalid("exact file search returned no 
per-query results"))?;
+            }))
+            .buffer_unordered(concurrency)
+            .try_collect::<Vec<_>>()
+            .await?
+        };
+        for results in per_file {
             for result in results {
                 add_candidate(&mut exact_heap, result, exact_limit);
             }
@@ -389,6 +493,8 @@ pub(crate) async fn bucket_search_batch(
     search_options: &HashMap<String, String>,
     skip_exact_fallback: bool,
     residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
+    concurrency: usize,
+    search_budget: Option<Arc<Semaphore>>,
 ) -> crate::Result<Vec<BucketSearchResult>> {
     if queries.is_empty() {
         return Err(data_invalid("vector search requires at least one query"));
@@ -410,6 +516,8 @@ pub(crate) async fn bucket_search_batch(
             search_options,
             skip_exact_fallback,
             residual_ranges,
+            concurrency,
+            search_budget,
         )
         .await?;
         return Ok(vec![single]);
@@ -489,6 +597,9 @@ pub(crate) async fn bucket_search_batch(
         files_by_name.keys().map(|name| name.to_string()).collect();
     let covered = covered_source_files(ann_segments, active_files);
 
+    // The ANN searcher is synchronous CPU work (no `.await`), so iterating 
segments
+    // sequentially is intentional: fanning it out would need 
`spawn_blocking`. Only
+    // the exact-fallback file reads below (which are async I/O) are 
parallelized.
     for segment in ann_segments {
         for source in segment.source_meta.source_files() {
             if let Some(active) = files_by_name.get(source.file_name()) {
@@ -528,6 +639,11 @@ pub(crate) async fn bucket_search_batch(
     }
 
     if !skip_exact_fallback {
+        // Eligible uncovered files (active-file order preserved) with their
+        // per-position exclusion predicate; a file with no residual-allowed 
rows is
+        // skipped without reading.
+        #[allow(clippy::type_complexity)]
+        let mut tasks: Vec<(&BucketActiveFile, Box<dyn Fn(i64) -> bool + 
Sync>)> = Vec::new();
         for file in active_files {
             if covered.contains(&file.file_name) {
                 continue;
@@ -540,40 +656,41 @@ pub(crate) async fn bucket_search_batch(
                 None => None,
             };
             let dv = deletion_vectors.get(&file.file_name).cloned();
-            let is_excluded = move |position: i64| -> bool {
-                let dv_deleted = match &dv {
-                    Some(dv) => u64::try_from(position)
-                        .map(|p| dv.is_deleted(p))
-                        .unwrap_or(false),
-                    None => false,
-                };
-                if dv_deleted {
-                    return true;
-                }
-                match residual_allowed {
-                    None => false,
-                    Some(allowed) => match u64::try_from(position) {
-                        Ok(p) => !allowed.contains(p),
-                        Err(_) => true,
-                    },
-                }
-            };
-            // One shared stream per file scores every query into its own heap.
-            let per_query = exact_file_search(
-                file,
-                queries,
-                metric,
-                exact_limit,
-                &is_excluded as &(dyn Fn(i64) -> bool + Sync),
-            )
-            .await?;
-            if per_query.len() != queries.len() {
-                return Err(data_invalid(format!(
-                    "exact file search returned {} result lists for {} 
queries",
-                    per_query.len(),
-                    queries.len()
-                )));
+            tasks.push((file, Box::new(position_excluder(dv, 
residual_allowed))));
+        }
+
+        // One shared stream per file scores every query into its own per-query
+        // list. The per-file lists feed per-query bounded heaps 
(order-independent),
+        // so the fan-in does not depend on which file finished first: collect 
every
+        // file's per-query result, then merge. `concurrency == 1` uses a 
strictly
+        // sequential loop; larger values fan the file searches out with
+        // `buffer_unordered`, each acquiring one slot of the shared 
`search_budget`
+        // so total in-flight exact-file I/O across all buckets is capped at N.
+        let per_file: Vec<Vec<Vec<PkVectorSearchResult>>> = if concurrency <= 
1 {
+            let mut out = Vec::with_capacity(tasks.len());
+            for (file, is_excluded) in &tasks {
+                let per_query =
+                    exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref())
+                        .await?;
+                out.push(validate_per_query_len(per_query, queries.len())?);
             }
+            out
+        } else {
+            stream::iter(tasks.iter().map(|(file, is_excluded)| {
+                let budget = search_budget.clone();
+                async move {
+                    let _permit = acquire_search_permit(&budget).await?;
+                    let per_query =
+                        exact_file_search(file, queries, metric, exact_limit, 
is_excluded.as_ref())
+                            .await?;
+                    validate_per_query_len(per_query, queries.len())
+                }
+            }))
+            .buffer_unordered(concurrency)
+            .try_collect::<Vec<_>>()
+            .await?
+        };
+        for per_query in per_file {
             for (results, heap) in 
per_query.into_iter().zip(exact_heaps.iter_mut()) {
                 for result in results {
                     add_candidate(heap, result, exact_limit);
@@ -777,6 +894,8 @@ mod tests {
             &opts,
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -807,6 +926,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -849,6 +970,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -904,6 +1027,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -967,6 +1092,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1042,6 +1169,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1084,6 +1213,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1110,6 +1241,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1158,6 +1291,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1194,6 +1329,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1221,6 +1358,8 @@ mod tests {
             &HashMap::new(),
             true, // skip_exact_fallback
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1257,6 +1396,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1295,6 +1436,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1322,6 +1465,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1390,6 +1535,8 @@ mod tests {
             &HashMap::new(),
             false,
             Some(&residual),
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1458,6 +1605,8 @@ mod tests {
             &HashMap::new(),
             false,
             Some(&residual),
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1503,6 +1652,8 @@ mod tests {
             &HashMap::new(),
             false,
             Some(&residual),
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1539,6 +1690,8 @@ mod tests {
             &HashMap::new(),
             false,
             Some(&residual),
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1582,6 +1735,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1684,6 +1839,8 @@ mod tests {
                 &opts,
                 false,
                 None,
+                1,
+                None,
             )
             .await
             .unwrap()
@@ -1708,6 +1865,8 @@ mod tests {
             &opts,
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1742,6 +1901,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1800,6 +1961,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap();
@@ -1842,6 +2005,8 @@ mod tests {
             &HashMap::new(),
             false,
             None,
+            1,
+            None,
         )
         .await
         .unwrap_err();
@@ -1851,4 +2016,168 @@ mod tests {
             "the exact-file search closure must not be invoked for a malformed 
batch"
         );
     }
+
+    #[tokio::test]
+    async fn test_exact_file_search_order_is_sequential_at_concurrency_one() {
+        // At concurrency == 1 the per-exact-file loop must visit uncovered 
files in
+        // strict active-file (submission) order. A recording closure captures 
the
+        // visit order; it must match the active-file order deterministically.
+        let calls = std::sync::Mutex::new(Vec::<String>::new());
+        let factory = as_search(
+            |file: &BucketActiveFile,
+             queries: &[&[f32]],
+             metric: VectorSearchMetric,
+             exact_limit: usize,
+             is_excluded: &(dyn Fn(i64) -> bool + Sync)|
+             -> ExactFileSearchFuture<'_> {
+                calls.lock().unwrap().push(file.file_name.clone());
+                let file_name = file.file_name.clone();
+                let query = queries[0].to_vec();
+                Box::pin(async move {
+                    let mut reader = ArrayReader::new(2, vec![Some(vec![1.0, 
0.0])]);
+                    Ok(vec![exact_search(
+                        &file_name,
+                        &mut reader,
+                        &query,
+                        metric,
+                        exact_limit,
+                        is_excluded,
+                    )?])
+                })
+            },
+        );
+        let out = bucket_search(
+            None,
+            &[],
+            &[
+                active("data-a", 1),
+                active("data-b", 1),
+                active("data-c", 1),
+                active("data-d", 1),
+            ],
+            &HashMap::new(),
+            &factory,
+            &[0.0, 0.0],
+            VectorSearchMetric::L2,
+            8,
+            8,
+            &HashMap::new(),
+            false,
+            None,
+            1,
+            None,
+        )
+        .await
+        .unwrap();
+        assert_eq!(out.exact.len(), 4);
+        assert_eq!(
+            calls.lock().unwrap().as_slice(),
+            &[
+                "data-a".to_string(),
+                "data-b".to_string(),
+                "data-c".to_string(),
+                "data-d".to_string(),
+            ],
+            "concurrency == 1 must visit files in strict active-file order"
+        );
+    }
+
+    #[tokio::test]
+    async fn 
test_parallel_exact_files_tie_and_nan_rank_deterministically_out_of_order() {
+        // Two uncovered files whose candidates share a distance (a tie 
decided by
+        // the BEST_FIRST file/position tie-break) plus a NaN-distance 
candidate that
+        // must sort LAST. The futures complete OUT OF ORDER: the 
alphabetically
+        // first file (data-a) yields more times before returning, so under
+        // buffer_unordered(concurrency > 1) data-b completes first. The 
bounded-heap
+        // merge is order-independent, so the final best-first ranking must 
still be
+        // the deterministic BEST_FIRST order (identical to a serial run).
+        let negative_nan = f32::from_bits(0xffc00000);
+        assert!(negative_nan.is_nan());
+        let factory = as_search(
+            move |file: &BucketActiveFile,
+                  queries: &[&[f32]],
+                  _metric: VectorSearchMetric,
+                  exact_limit: usize,
+                  _is_excluded: &(dyn Fn(i64) -> bool + Sync)|
+                  -> ExactFileSearchFuture<'_> {
+                let name = file.file_name.clone();
+                // data-a is submitted first but yields more, so it finishes 
last.
+                let yields = if name == "data-a" { 8 } else { 0 };
+                let _ = (queries, exact_limit);
+                Box::pin(async move {
+                    for _ in 0..yields {
+                        tokio::task::yield_now().await;
+                    }
+                    // Each file returns two candidates: one tied distance 1.0 
and one
+                    // NaN distance that must never outrank a finite candidate.
+                    let results = vec![
+                        PkVectorSearchResult {
+                            data_file_name: name.clone(),
+                            row_position: 0,
+                            distance: 1.0,
+                        },
+                        PkVectorSearchResult {
+                            data_file_name: name.clone(),
+                            row_position: 1,
+                            distance: negative_nan,
+                        },
+                    ];
+                    Ok(vec![results])
+                })
+            },
+        );
+
+        let run = |concurrency: usize| {
+            let factory = &factory;
+            async move {
+                let out = bucket_search(
+                    None,
+                    &[],
+                    &[active("data-a", 2), active("data-b", 2)],
+                    &HashMap::new(),
+                    factory,
+                    &[0.0, 0.0],
+                    VectorSearchMetric::L2,
+                    8,
+                    8,
+                    &HashMap::new(),
+                    false,
+                    None,
+                    concurrency,
+                    None,
+                )
+                .await
+                .unwrap();
+                out.exact
+                    .iter()
+                    .map(|r| {
+                        (
+                            r.data_file_name.clone(),
+                            r.row_position,
+                            r.distance.is_nan(),
+                        )
+                    })
+                    .collect::<Vec<_>>()
+            }
+        };
+
+        let serial = run(1).await;
+        let parallel = run(4).await;
+        // Serial == parallel, and the deterministic BEST_FIRST order is: tied
+        // distance-1.0 candidates first (data-a before data-b by file name), 
then the
+        // NaN-distance candidates last (again data-a before data-b).
+        assert_eq!(
+            serial,
+            vec![
+                ("data-a".to_string(), 0, false),
+                ("data-b".to_string(), 0, false),
+                ("data-a".to_string(), 1, true),
+                ("data-b".to_string(), 1, true),
+            ]
+        );
+        assert_eq!(
+            parallel, serial,
+            "parallel ranking must equal serial ranking"
+        );
+    }
 }

Reply via email to