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 8cd11661 perf(vindex): read search indexes through positional ranges 
(#645)
8cd11661 is described below

commit 8cd1166110504f9a882d779cb1bf7643e95de54b
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Aug 3 16:39:15 2026 +0800

    perf(vindex): read search indexes through positional ranges (#645)
---
 Cargo.lock                                       |   1 +
 benchmarks/tpcds/DEPENDENCIES.rust.tsv           |   1 +
 bindings/c/DEPENDENCIES.rust.tsv                 |   1 +
 bindings/go/DEPENDENCIES.rust.tsv                |   1 +
 crates/integration_tests/DEPENDENCIES.rust.tsv   |   1 +
 crates/paimon-rest-server/DEPENDENCIES.rust.tsv  |   1 +
 crates/paimon/Cargo.toml                         |   1 +
 crates/paimon/src/spec/core_options.rs           |   7 +-
 crates/paimon/src/table/vector_search_builder.rs | 155 +++++-
 crates/paimon/src/vindex/executor.rs             | 605 +++++++++++++++++++++
 crates/paimon/src/vindex/mod.rs                  |   2 +
 crates/paimon/src/vindex/range_reader.rs         | 644 +++++++++++++++++++++++
 crates/paimon/src/vindex/reader.rs               | 231 +++++++-
 13 files changed, 1602 insertions(+), 49 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 45b953b4..87cdeca7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4535,6 +4535,7 @@ dependencies = [
  "bytes",
  "chrono",
  "crc32fast",
+ "crossbeam-channel",
  "futures",
  "hex",
  "hmac 0.12.1",
diff --git a/benchmarks/tpcds/DEPENDENCIES.rust.tsv 
b/benchmarks/tpcds/DEPENDENCIES.rust.tsv
index da26dc28..4b76a75b 100644
--- a/benchmarks/tpcds/DEPENDENCIES.rust.tsv
+++ b/benchmarks/tpcds/DEPENDENCIES.rust.tsv
@@ -81,6 +81,7 @@ [email protected]             X                       
                                                X
 [email protected]             X                                               
                        X                                       
 [email protected]              X                                               
                        X                                       
 [email protected]                X                                               
                        X                                       
[email protected]               X                                       
                                X                                       
 [email protected]          X                                               
                        X                                       
 [email protected]         X                                               
                        X                                       
 [email protected]         X                                               
                        X                                       
diff --git a/bindings/c/DEPENDENCIES.rust.tsv b/bindings/c/DEPENDENCIES.rust.tsv
index f196c1cf..244fb110 100644
--- a/bindings/c/DEPENDENCIES.rust.tsv
+++ b/bindings/c/DEPENDENCIES.rust.tsv
@@ -60,6 +60,7 @@ [email protected]             X                       
                                                X
 [email protected]             X                                               
                        X                               
 [email protected]              X                                               
                        X                               
 [email protected]                X                                               
                        X                               
[email protected]               X                                       
                                X                               
 [email protected]          X                                               
                        X                               
 [email protected]         X                                               
                        X                               
 [email protected]         X                                               
                        X                               
diff --git a/bindings/go/DEPENDENCIES.rust.tsv 
b/bindings/go/DEPENDENCIES.rust.tsv
index f196c1cf..244fb110 100644
--- a/bindings/go/DEPENDENCIES.rust.tsv
+++ b/bindings/go/DEPENDENCIES.rust.tsv
@@ -60,6 +60,7 @@ [email protected]             X                       
                                                X
 [email protected]             X                                               
                        X                               
 [email protected]              X                                               
                        X                               
 [email protected]                X                                               
                        X                               
[email protected]               X                                       
                                X                               
 [email protected]          X                                               
                        X                               
 [email protected]         X                                               
                        X                               
 [email protected]         X                                               
                        X                               
diff --git a/crates/integration_tests/DEPENDENCIES.rust.tsv 
b/crates/integration_tests/DEPENDENCIES.rust.tsv
index fffee985..07114e66 100644
--- a/crates/integration_tests/DEPENDENCIES.rust.tsv
+++ b/crates/integration_tests/DEPENDENCIES.rust.tsv
@@ -60,6 +60,7 @@ [email protected]             X                       
                                                X
 [email protected]             X                                               
                        X                               
 [email protected]              X                                               
                        X                               
 [email protected]                X                                               
                        X                               
[email protected]               X                                       
                                X                               
 [email protected]          X                                               
                        X                               
 [email protected]         X                                               
                        X                               
 [email protected]         X                                               
                        X                               
diff --git a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv 
b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
index fbfc944b..e068e1d7 100644
--- a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
+++ b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
@@ -63,6 +63,7 @@ [email protected]             X                       
                                                X
 [email protected]             X                                               
                        X                               
 [email protected]              X                                               
                        X                               
 [email protected]                X                                               
                        X                               
[email protected]               X                                       
                                X                               
 [email protected]          X                                               
                        X                               
 [email protected]         X                                               
                        X                               
 [email protected]         X                                               
                        X                               
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index b8db7fa9..22fb72d1 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -97,6 +97,7 @@ arrow-schema = { workspace = true }
 arrow-select = { workspace = true }
 arrow-string = { workspace = true }
 futures = "0.3"
+crossbeam-channel = "0.5"
 tokio-util = { workspace = true, features = ["compat"] }
 parquet = { workspace = true, features = ["async", "zstd", "lz4", "snap"] }
 orc-rust = "0.8.0"
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index e26c6bc3..81d85ed4 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -647,9 +647,10 @@ impl<'a> CoreOptions<'a> {
     /// 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 per-operation fan-out limit for sorted BTree 
and
-    /// bitmap shard reads and for primary-key vector search. A value of `1`
-    /// reproduces strict sequential execution. A non-positive value is a
-    /// misconfiguration and fails loud rather than being silently clamped.
+    /// bitmap shard reads, global-index vector search, and primary-key vector
+    /// search. 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)?
diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index 82183f38..281f77bb 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -50,10 +50,14 @@ use crate::table::{
     find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange, 
Table,
 };
 use crate::vector_search::{GlobalIndexIOMeta, SearchResult, VectorSearch};
+use crate::vindex::executor::{
+    drain_indexed_jobs, ensure_global_index_executor_capacity, 
execute_global_index,
+};
 use crate::vindex::pkvector::ann::VindexAnnSearcher;
 use crate::vindex::pkvector::bucket::{BucketActiveFile, BucketAnnSegment, 
ExactFileSearchFuture};
 use crate::vindex::pkvector::exact::validate_query;
 use crate::vindex::pkvector::metric::VectorSearchMetric;
+use crate::vindex::range_reader::VindexFileReader;
 use crate::vindex::reader::VindexVectorGlobalIndexReader;
 use crate::vindex::{is_vindex_index_type, VindexVectorIndexOptions};
 use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array, 
ListArray, RecordBatch};
@@ -94,6 +98,13 @@ impl VectorIndexBackend {
     }
 }
 
+fn current_tokio_runtime_handle() -> crate::Result<tokio::runtime::Handle> {
+    tokio::runtime::Handle::try_current().map_err(|error| 
crate::Error::UnexpectedError {
+        message: "Vector index range reader requires a Tokio 
runtime".to_string(),
+        source: Some(Box::new(error)),
+    })
+}
+
 pub struct VectorSearchBuilder<'a> {
     table: &'a Table,
     vector_column: Option<String>,
@@ -1412,6 +1423,8 @@ async fn evaluate_batch_vector_search(
 
     let mut merged = vec![SearchResult::empty(); vector_searches.len()];
     if !vector_entries.is_empty() {
+        let concurrency = core_options.global_index_thread_num()?;
+        ensure_global_index_executor_capacity(concurrency);
         let futures: Vec<_> = vector_entries
             .into_iter()
             .map(|entry| {
@@ -1440,39 +1453,102 @@ async fn evaluate_batch_vector_search(
                 let input = evaluation.file_io.new_input(&path);
                 async move {
                     let input = input?;
-                    let bytes = input.read().await.map_err(|e| 
crate::Error::DataInvalid {
-                        message: format!(
-                            "Failed to read {} index file '{}': {}",
-                            backend.error_name(),
-                            file_name,
-                            e
-                        ),
-                        source: None,
-                    })?;
-
+                    let query_count = vector_searches.len();
                     let io_meta =
                         GlobalIndexIOMeta::new(file_name.clone(), file_size, 
index_meta_bytes);
-                    let data = bytes.to_vec();
                     let results = match backend {
                         VectorIndexBackend::Lumina => {
-                            let mut reader = 
LuminaVectorGlobalIndexReader::new(io_meta, options);
-                            reader.visit_batch_vector_search(&vector_searches, 
|_| {
-                                Ok(Cursor::new(data))
-                            })?
+                            let data = input.read().await.map_err(|e| {
+                                crate::Error::DataInvalid {
+                                    message: format!(
+                                        "Failed to read {} index file '{}': 
{}",
+                                        backend.error_name(),
+                                        file_name,
+                                        e
+                                    ),
+                                    source: None,
+                                }
+                            })?;
+                            execute_global_index(
+                                "Lumina global-index batch search task failed",
+                                move || {
+                                    let mut reader =
+                                        
LuminaVectorGlobalIndexReader::new(io_meta, options);
+                                    
reader.visit_batch_vector_search(&vector_searches, |_| {
+                                        Ok(Cursor::new(data))
+                                    })
+                                },
+                            )
+                            .await?
                         }
                         VectorIndexBackend::Vindex => {
-                            let mut reader = 
VindexVectorGlobalIndexReader::new(io_meta, options);
-                            reader.visit_batch_vector_search(&vector_searches, 
|_| {
-                                Ok(Cursor::new(data))
-                            })?
+                            if vector_searches.len() > 1 {
+                                let data = input.read().await.map_err(|e| {
+                                    crate::Error::DataInvalid {
+                                        message: format!(
+                                            "Failed to read vindex index file 
'{}': {}",
+                                            file_name, e
+                                        ),
+                                        source: None,
+                                    }
+                                })?;
+                                execute_global_index(
+                                    "vindex global-index batch search task 
failed",
+                                    move || {
+                                        let mut reader = 
VindexVectorGlobalIndexReader::new(
+                                            io_meta, options,
+                                        );
+                                        
reader.visit_batch_vector_search(&vector_searches, |_| {
+                                            Ok(Cursor::new(data))
+                                        })
+                                    },
+                                )
+                                .await?
+                            } else {
+                                let file_reader = 
input.reader().await.map_err(|e| {
+                                    crate::Error::DataInvalid {
+                                        message: format!(
+                                            "Failed to open vindex file '{}' 
for range reads: {}",
+                                            file_name, e
+                                        ),
+                                        source: None,
+                                    }
+                                })?;
+                                let source = VindexFileReader::new(
+                                    Arc::new(file_reader),
+                                    current_tokio_runtime_handle()?,
+                                    file_size,
+                                    file_name.clone(),
+                                );
+                                execute_global_index(
+                                    "vindex global-index search task failed",
+                                    move || {
+                                        let mut reader = 
VindexVectorGlobalIndexReader::new(
+                                            io_meta, options,
+                                        );
+                                        reader
+                                            
.visit_batch_vector_search(&vector_searches, |_| {
+                                                Ok(source)
+                                            })
+                                            .map_err(|e| 
crate::Error::DataInvalid {
+                                                message: format!(
+                                                    "Failed to read vindex 
index file '{}': {}",
+                                                    file_name, e
+                                                ),
+                                                source: Some(Box::new(e)),
+                                            })
+                                    },
+                                )
+                                .await?
+                            }
                         }
                     };
-                    if results.len() != vector_searches.len() {
+                    if results.len() != query_count {
                         return Err(crate::Error::DataInvalid {
                             message: format!(
                                 "Batch vector search backend returned {} 
results for {} query vectors",
                                 results.len(),
-                                vector_searches.len()
+                                query_count
                             ),
                             source: None,
                         });
@@ -1492,7 +1568,7 @@ async fn evaluate_batch_vector_search(
             })
             .collect();
 
-        let results = futures::future::try_join_all(futures).await?;
+        let results = drain_indexed_jobs(futures.into_iter(), 
concurrency).await?;
         for per_entry in &results {
             for (query_index, result) in per_entry.iter().enumerate() {
                 merged[query_index] = merged[query_index].or(result);
@@ -3514,6 +3590,41 @@ mod tests {
                 .contains("Failed to read vindex index file 'missing.idx'"),
             "unexpected error: {err}"
         );
+        assert!(
+            std::error::Error::source(&err).is_some(),
+            "wrapped vindex read errors should retain their source: {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_single_vindex_outside_tokio_returns_error() {
+        futures::executor::block_on(async {
+            let file_io = 
crate::io::FileIOBuilder::new("memory").build().unwrap();
+            file_io
+                .new_output("memory:///test_table/index/test.idx")
+                .unwrap()
+                .write(bytes::Bytes::from_static(b"index"))
+                .await
+                .unwrap();
+            let fields = vec![make_field(2, "embedding")];
+            let vs = VectorSearch::new(vec![1.0], 10, 
"embedding".to_string()).unwrap();
+            let options = HashMap::new();
+            let entry = make_lumina_entry("test.idx", IVF_FLAT_IDENTIFIER, 
FileKind::Add, 2);
+
+            let err = evaluate_vector_search(
+                eval_context(&file_io, &options, &fields, None),
+                &[entry],
+                &vs,
+            )
+            .await
+            .expect_err("vindex range reads outside Tokio should fail without 
panicking");
+
+            assert!(
+                matches!(err, crate::Error::UnexpectedError { ref message, .. }
+                    if message.contains("requires a Tokio runtime")),
+                "unexpected error: {err:?}"
+            );
+        });
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/vindex/executor.rs 
b/crates/paimon/src/vindex/executor.rs
new file mode 100644
index 00000000..d8947102
--- /dev/null
+++ b/crates/paimon/src/vindex/executor.rs
@@ -0,0 +1,605 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
+use futures::stream::{self, StreamExt};
+use std::any::Any;
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::Duration;
+use tokio::sync::oneshot;
+
+type Job = Box<dyn FnOnce() + Send + 'static>;
+
+const DEFAULT_IO_BOUND_WORKERS: usize = 32;
+const IO_BOUND_WORKERS_PER_CPU: usize = 4;
+const WORKER_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
+
+struct ExecutorState {
+    receiver: Receiver<Job>,
+    max_workers: AtomicUsize,
+    minimum_workers: usize,
+    physical_worker_limit: usize,
+    worker_idle_timeout: Duration,
+    worker_count: AtomicUsize,
+    outstanding_jobs: AtomicUsize,
+    next_worker_id: AtomicUsize,
+}
+
+struct GlobalIndexExecutor {
+    sender: Sender<Job>,
+    state: Arc<ExecutorState>,
+}
+
+#[derive(Default)]
+struct TaskCompletionState {
+    started: bool,
+    cancelled: bool,
+}
+
+struct TaskCompletion {
+    state: Mutex<TaskCompletionState>,
+}
+
+impl TaskCompletion {
+    fn new() -> Self {
+        Self {
+            state: Mutex::new(TaskCompletionState::default()),
+        }
+    }
+
+    fn try_start(&self) -> bool {
+        let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
+        if state.cancelled {
+            return false;
+        }
+        state.started = true;
+        true
+    }
+
+    fn cancel_if_queued(&self) {
+        let mut state = self.state.lock().unwrap_or_else(|error| 
error.into_inner());
+        if !state.started {
+            state.cancelled = true;
+        }
+    }
+}
+
+struct TaskCompletionGuard(Arc<TaskCompletion>);
+
+impl Drop for TaskCompletionGuard {
+    fn drop(&mut self) {
+        self.0.cancel_if_queued();
+    }
+}
+
+impl GlobalIndexExecutor {
+    fn new(max_workers: usize) -> Self {
+        Self::new_with_limits(
+            max_workers,
+            max_physical_worker_count(),
+            WORKER_IDLE_TIMEOUT,
+        )
+    }
+
+    fn new_with_limits(
+        max_workers: usize,
+        physical_worker_limit: usize,
+        worker_idle_timeout: Duration,
+    ) -> Self {
+        let (sender, receiver) = crossbeam_channel::unbounded();
+        let physical_worker_limit = physical_worker_limit.max(1);
+        let max_workers = max_workers.max(1).min(physical_worker_limit);
+        Self {
+            sender,
+            state: Arc::new(ExecutorState {
+                receiver,
+                max_workers: AtomicUsize::new(max_workers),
+                minimum_workers: max_workers,
+                physical_worker_limit,
+                worker_idle_timeout,
+                worker_count: AtomicUsize::new(0),
+                outstanding_jobs: AtomicUsize::new(0),
+                next_worker_id: AtomicUsize::new(0),
+            }),
+        }
+    }
+
+    fn ensure_capacity(&self, max_workers: usize) {
+        let max_workers = 
max_workers.max(1).min(self.state.physical_worker_limit);
+        self.state
+            .max_workers
+            .fetch_max(max_workers, Ordering::SeqCst);
+        // Another query may already have queued work, so raising the 
high-watermark
+        // can immediately make more workers useful even before this caller 
submits.
+        let _ = self.ensure_workers_for_load();
+    }
+
+    fn submit(&self, job: Job) -> crate::Result<()> {
+        self.state.outstanding_jobs.fetch_add(1, Ordering::SeqCst);
+        if let Err(error) = self.ensure_workers_for_load() {
+            self.state.outstanding_jobs.fetch_sub(1, Ordering::SeqCst);
+            return Err(error);
+        }
+        if self.sender.send(job).is_err() {
+            self.state.outstanding_jobs.fetch_sub(1, Ordering::SeqCst);
+            return Err(crate::Error::UnexpectedError {
+                message: "global-index executor stopped before accepting a 
task".to_string(),
+                source: None,
+            });
+        }
+        Ok(())
+    }
+
+    fn ensure_workers_for_load(&self) -> crate::Result<()> {
+        ensure_workers_for_load(&self.state)
+    }
+}
+
+fn ensure_workers_for_load(state: &Arc<ExecutorState>) -> crate::Result<()> {
+    loop {
+        let current = state.worker_count.load(Ordering::SeqCst);
+        let max_workers = state.max_workers.load(Ordering::SeqCst);
+        let outstanding = state.outstanding_jobs.load(Ordering::SeqCst);
+        let target = outstanding.min(max_workers);
+        if current >= target {
+            return Ok(());
+        }
+        if state
+            .worker_count
+            .compare_exchange(current, current + 1, Ordering::SeqCst, 
Ordering::SeqCst)
+            .is_err()
+        {
+            continue;
+        }
+
+        let worker_id = state.next_worker_id.fetch_add(1, Ordering::Relaxed);
+        let worker_state = Arc::clone(state);
+        if let Err(error) = std::thread::Builder::new()
+            .name(format!("paimon-global-index-{worker_id}"))
+            .spawn(move || run_worker(worker_state))
+        {
+            state.worker_count.fetch_sub(1, Ordering::SeqCst);
+            return Err(crate::Error::UnexpectedError {
+                message: format!("failed to start global-index executor 
thread: {error}"),
+                source: Some(Box::new(error)),
+            });
+        }
+    }
+}
+
+fn run_worker(state: Arc<ExecutorState>) {
+    loop {
+        match state.receiver.recv_timeout(state.worker_idle_timeout) {
+            Ok(job) => {
+                let _ = catch_unwind(AssertUnwindSafe(job));
+                state.outstanding_jobs.fetch_sub(1, Ordering::SeqCst);
+            }
+            Err(RecvTimeoutError::Timeout) => {
+                if retire_idle_worker(&state) {
+                    // A submit racing with retirement may have observed the 
old worker
+                    // count. Re-check after decrementing so queued work can 
replace us.
+                    let _ = ensure_workers_for_load(&state);
+                    return;
+                }
+            }
+            Err(RecvTimeoutError::Disconnected) => {
+                state.worker_count.fetch_sub(1, Ordering::SeqCst);
+                return;
+            }
+        }
+    }
+}
+
+fn retire_idle_worker(state: &ExecutorState) -> bool {
+    loop {
+        let current = state.worker_count.load(Ordering::SeqCst);
+        if current <= state.minimum_workers {
+            return false;
+        }
+        if state
+            .worker_count
+            .compare_exchange(current, current - 1, Ordering::SeqCst, 
Ordering::SeqCst)
+            .is_ok()
+        {
+            return true;
+        }
+    }
+}
+
+fn default_worker_count() -> usize {
+    std::thread::available_parallelism()
+        .map(|parallelism| parallelism.get())
+        .unwrap_or(1)
+}
+
+fn max_physical_worker_count() -> usize {
+    default_worker_count()
+        .saturating_mul(IO_BOUND_WORKERS_PER_CPU)
+        .max(DEFAULT_IO_BOUND_WORKERS)
+}
+
+/// Shared global-index executor for synchronous search and range-I/O waits. It
+/// starts at the machine's available parallelism and grows lazily, but keeps 
the
+/// configured logical fan-out separate from a physical cap of four workers per
+/// CPU (and at least the default 32 I/O workers). Growth workers expire after 
the
+/// same one-minute idle interval used by Java's `GlobalIndexReadThreadPool`.
+/// Smaller query limits are enforced by each query's bounded job scheduler.
+fn global_executor() -> &'static GlobalIndexExecutor {
+    static EXECUTOR: OnceLock<GlobalIndexExecutor> = OnceLock::new();
+    EXECUTOR.get_or_init(|| GlobalIndexExecutor::new(default_worker_count()))
+}
+
+pub(crate) fn ensure_global_index_executor_capacity(max_workers: usize) {
+    global_executor().ensure_capacity(max_workers);
+}
+
+/// Runs bounded global-index jobs to completion, restores submission order, 
and
+/// returns the first error by submission index. Dedicated executor jobs 
cannot be
+/// interrupted after they start, so short-circuiting would only hide 
background
+/// work and make the surfaced error depend on completion timing.
+pub(crate) async fn drain_indexed_jobs<T, F>(
+    jobs: impl Iterator<Item = F>,
+    concurrency: usize,
+) -> crate::Result<Vec<T>>
+where
+    F: std::future::Future<Output = crate::Result<T>>,
+{
+    let indexed = jobs
+        .enumerate()
+        .map(|(index, job)| async move { (index, job.await) });
+    let mut collected: Vec<(usize, crate::Result<T>)> = stream::iter(indexed)
+        .buffer_unordered(concurrency.max(1))
+        .collect()
+        .await;
+    collected.sort_by_key(|(index, _)| *index);
+    collected.into_iter().map(|(_, result)| result).collect()
+}
+
+pub(crate) async fn execute_global_index<T, F>(
+    panic_context: &'static str,
+    task: F,
+) -> crate::Result<T>
+where
+    T: Send + 'static,
+    F: FnOnce() -> crate::Result<T> + Send + 'static,
+{
+    execute_on(global_executor(), panic_context, task).await
+}
+
+async fn execute_on<T, F>(
+    executor: &GlobalIndexExecutor,
+    panic_context: &'static str,
+    task: F,
+) -> crate::Result<T>
+where
+    T: Send + 'static,
+    F: FnOnce() -> crate::Result<T> + Send + 'static,
+{
+    let (sender, receiver) = oneshot::channel();
+    let completion = Arc::new(TaskCompletion::new());
+    let task_completion = Arc::clone(&completion);
+    executor.submit(Box::new(move || {
+        if !task_completion.try_start() {
+            return;
+        }
+        let outcome = catch_unwind(AssertUnwindSafe(task));
+        let _ = sender.send(outcome);
+    }))?;
+    // Cancellation marks a queued job so the worker skips it. Started work is
+    // detached like Tokio's spawn_blocking tasks; Drop must not block a 
runtime
+    // thread because the search may still depend on async range I/O from it. 
The
+    // caller runtime must remain driven until that I/O completes; dropping the
+    // runtime cancels the spawned read and releases the worker.
+    let _completion_guard = TaskCompletionGuard(completion);
+
+    let outcome = receiver
+        .await
+        .map_err(|error| crate::Error::UnexpectedError {
+            message: "global-index executor dropped a task result".to_string(),
+            source: Some(Box::new(error)),
+        })?;
+    match outcome {
+        Ok(result) => result,
+        Err(payload) => Err(crate::Error::UnexpectedError {
+            message: format!("{panic_context}: {}", 
panic_message(payload.as_ref())),
+            source: None,
+        }),
+    }
+}
+
+fn panic_message(payload: &(dyn Any + Send)) -> &str {
+    if let Some(message) = payload.downcast_ref::<&str>() {
+        message
+    } else if let Some(message) = payload.downcast_ref::<String>() {
+        message.as_str()
+    } else {
+        "non-string panic payload"
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::sync::atomic::{AtomicBool, AtomicUsize};
+    use std::time::Duration;
+
+    #[tokio::test]
+    async fn executor_respects_worker_limit() {
+        let executor = GlobalIndexExecutor::new(2);
+        let in_flight = Arc::new(AtomicUsize::new(0));
+        let peak = Arc::new(AtomicUsize::new(0));
+        let tasks = (0..8).map(|_| {
+            let in_flight = Arc::clone(&in_flight);
+            let peak = Arc::clone(&peak);
+            execute_on(&executor, "test global-index task failed", move || {
+                let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
+                peak.fetch_max(now, Ordering::SeqCst);
+                std::thread::sleep(Duration::from_millis(20));
+                in_flight.fetch_sub(1, Ordering::SeqCst);
+                Ok(())
+            })
+        });
+
+        futures::future::try_join_all(tasks).await.unwrap();
+        assert!(peak.load(Ordering::SeqCst) <= 2);
+    }
+
+    #[tokio::test]
+    async fn executor_grows_to_requested_capacity() {
+        let executor = Arc::new(GlobalIndexExecutor::new(1));
+        executor.ensure_capacity(3);
+        let (started_sender, mut started_receiver) = 
tokio::sync::mpsc::unbounded_channel();
+        let (release_sender, release_receiver) = crossbeam_channel::bounded(3);
+        let executor_for_tasks = Arc::clone(&executor);
+        let tasks = (0..3).map(move |_| {
+            let executor = Arc::clone(&executor_for_tasks);
+            let started_sender = started_sender.clone();
+            let release_receiver = release_receiver.clone();
+            async move {
+                execute_on(&executor, "test global-index task failed", move || 
{
+                    started_sender.send(()).unwrap();
+                    release_receiver.recv().unwrap();
+                    Ok(())
+                })
+                .await
+            }
+        });
+        let join = tokio::spawn(async move { 
futures::future::try_join_all(tasks).await });
+
+        for _ in 0..3 {
+            tokio::time::timeout(Duration::from_secs(1), 
started_receiver.recv())
+                .await
+                .expect("executor did not grow to requested capacity")
+                .expect("executor stopped before all tasks started");
+        }
+        for _ in 0..3 {
+            release_sender.send(()).unwrap();
+        }
+        join.await.unwrap().unwrap();
+        assert_eq!(executor.state.worker_count.load(Ordering::SeqCst), 3);
+    }
+
+    #[test]
+    fn executor_caps_requested_physical_capacity() {
+        let executor = GlobalIndexExecutor::new_with_limits(1, 3, 
Duration::from_secs(60));
+        executor.ensure_capacity(usize::MAX);
+
+        assert_eq!(executor.state.max_workers.load(Ordering::SeqCst), 3);
+    }
+
+    #[tokio::test]
+    async fn executor_reclaims_idle_growth() {
+        let executor = Arc::new(GlobalIndexExecutor::new_with_limits(
+            1,
+            3,
+            Duration::from_millis(20),
+        ));
+        executor.ensure_capacity(3);
+        let (started_sender, mut started_receiver) = 
tokio::sync::mpsc::unbounded_channel();
+        let (release_sender, release_receiver) = crossbeam_channel::bounded(3);
+        let executor_for_tasks = Arc::clone(&executor);
+        let tasks = (0..3).map(move |_| {
+            let executor = Arc::clone(&executor_for_tasks);
+            let started_sender = started_sender.clone();
+            let release_receiver = release_receiver.clone();
+            async move {
+                execute_on(&executor, "test global-index task failed", move || 
{
+                    started_sender.send(()).unwrap();
+                    release_receiver.recv().unwrap();
+                    Ok(())
+                })
+                .await
+            }
+        });
+        let join = tokio::spawn(async move { 
futures::future::try_join_all(tasks).await });
+
+        for _ in 0..3 {
+            tokio::time::timeout(Duration::from_secs(1), 
started_receiver.recv())
+                .await
+                .expect("executor did not grow before idle reclamation")
+                .expect("executor stopped before all tasks started");
+        }
+        for _ in 0..3 {
+            release_sender.send(()).unwrap();
+        }
+        join.await.unwrap().unwrap();
+
+        tokio::time::timeout(Duration::from_secs(1), async {
+            while executor.state.worker_count.load(Ordering::SeqCst) != 1 {
+                tokio::time::sleep(Duration::from_millis(5)).await;
+            }
+        })
+        .await
+        .expect("executor did not reclaim idle growth workers");
+
+        execute_on(&executor, "test global-index task failed", || Ok(()))
+            .await
+            .unwrap();
+    }
+
+    #[tokio::test]
+    async fn drain_indexed_jobs_bounds_concurrency_and_restores_order() {
+        let in_flight = Arc::new(AtomicUsize::new(0));
+        let peak = Arc::new(AtomicUsize::new(0));
+        let jobs = (0..8).map(|index| {
+            let in_flight = Arc::clone(&in_flight);
+            let peak = Arc::clone(&peak);
+            async move {
+                let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
+                peak.fetch_max(now, Ordering::SeqCst);
+                tokio::time::sleep(Duration::from_millis((8 - index) as 
u64)).await;
+                in_flight.fetch_sub(1, Ordering::SeqCst);
+                Ok(index)
+            }
+        });
+
+        let results = drain_indexed_jobs(jobs, 2).await.unwrap();
+        assert_eq!(results, (0..8).collect::<Vec<_>>());
+        assert_eq!(peak.load(Ordering::SeqCst), 2);
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn cancelling_waiter_does_not_stop_submitted_task() {
+        let (started_sender, started_receiver) = oneshot::channel();
+        let (release_sender, release_receiver) = std::sync::mpsc::channel();
+        let completed = Arc::new(AtomicBool::new(false));
+        let completed_for_task = Arc::clone(&completed);
+        let join = tokio::spawn(async move {
+            execute_global_index("test global-index task failed", move || {
+                let _ = started_sender.send(());
+                release_receiver.recv().unwrap();
+                completed_for_task.store(true, Ordering::SeqCst);
+                Ok(())
+            })
+            .await
+        });
+        started_receiver.await.unwrap();
+
+        let releaser = std::thread::spawn(move || {
+            std::thread::sleep(Duration::from_millis(20));
+            release_sender.send(()).unwrap();
+        });
+        join.abort();
+        assert!(join.await.unwrap_err().is_cancelled());
+        releaser.join().unwrap();
+        assert!(completed.load(Ordering::SeqCst));
+    }
+
+    #[test]
+    fn cancelling_started_search_keeps_current_thread_runtime_live() {
+        let runtime = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .unwrap();
+        runtime.block_on(async {
+            let handle = tokio::runtime::Handle::current();
+            let (started_sender, started_receiver) = oneshot::channel();
+            let completed = Arc::new(AtomicBool::new(false));
+            let completed_for_task = Arc::clone(&completed);
+            let join = tokio::spawn(async move {
+                execute_global_index("test global-index task failed", move || {
+                    let _ = started_sender.send(());
+                    let (sender, receiver) = std::sync::mpsc::sync_channel(1);
+                    handle.spawn(async move {
+                        tokio::task::yield_now().await;
+                        let _ = sender.send(());
+                    });
+                    receiver.recv().unwrap();
+                    completed_for_task.store(true, Ordering::SeqCst);
+                    Ok(())
+                })
+                .await
+            });
+            started_receiver.await.unwrap();
+
+            join.abort();
+            let error = tokio::time::timeout(Duration::from_secs(1), join)
+                .await
+                .expect("cancelling a search blocked the current-thread 
runtime")
+                .unwrap_err();
+            assert!(error.is_cancelled());
+            tokio::time::timeout(Duration::from_secs(1), async {
+                while !completed.load(Ordering::SeqCst) {
+                    tokio::task::yield_now().await;
+                }
+            })
+            .await
+            .expect("detached search did not finish after caller 
cancellation");
+        });
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn cancelling_queued_task_returns_without_waiting_for_queue() {
+        let executor = Arc::new(GlobalIndexExecutor::new(1));
+        let (started_sender, started_receiver) = oneshot::channel();
+        let (release_sender, release_receiver) = std::sync::mpsc::channel();
+        let first_executor = Arc::clone(&executor);
+        let first = tokio::spawn(async move {
+            execute_on(&first_executor, "first task failed", move || {
+                let _ = started_sender.send(());
+                release_receiver.recv().unwrap();
+                Ok(())
+            })
+            .await
+        });
+        started_receiver.await.unwrap();
+
+        let second_ran = Arc::new(AtomicBool::new(false));
+        let second_ran_in_task = Arc::clone(&second_ran);
+        let second_executor = Arc::clone(&executor);
+        let mut second = tokio::spawn(async move {
+            execute_on(&second_executor, "second task failed", move || {
+                second_ran_in_task.store(true, Ordering::SeqCst);
+                Ok(())
+            })
+            .await
+        });
+        tokio::time::timeout(Duration::from_secs(1), async {
+            while executor.state.outstanding_jobs.load(Ordering::SeqCst) < 2 {
+                tokio::task::yield_now().await;
+            }
+        })
+        .await
+        .expect("second task was not queued");
+
+        second.abort();
+        let cancelled_without_waiting = 
tokio::time::timeout(Duration::from_secs(1), &mut second)
+            .await
+            .is_ok();
+
+        release_sender.send(()).unwrap();
+        first.await.unwrap().unwrap();
+        if !cancelled_without_waiting {
+            assert!(second.await.unwrap_err().is_cancelled());
+        }
+        tokio::time::timeout(Duration::from_secs(1), async {
+            while executor.state.outstanding_jobs.load(Ordering::SeqCst) != 0 {
+                tokio::task::yield_now().await;
+            }
+        })
+        .await
+        .expect("cancelled task was not drained from the executor queue");
+
+        assert!(
+            cancelled_without_waiting,
+            "cancelling a queued task waited for the task ahead of it"
+        );
+        assert!(!second_ran.load(Ordering::SeqCst));
+    }
+}
diff --git a/crates/paimon/src/vindex/mod.rs b/crates/paimon/src/vindex/mod.rs
index f6e35ab5..c6db3adf 100644
--- a/crates/paimon/src/vindex/mod.rs
+++ b/crates/paimon/src/vindex/mod.rs
@@ -15,6 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub(crate) mod executor;
+pub(crate) mod range_reader;
 pub mod reader;
 
 pub mod pkvector;
diff --git a/crates/paimon/src/vindex/range_reader.rs 
b/crates/paimon/src/vindex/range_reader.rs
new file mode 100644
index 00000000..1e66472c
--- /dev/null
+++ b/crates/paimon/src/vindex/range_reader.rs
@@ -0,0 +1,644 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::io::FileRead;
+use bytes::Bytes;
+use futures::future::try_join_all;
+use paimon_vindex_core::io::{ReadRequest, SeekRead, SeekReadCapabilities};
+use std::io;
+use std::ops::Range;
+use std::sync::{mpsc, Arc};
+
+const SCALAR_READ_MAX: usize = 64;
+const SCALAR_READ_AHEAD: u64 = 64 * 1024;
+const RANGE_COALESCE_GAP: u64 = 16 * 1024;
+const RANGE_READ_CONCURRENCY: usize = 32;
+
+struct CachedRange {
+    start: u64,
+    data: Bytes,
+}
+
+impl CachedRange {
+    fn end(&self) -> u64 {
+        self.start + self.data.len() as u64
+    }
+
+    fn contains(&self, range: &Range<u64>) -> bool {
+        self.start <= range.start && range.end <= self.end()
+    }
+}
+
+struct RequestedRange {
+    request_index: usize,
+    range: Range<u64>,
+}
+
+struct MergedRange {
+    range: Range<u64>,
+    request_indices: Vec<usize>,
+    requested_bytes: u64,
+}
+
+/// Bridges vindex-core's synchronous positional reads to Paimon's asynchronous
+/// range reader. This type is consumed from a blocking search task; it 
captures
+/// the surrounding Tokio runtime so remote storage reads still run 
asynchronously.
+pub(crate) struct VindexFileReader {
+    reader: Arc<dyn FileRead>,
+    runtime: tokio::runtime::Handle,
+    permits: Arc<tokio::sync::Semaphore>,
+    file_size: u64,
+    path: String,
+    scalar_cache: Option<CachedRange>,
+}
+
+impl VindexFileReader {
+    pub(crate) fn new(
+        reader: Arc<dyn FileRead>,
+        runtime: tokio::runtime::Handle,
+        file_size: u64,
+        path: String,
+    ) -> Self {
+        Self {
+            reader,
+            runtime,
+            permits: 
Arc::new(tokio::sync::Semaphore::new(RANGE_READ_CONCURRENCY)),
+            file_size,
+            path,
+            scalar_cache: None,
+        }
+    }
+
+    fn validate_range(&self, pos: u64, len: usize) -> io::Result<Range<u64>> {
+        let end = pos.checked_add(len as u64).ok_or_else(|| {
+            io::Error::new(
+                io::ErrorKind::InvalidInput,
+                format!("vindex read range overflows for '{}'", self.path),
+            )
+        })?;
+        if end > self.file_size {
+            return Err(io::Error::new(
+                io::ErrorKind::UnexpectedEof,
+                format!(
+                    "vindex read range {pos}..{end} exceeds file '{}' size {}",
+                    self.path, self.file_size
+                ),
+            ));
+        }
+        Ok(pos..end)
+    }
+
+    fn read_one(&mut self, pos: u64, buf: &mut [u8]) -> io::Result<()> {
+        if buf.is_empty() {
+            return Ok(());
+        }
+        let range = self.validate_range(pos, buf.len())?;
+        if buf.len() <= SCALAR_READ_MAX {
+            if let Some(cache) = &self.scalar_cache {
+                if cache.contains(&range) {
+                    let start = (range.start - cache.start) as usize;
+                    buf.copy_from_slice(&cache.data[start..start + buf.len()]);
+                    return Ok(());
+                }
+            }
+
+            let read_end = range
+                .start
+                .saturating_add(SCALAR_READ_AHEAD)
+                .max(range.end)
+                .min(self.file_size);
+            let data = self.fetch_exact(range.start..read_end)?;
+            buf.copy_from_slice(&data[..buf.len()]);
+            self.scalar_cache = Some(CachedRange {
+                start: range.start,
+                data,
+            });
+            return Ok(());
+        }
+
+        let data = self.fetch_exact(range)?;
+        buf.copy_from_slice(&data);
+        Ok(())
+    }
+
+    fn fetch_exact(&self, range: Range<u64>) -> io::Result<Bytes> {
+        let mut results = 
self.fetch_range_batch(std::slice::from_ref(&range))?;
+        Ok(results.pop().expect("one requested range"))
+    }
+
+    fn fetch_range_batch(&self, ranges: &[Range<u64>]) -> 
io::Result<Vec<Bytes>> {
+        debug_assert!(ranges.len() <= RANGE_READ_CONCURRENCY);
+        let reader = Arc::clone(&self.reader);
+        let permits = Arc::clone(&self.permits);
+        let path = self.path.clone();
+        let requested = ranges.to_vec();
+        let (sender, receiver) = mpsc::sync_channel(1);
+        self.runtime.spawn(async move {
+            let fetched = try_join_all(requested.iter().cloned().map(|range| {
+                let reader = Arc::clone(&reader);
+                let permits = Arc::clone(&permits);
+                let path = path.clone();
+                async move {
+                    let _permit = permits.acquire_owned().await.map_err(|_| {
+                        io::Error::other("vindex range read concurrency 
limiter closed")
+                    })?;
+                    let expected = (range.end - range.start) as usize;
+                    let data = 
reader.read(range.clone()).await.map_err(|error| {
+                        io::Error::other(format!(
+                            "failed to read vindex file '{path}' range {}..{}: 
{error}",
+                            range.start, range.end
+                        ))
+                    })?;
+                    if data.len() != expected {
+                        return Err(io::Error::new(
+                            io::ErrorKind::UnexpectedEof,
+                            format!(
+                                "short read for vindex file '{path}' range 
{}..{}: expected {expected} bytes, got {}",
+                                range.start,
+                                range.end,
+                                data.len()
+                            ),
+                        ));
+                    }
+                    Ok(data)
+                }
+            }))
+            .await;
+            let _ = sender.send(fetched);
+        });
+        receiver.recv().map_err(|_| {
+            io::Error::other(format!(
+                "vindex range read task for '{}' was cancelled",
+                self.path
+            ))
+        })?
+    }
+
+    fn read_many(&self, requests: &mut [ReadRequest<'_>]) -> io::Result<()> {
+        let mut requested = Vec::with_capacity(requests.len());
+        for (request_index, request) in requests.iter().enumerate() {
+            if request.buf.is_empty() {
+                continue;
+            }
+            requested.push(RequestedRange {
+                request_index,
+                range: self.validate_range(request.pos, request.buf.len())?,
+            });
+        }
+        if requested.is_empty() {
+            return Ok(());
+        }
+
+        requested.sort_by_key(|request| request.range.start);
+        let mut merged = Vec::<MergedRange>::new();
+        for request in requested {
+            if let Some(last) = merged.last_mut() {
+                let merged_end = last.range.end.max(request.range.end);
+                let merged_bytes = merged_end - last.range.start;
+                let requested_bytes = last
+                    .requested_bytes
+                    .saturating_add(request.range.end - request.range.start);
+                let within_gap =
+                    request.range.start <= 
last.range.end.saturating_add(RANGE_COALESCE_GAP);
+                let bounded_amplification = merged_bytes <= 
requested_bytes.saturating_mul(2);
+                if within_gap && bounded_amplification {
+                    last.range.end = last.range.end.max(request.range.end);
+                    last.request_indices.push(request.request_index);
+                    last.requested_bytes = requested_bytes;
+                    continue;
+                }
+            }
+            let requested_bytes = request.range.end - request.range.start;
+            merged.push(MergedRange {
+                range: request.range,
+                request_indices: vec![request.request_index],
+                requested_bytes,
+            });
+        }
+
+        for batch in merged.chunks(RANGE_READ_CONCURRENCY) {
+            let ranges: Vec<_> = batch.iter().map(|merged| 
merged.range.clone()).collect();
+            let fetched = self.fetch_range_batch(&ranges)?;
+            for (merged_range, data) in batch.iter().zip(fetched) {
+                for &request_index in &merged_range.request_indices {
+                    let request = &mut requests[request_index];
+                    let start = (request.pos - merged_range.range.start) as 
usize;
+                    request
+                        .buf
+                        .copy_from_slice(&data[start..start + 
request.buf.len()]);
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+impl SeekRead for VindexFileReader {
+    fn pread(&mut self, requests: &mut [ReadRequest<'_>]) -> io::Result<()> {
+        let non_empty = requests
+            .iter()
+            .filter(|request| !request.buf.is_empty())
+            .count();
+        if non_empty == 1 {
+            let request = requests
+                .iter_mut()
+                .find(|request| !request.buf.is_empty())
+                .expect("one non-empty request");
+            self.read_one(request.pos, request.buf)
+        } else {
+            self.read_many(requests)
+        }
+    }
+
+    fn try_clone_reader(&self) -> io::Result<Option<Self>> {
+        Ok(Some(Self {
+            reader: Arc::clone(&self.reader),
+            runtime: self.runtime.clone(),
+            permits: Arc::clone(&self.permits),
+            file_size: self.file_size,
+            path: self.path.clone(),
+            scalar_cache: None,
+        }))
+    }
+
+    fn read_capabilities(&self) -> SeekReadCapabilities {
+        // This adapter accepts any number of ranges and splits them 
internally.
+        // The efficient window size depends on the underlying FileRead 
backend,
+        // so leave both storage-specific hints unspecified.
+        SeekReadCapabilities::default()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::io::FileIO;
+    use async_trait::async_trait;
+    use std::sync::Mutex;
+    use std::time::Duration;
+
+    struct TrackingRead {
+        data: Bytes,
+        ranges: Mutex<Vec<Range<u64>>>,
+        short_read: bool,
+    }
+
+    impl TrackingRead {
+        fn new(data: Bytes) -> Arc<Self> {
+            Arc::new(Self {
+                data,
+                ranges: Mutex::new(Vec::new()),
+                short_read: false,
+            })
+        }
+
+        fn short(data: Bytes) -> Arc<Self> {
+            Arc::new(Self {
+                data,
+                ranges: Mutex::new(Vec::new()),
+                short_read: true,
+            })
+        }
+
+        fn ranges(&self) -> Vec<Range<u64>> {
+            self.ranges.lock().unwrap().clone()
+        }
+    }
+
+    struct RuntimeTrackingRead {
+        data: Bytes,
+        runtime_id: Mutex<Option<tokio::runtime::Id>>,
+    }
+
+    #[async_trait]
+    impl FileRead for RuntimeTrackingRead {
+        async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+            *self.runtime_id.lock().unwrap() = 
Some(tokio::runtime::Handle::current().id());
+            Ok(self.data.slice(range.start as usize..range.end as usize))
+        }
+    }
+
+    #[async_trait]
+    impl FileRead for TrackingRead {
+        async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+            self.ranges.lock().unwrap().push(range.clone());
+            let mut end = range.end as usize;
+            if self.short_read && end > range.start as usize {
+                end -= 1;
+            }
+            Ok(self.data.slice(range.start as usize..end))
+        }
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn scalar_reads_reuse_bounded_read_ahead() {
+        let data = Bytes::from((0..200_000).map(|value| value as 
u8).collect::<Vec<_>>());
+        let tracking = TrackingRead::new(data.clone());
+        let source: Arc<dyn FileRead> = tracking.clone();
+        let mut reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+
+        tokio::task::spawn_blocking(move || {
+            let mut first = [0u8; 8];
+            reader
+                .pread(&mut [ReadRequest::new(32, &mut first)])
+                .unwrap();
+            assert_eq!(&first, &data[32..40]);
+
+            let mut second = [0u8; 4];
+            reader
+                .pread(&mut [ReadRequest::new(128, &mut second)])
+                .unwrap();
+            assert_eq!(&second, &data[128..132]);
+        })
+        .await
+        .unwrap();
+
+        assert_eq!(tracking.ranges(), vec![32..32 + SCALAR_READ_AHEAD]);
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn multi_range_reads_merge_only_nearby_requests() {
+        let data = Bytes::from((0..100_000).map(|value| value as 
u8).collect::<Vec<_>>());
+        let tracking = TrackingRead::new(data.clone());
+        let source: Arc<dyn FileRead> = tracking.clone();
+        let mut reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+
+        tokio::task::spawn_blocking(move || {
+            let mut first = [0u8; 4];
+            let mut second = [0u8; 4];
+            let mut third = [0u8; 4];
+            reader
+                .pread(&mut [
+                    ReadRequest::new(0, &mut first),
+                    ReadRequest::new(8, &mut second),
+                    ReadRequest::new(20_000, &mut third),
+                ])
+                .unwrap();
+            assert_eq!(&first, &data[0..4]);
+            assert_eq!(&second, &data[8..12]);
+            assert_eq!(&third, &data[20_000..20_004]);
+        })
+        .await
+        .unwrap();
+
+        assert_eq!(tracking.ranges(), vec![0..12, 20_000..20_004]);
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn multi_range_reads_reject_sparse_amplification() {
+        let data = Bytes::from(vec![7u8; 100_000]);
+        let tracking = TrackingRead::new(data.clone());
+        let source: Arc<dyn FileRead> = tracking.clone();
+        let mut reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+
+        tokio::task::spawn_blocking(move || {
+            let mut first = [0u8; 4];
+            let mut second = [0u8; 4];
+            reader
+                .pread(&mut [
+                    ReadRequest::new(0, &mut first),
+                    ReadRequest::new(16_000, &mut second),
+                ])
+                .unwrap();
+        })
+        .await
+        .unwrap();
+
+        assert_eq!(tracking.ranges(), vec![0..4, 16_000..16_004]);
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn short_range_read_fails_loudly() {
+        let data = Bytes::from(vec![1u8; 1024]);
+        let tracking = TrackingRead::short(data.clone());
+        let source: Arc<dyn FileRead> = tracking;
+        let mut reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+
+        let error = tokio::task::spawn_blocking(move || {
+            let mut output = [0u8; 128];
+            reader
+                .pread(&mut [ReadRequest::new(256, &mut output)])
+                .unwrap_err()
+        })
+        .await
+        .unwrap();
+
+        assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
+        assert!(error.to_string().contains("short read"));
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn worker_read_is_safe_on_multi_thread_runtime() {
+        let data = Bytes::from(vec![3u8; 1024]);
+        let source: Arc<dyn FileRead> = TrackingRead::new(data.clone());
+        let mut reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+        let output = tokio::task::spawn_blocking(move || {
+            let mut output = [0u8; 16];
+            reader
+                .pread(&mut [ReadRequest::new(128, &mut output)])
+                .unwrap();
+            output
+        })
+        .await
+        .unwrap();
+        assert_eq!(&output, &data[128..144]);
+    }
+
+    #[tokio::test]
+    async fn worker_read_is_safe_on_current_thread_runtime() {
+        let data = Bytes::from(vec![5u8; 1024]);
+        let source: Arc<dyn FileRead> = TrackingRead::new(data.clone());
+        let mut reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+        let (sender, receiver) = tokio::sync::oneshot::channel();
+        let worker = std::thread::spawn(move || {
+            let mut output = [0u8; 16];
+            let result = reader.pread(&mut [ReadRequest::new(256, &mut 
output)]);
+            let _ = sender.send((result, output));
+        });
+        let (result, output) = receiver.await.unwrap();
+        result.unwrap();
+        worker.join().unwrap();
+        assert_eq!(&output, &data[256..272]);
+    }
+
+    #[test]
+    fn async_reads_run_on_the_calling_runtime() {
+        let runtime = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .unwrap();
+        let caller_runtime_id = runtime.handle().id();
+        let data = Bytes::from(vec![6u8; 1024]);
+        let tracking = Arc::new(RuntimeTrackingRead {
+            data: data.clone(),
+            runtime_id: Mutex::new(None),
+        });
+        let source: Arc<dyn FileRead> = tracking.clone();
+        let mut reader = VindexFileReader::new(
+            source,
+            runtime.handle().clone(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+        let (sender, receiver) = tokio::sync::oneshot::channel();
+        let worker = std::thread::spawn(move || {
+            let mut output = [0u8; 16];
+            let result = reader.pread(&mut [ReadRequest::new(128, &mut 
output)]);
+            let _ = sender.send((result, output));
+        });
+
+        let (result, output) = runtime.block_on(receiver).unwrap();
+        result.unwrap();
+        worker.join().unwrap();
+        assert_eq!(&output, &data[128..144]);
+        assert_eq!(
+            *tracking.runtime_id.lock().unwrap(),
+            Some(caller_runtime_id)
+        );
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn reader_can_clone_for_parallel_search() {
+        let data = Bytes::from(vec![7u8; 1024]);
+        let source: Arc<dyn FileRead> = TrackingRead::new(data.clone());
+        let reader = VindexFileReader::new(
+            source,
+            tokio::runtime::Handle::current(),
+            data.len() as u64,
+            "index".to_string(),
+        );
+
+        assert_eq!(reader.read_capabilities(), 
SeekReadCapabilities::default());
+        let cloned = reader.try_clone_reader().unwrap().unwrap();
+        assert_eq!(cloned.read_capabilities(), 
SeekReadCapabilities::default());
+    }
+
+    #[test]
+    fn local_fs_read_completes_with_one_host_blocking_thread() {
+        let temp_dir = tempfile::tempdir().unwrap();
+        let path = temp_dir.path().join("index.bin");
+        let data = vec![9u8; 4096];
+        std::fs::write(&path, &data).unwrap();
+        let runtime = tokio::runtime::Builder::new_multi_thread()
+            .worker_threads(1)
+            .max_blocking_threads(1)
+            .enable_all()
+            .build()
+            .unwrap();
+
+        runtime.block_on(async {
+            let file_io = FileIO::from_path(path.to_string_lossy())
+                .unwrap()
+                .build()
+                .unwrap();
+            let input = 
file_io.new_input(path.to_string_lossy().as_ref()).unwrap();
+            let source: Arc<dyn FileRead> = 
Arc::new(input.reader().await.unwrap());
+            let mut reader = VindexFileReader::new(
+                source,
+                tokio::runtime::Handle::current(),
+                data.len() as u64,
+                path.to_string_lossy().into_owned(),
+            );
+
+            let (sender, receiver) = tokio::sync::oneshot::channel();
+            let worker = std::thread::spawn(move || {
+                let mut output = [0u8; 32];
+                let result = reader.pread(&mut [ReadRequest::new(512, &mut 
output)]);
+                let _ = sender.send((result, output));
+            });
+            let (result, output) = 
tokio::time::timeout(Duration::from_secs(5), receiver)
+                .await
+                .expect("vindex range read deadlocked on the host blocking 
pool")
+                .unwrap();
+            result.unwrap();
+            worker.join().unwrap();
+            assert_eq!(output, [9u8; 32]);
+        });
+    }
+
+    #[test]
+    fn local_fs_read_is_safe_on_current_thread_runtime() {
+        let temp_dir = tempfile::tempdir().unwrap();
+        let path = temp_dir.path().join("index.bin");
+        let data = vec![11u8; 4096];
+        std::fs::write(&path, &data).unwrap();
+        let worker = std::thread::spawn(move || {
+            let runtime = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .unwrap();
+            runtime.block_on(async {
+                let file_io = FileIO::from_path(path.to_string_lossy())
+                    .unwrap()
+                    .build()
+                    .unwrap();
+                let input = 
file_io.new_input(path.to_string_lossy().as_ref()).unwrap();
+                let source: Arc<dyn FileRead> = 
Arc::new(input.reader().await.unwrap());
+                let mut reader = VindexFileReader::new(
+                    source,
+                    tokio::runtime::Handle::current(),
+                    data.len() as u64,
+                    path.to_string_lossy().into_owned(),
+                );
+                let (sender, receiver) = tokio::sync::oneshot::channel();
+                let search = std::thread::spawn(move || {
+                    let mut output = [0u8; 32];
+                    let result = reader.pread(&mut [ReadRequest::new(1024, 
&mut output)]);
+                    let _ = sender.send((result, output));
+                });
+                let (result, output) = 
tokio::time::timeout(Duration::from_secs(5), receiver)
+                    .await
+                    .expect("vindex range read hung on a current-thread 
runtime")
+                    .unwrap();
+                result.unwrap();
+                search.join().unwrap();
+                assert_eq!(output, [11u8; 32]);
+            });
+        });
+        worker.join().unwrap();
+    }
+}
diff --git a/crates/paimon/src/vindex/reader.rs 
b/crates/paimon/src/vindex/reader.rs
index 74551154..ecc39a57 100644
--- a/crates/paimon/src/vindex/reader.rs
+++ b/crates/paimon/src/vindex/reader.rs
@@ -20,17 +20,63 @@ use paimon_vindex_core::distance::MetricType;
 use paimon_vindex_core::index::{
     VectorIndexMetadata, VectorIndexReader as VIndexReader, VectorSearchParams,
 };
+use paimon_vindex_core::io::{ReadRequest, SeekRead, SeekReadCapabilities};
 use std::collections::BinaryHeap;
 use std::collections::HashMap;
-use std::io::{Cursor, Read, Seek, SeekFrom};
+use std::io;
 
 const DEFAULT_NPROBE: usize = 16;
 const NPROBE_PARAMETER: &str = "ivf.nprobe";
 
+trait ErasedSeekRead: Send {
+    fn pread_erased(&mut self, ranges: &mut [ReadRequest<'_>]) -> 
io::Result<()>;
+
+    fn try_clone_erased(&self) -> io::Result<Option<Box<dyn ErasedSeekRead>>>;
+
+    fn capabilities_erased(&self) -> SeekReadCapabilities;
+}
+
+impl<T: SeekRead + 'static> ErasedSeekRead for T {
+    fn pread_erased(&mut self, ranges: &mut [ReadRequest<'_>]) -> 
io::Result<()> {
+        SeekRead::pread(self, ranges)
+    }
+
+    fn try_clone_erased(&self) -> io::Result<Option<Box<dyn ErasedSeekRead>>> {
+        Ok(SeekRead::try_clone_reader(self)?
+            .map(|reader| Box::new(reader) as Box<dyn ErasedSeekRead>))
+    }
+
+    fn capabilities_erased(&self) -> SeekReadCapabilities {
+        SeekRead::read_capabilities(self)
+    }
+}
+
+struct VindexInput(Box<dyn ErasedSeekRead>);
+
+impl VindexInput {
+    fn new<S: SeekRead + 'static>(source: S) -> Self {
+        Self(Box::new(source))
+    }
+}
+
+impl SeekRead for VindexInput {
+    fn pread(&mut self, ranges: &mut [ReadRequest<'_>]) -> io::Result<()> {
+        self.0.pread_erased(ranges)
+    }
+
+    fn try_clone_reader(&self) -> io::Result<Option<Self>> {
+        Ok(self.0.try_clone_erased()?.map(Self))
+    }
+
+    fn read_capabilities(&self) -> SeekReadCapabilities {
+        self.0.capabilities_erased()
+    }
+}
+
 pub struct VindexVectorGlobalIndexReader {
     io_meta: GlobalIndexIOMeta,
     options: HashMap<String, String>,
-    reader: Option<VIndexReader<Cursor<Vec<u8>>>>,
+    reader: Option<VIndexReader<VindexInput>>,
     metadata: Option<VectorIndexMetadata>,
 }
 
@@ -44,7 +90,7 @@ impl VindexVectorGlobalIndexReader {
         }
     }
 
-    pub fn visit_vector_search<S: Read + Seek + Send + 'static>(
+    pub fn visit_vector_search<S: SeekRead + 'static>(
         &mut self,
         vector_search: &VectorSearch,
         stream_fn: impl FnOnce(&str) -> crate::Result<S>,
@@ -53,7 +99,7 @@ impl VindexVectorGlobalIndexReader {
         self.search(vector_search)
     }
 
-    pub fn visit_batch_vector_search<S: Read + Seek + Send + 'static>(
+    pub fn visit_batch_vector_search<S: SeekRead + 'static>(
         &mut self,
         vector_searches: &[VectorSearch],
         stream_fn: impl FnOnce(&str) -> crate::Result<S>,
@@ -84,7 +130,7 @@ impl VindexVectorGlobalIndexReader {
         search_vindex(reader, metadata, &self.options, vector_search)
     }
 
-    fn ensure_loaded<S: Read + Seek + Send + 'static>(
+    fn ensure_loaded<S: SeekRead + 'static>(
         &mut self,
         stream_fn: impl FnOnce(&str) -> crate::Result<S>,
     ) -> crate::Result<()> {
@@ -92,26 +138,13 @@ impl VindexVectorGlobalIndexReader {
             return Ok(());
         }
 
-        let mut stream = stream_fn(&self.io_meta.file_path)?;
-        stream
-            .seek(SeekFrom::Start(0))
-            .map_err(|e| crate::Error::UnexpectedError {
-                message: format!("Failed to seek vindex stream to start: {}", 
e),
-                source: Some(Box::new(e)),
-            })?;
-        let mut bytes = Vec::with_capacity(self.io_meta.file_size as usize);
-        stream
-            .read_to_end(&mut bytes)
-            .map_err(|e| crate::Error::UnexpectedError {
-                message: format!("Failed to read vindex stream: {}", e),
-                source: Some(Box::new(e)),
-            })?;
-
-        let mut reader =
-            VIndexReader::open(Cursor::new(bytes)).map_err(|e| 
crate::Error::DataInvalid {
+        let source = stream_fn(&self.io_meta.file_path)?;
+        let mut reader = 
VIndexReader::open(VindexInput::new(source)).map_err(|e| {
+            crate::Error::DataInvalid {
                 message: format!("Failed to open paimon-vindex-core reader: 
{}", e),
                 source: Some(Box::new(e)),
-            })?;
+            }
+        })?;
         let metadata = reader.metadata();
         reader
             .optimize_for_search()
@@ -127,7 +160,7 @@ impl VindexVectorGlobalIndexReader {
 }
 
 fn search_vindex(
-    reader: &mut VIndexReader<Cursor<Vec<u8>>>,
+    reader: &mut VIndexReader<impl SeekRead>,
     metadata: &VectorIndexMetadata,
     options: &HashMap<String, String>,
     vector_search: &VectorSearch,
@@ -266,6 +299,102 @@ fn int_parameter(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::io::FileRead;
+    use crate::vindex::range_reader::VindexFileReader;
+    use async_trait::async_trait;
+    use bytes::Bytes;
+    use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer, 
VectorIndexWriter};
+    use paimon_vindex_core::io::{PosWriter, SeekReadCapabilities};
+    use std::ops::Range;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::{Arc, Mutex};
+
+    const TEST_DIMENSION: usize = 16;
+
+    #[derive(Clone)]
+    struct CloneableSeekRead {
+        capabilities: SeekReadCapabilities,
+    }
+
+    impl SeekRead for CloneableSeekRead {
+        fn pread(&mut self, _ranges: &mut [ReadRequest<'_>]) -> io::Result<()> 
{
+            Ok(())
+        }
+
+        fn try_clone_reader(&self) -> io::Result<Option<Self>> {
+            Ok(Some(self.clone()))
+        }
+
+        fn read_capabilities(&self) -> SeekReadCapabilities {
+            self.capabilities
+        }
+    }
+
+    struct TrackingIndexRead {
+        data: Bytes,
+        ranges: Mutex<Vec<Range<u64>>>,
+        bytes_read: AtomicUsize,
+    }
+
+    impl TrackingIndexRead {
+        fn new(data: Bytes) -> Arc<Self> {
+            Arc::new(Self {
+                data,
+                ranges: Mutex::new(Vec::new()),
+                bytes_read: AtomicUsize::new(0),
+            })
+        }
+
+        fn ranges(&self) -> Vec<Range<u64>> {
+            self.ranges.lock().unwrap().clone()
+        }
+    }
+
+    #[async_trait]
+    impl FileRead for TrackingIndexRead {
+        async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+            self.bytes_read
+                .fetch_add((range.end - range.start) as usize, 
Ordering::SeqCst);
+            self.ranges.lock().unwrap().push(range.clone());
+            Ok(self.data.slice(range.start as usize..range.end as usize))
+        }
+    }
+
+    fn build_ivf_flat_index() -> Bytes {
+        let vector_count = 8192usize;
+        let mut vectors = Vec::with_capacity(vector_count * TEST_DIMENSION);
+        for row in 0..vector_count {
+            let cluster = (row % 16) as f32 * 100.0;
+            for dimension in 0..TEST_DIMENSION {
+                vectors.push(cluster + dimension as f32 * 0.01 + row as f32 * 
0.000001);
+            }
+        }
+        let ids: Vec<i64> = (0..vector_count as i64).collect();
+        let options = HashMap::from([
+            ("index.type".to_string(), "ivf_flat".to_string()),
+            ("dimension".to_string(), TEST_DIMENSION.to_string()),
+            ("nlist".to_string(), "16".to_string()),
+            ("metric".to_string(), "l2".to_string()),
+        ]);
+        let config = VectorIndexConfig::from_options(&options).unwrap();
+        let training = VectorIndexTrainer::train(config, &vectors, 
vector_count).unwrap();
+        let mut writer = VectorIndexWriter::new(training);
+        writer.add_vectors(&ids, &vectors, vector_count).unwrap();
+        let mut output = Vec::new();
+        writer.write(&mut PosWriter::new(&mut output)).unwrap();
+        Bytes::from(output)
+    }
+
+    fn query() -> VectorSearch {
+        VectorSearch::new(
+            (0..TEST_DIMENSION)
+                .map(|dimension| dimension as f32 * 0.01)
+                .collect(),
+            10,
+            "embedding".to_string(),
+        )
+        .unwrap()
+    }
 
     #[test]
     fn test_convert_distance_to_score() {
@@ -317,4 +446,58 @@ mod tests {
         options.insert(NPROBE_PARAMETER.to_string(), "abc".to_string());
         assert!(int_parameter(&options, NPROBE_PARAMETER, 
DEFAULT_NPROBE).is_err());
     }
+
+    #[test]
+    fn erased_input_forwards_clone_and_capabilities() {
+        let capabilities = SeekReadCapabilities {
+            estimated_random_read_latency_nanos: 123,
+            preferred_window_bytes: 64 * 1024,
+            max_ranges_per_pread: 7,
+        };
+        let input = VindexInput::new(CloneableSeekRead { capabilities });
+
+        assert_eq!(input.read_capabilities(), capabilities);
+        let cloned = input.try_clone_reader().unwrap().unwrap();
+        assert_eq!(cloned.read_capabilities(), capabilities);
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn scalar_search_range_reads_instead_of_loading_the_whole_index() {
+        let index = build_ivf_flat_index();
+        let tracking = TrackingIndexRead::new(index.clone());
+        let source: Arc<dyn FileRead> = tracking.clone();
+        let index_size = index.len();
+        let runtime = tokio::runtime::Handle::current();
+
+        let result = tokio::task::spawn_blocking(move || {
+            let source = VindexFileReader::new(
+                source,
+                runtime,
+                index_size as u64,
+                "scalar.index".to_string(),
+            );
+            let io_meta =
+                GlobalIndexIOMeta::new("scalar.index".to_string(), index_size 
as u64, Vec::new());
+            let options = HashMap::from([(NPROBE_PARAMETER.to_string(), 
"1".to_string())]);
+            let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
options);
+            reader.visit_vector_search(&query(), |_| Ok(source))
+        })
+        .await
+        .unwrap()
+        .unwrap();
+
+        assert!(result.is_some());
+        let bytes_read = tracking.bytes_read.load(Ordering::SeqCst);
+        assert!(
+            bytes_read < index_size / 2,
+            "nprobe=1 should read substantially less than the full index: 
read={bytes_read}, file={index_size}"
+        );
+        assert!(
+            tracking
+                .ranges()
+                .iter()
+                .all(|range| range.start != 0 || range.end != index_size as 
u64),
+            "range search unexpectedly read the entire index"
+        );
+    }
 }

Reply via email to