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

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


The following commit(s) were added to refs/heads/master by this push:
     new 38b3c1637d [python] Read native vector index ranges with bounded 
concurrency (#9756)
38b3c1637d is described below

commit 38b3c1637dd95450e07752b9fb9dc6df74a0418a
Author: chaoyang <[email protected]>
AuthorDate: Sun Sep 13 16:25:31 2026 +0800

    [python] Read native vector index ranges with bounded concurrency (#9756)
---
 paimon-python/README.md                            |  16 ++
 .../vindex/vindex_vector_global_index_reader.py    | 110 ++++++++--
 paimon-python/pypaimon/tests/vindex_input_test.py  | 233 +++++++++++++++++++++
 3 files changed, 342 insertions(+), 17 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index f864ff265b..ed96878ce4 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -286,3 +286,19 @@ unsupported platform such as Windows), `pypaimon` 
automatically falls
 back to the `pyarrow` (`libhdfs`/JVM) path and logs a warning. Disable
 the fallback with `hdfs.client.fallback-to-pyarrow=false` if you want
 hard failures instead.
+
+
+# Vector index range reads
+
+Native vector indexes (`ivf-flat`, `ivf-pq`, `ivf-sq`, `ivf-rq`, and `diskann`)
+read multiple file ranges concurrently when the input stream supports
+thread-safe positional reads. Set the table option `vindex.read.parallelism`
+to a positive integer to control the maximum number of concurrent reads per
+index reader, including reads from concurrent native query callbacks.
+
+The default is **4** for remote index paths and **1** for local paths 
(including
+`file://`). Setting it to **1** disables range-level concurrency. Streams that
+only support `seek` and `read` remain serialized. Workers are created lazily
+and released when the index reader closes; separate readers have separate
+budgets. This option controls index I/O, not shard search or native compute
+threads.
diff --git 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
index d2576e2f2e..3a7efb7d83 100644
--- 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
+++ 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
@@ -19,6 +19,8 @@
 
 import os
 import threading
+from concurrent.futures import ThreadPoolExecutor, wait
+from urllib.parse import urlparse
 
 import numpy as np
 
@@ -30,26 +32,69 @@ VINDEX_IDENTIFIERS = ("ivf-flat", "ivf-pq", "ivf-sq", 
"ivf-rq", "diskann")
 
 NPROBE_PARAMETER = "ivf.nprobe"
 L_SEARCH_PARAMETER = "diskann.l_search"
+READ_PARALLELISM_PARAMETER = "vindex.read.parallelism"
 
 
 class PaimonVindexInput:
     """Input adapter required by paimon_vindex.VectorIndexReader."""
 
-    def __init__(self, stream):
+    def __init__(self, stream, parallelism=1):
+        if isinstance(parallelism, bool) or not isinstance(parallelism, int) 
or parallelism < 1:
+            raise ValueError("Vector index read parallelism must be a positive 
integer")
         self._stream = stream
+        self._parallelism = parallelism
+        self._read_slots = threading.BoundedSemaphore(parallelism)
         self._supports_pread = supports_pread(stream)
         self._lock = threading.Lock()
+        self._executor = None
+        self._closed = False
 
     def pread_many(self, ranges):
-        if self._supports_pread:
-            return [pread(self._stream, length, offset) for offset, length in 
ranges]
-
-        chunks = []
+        ranges = list(ranges)
         with self._lock:
+            if self._closed:
+                raise ValueError("Vector index input is closed")
+            if not self._supports_pread:
+                chunks = []
+                for offset, length in ranges:
+                    self._stream.seek(offset)
+                    chunks.append(self._stream.read(length))
+                return chunks
+            if self._parallelism > 1 and len(ranges) > 1:
+                if self._executor is None:
+                    self._executor = ThreadPoolExecutor(
+                        max_workers=self._parallelism,
+                        thread_name_prefix="paimon-vindex-io")
+            executor = self._executor
+
+        if self._parallelism == 1:
+            with self._read_slots:
+                return [pread(self._stream, length, offset) for offset, length 
in ranges]
+        if executor is None or len(ranges) <= 1:
+            return [self._pread(offset, length) for offset, length in ranges]
+
+        futures = []
+        try:
             for offset, length in ranges:
-                self._stream.seek(offset)
-                chunks.append(self._stream.read(length))
-        return chunks
+                futures.append(executor.submit(self._pread, offset, length))
+            return [future.result() for future in futures]
+        finally:
+            # A failed range must not leave reads using a stream the caller 
may close.
+            wait(futures)
+
+    def _pread(self, offset, length):
+        # Native query workers may also issue single-range callbacks 
concurrently.
+        with self._read_slots:
+            return pread(self._stream, length, offset)
+
+    def close(self):
+        """Release workers; the owner remains responsible for closing the 
stream."""
+        with self._lock:
+            self._closed = True
+            executor = self._executor
+            self._executor = None
+        if executor is not None:
+            executor.shutdown(wait=True)
 
 
 class VindexVectorGlobalIndexReader(GlobalIndexReader):
@@ -165,10 +210,12 @@ class VindexVectorGlobalIndexReader(GlobalIndexReader):
             file_path = (self._io_meta.external_path
                          if self._io_meta.external_path
                          else os.path.join(self._index_path, 
self._io_meta.file_name))
+            parallelism = _read_parallelism(self._options, file_path)
             stream = self._file_io.new_input_stream(file_path)
             reader = None
+            index_input = None
             try:
-                index_input = PaimonVindexInput(stream)
+                index_input = PaimonVindexInput(stream, parallelism)
                 reader = VectorIndexReader(index_input)
                 self._metadata = reader.metadata()
                 reader.optimize_for_search()
@@ -177,9 +224,15 @@ class VindexVectorGlobalIndexReader(GlobalIndexReader):
                 self._search_params_type = SearchParams
                 self._stream = stream
             except Exception:
-                if reader is not None:
-                    reader.close()
-                stream.close()
+                try:
+                    if reader is not None:
+                        reader.close()
+                finally:
+                    try:
+                        if index_input is not None:
+                            index_input.close()
+                    finally:
+                        stream.close()
                 raise
 
     def __enter__(self):
@@ -190,12 +243,35 @@ class VindexVectorGlobalIndexReader(GlobalIndexReader):
         return False
 
     def close(self):
-        if self._reader is not None:
-            self._reader.close()
+        try:
+            if self._reader is not None:
+                self._reader.close()
+        finally:
             self._reader = None
-        if self._stream is not None:
-            self._stream.close()
-            self._stream = None
+            try:
+                if self._index_input is not None:
+                    self._index_input.close()
+            finally:
+                self._index_input = None
+                if self._stream is not None:
+                    self._stream.close()
+                    self._stream = None
+
+
+def _read_parallelism(options, file_path):
+    value = options.get(READ_PARALLELISM_PARAMETER)
+    if value is None:
+        # Avoid thread scheduling overhead for local files, including Windows 
paths.
+        scheme = urlparse(file_path).scheme
+        return 1 if scheme in ("", "file") or len(scheme) == 1 else 4
+    try:
+        parallelism = int(str(value))
+    except (ValueError, TypeError):
+        parallelism = 0
+    if parallelism < 1:
+        raise ValueError("'%s' must be a positive integer, got: %s"
+                         % (READ_PARALLELISM_PARAMETER, value))
+    return parallelism
 
 
 def _filter_bytes(include_row_ids):
diff --git a/paimon-python/pypaimon/tests/vindex_input_test.py 
b/paimon-python/pypaimon/tests/vindex_input_test.py
new file mode 100644
index 0000000000..892231462d
--- /dev/null
+++ b/paimon-python/pypaimon/tests/vindex_input_test.py
@@ -0,0 +1,233 @@
+# 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.
+
+import io
+import tempfile
+import threading
+import unittest
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as 
FutureTimeoutError
+from unittest import mock
+
+from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
+from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
+    PaimonVindexInput,
+    VindexVectorGlobalIndexReader,
+    _read_parallelism,
+)
+
+
+class VindexInputTest(unittest.TestCase):
+
+    def test_positional_reads_preserve_order_and_cursor(self):
+        data = bytes(range(256)) * 100
+        ranges = [(100, 20), (0, 12), (100, 20), (250, 30), (len(data) - 2, 
10), (0, 0)]
+        with tempfile.TemporaryFile() as stream:
+            stream.write(data)
+            stream.flush()
+            stream.seek(7)
+            input_ = PaimonVindexInput(stream, parallelism=4)
+            try:
+                self.assertEqual([data[o:o + n] for o, n in ranges], 
input_.pread_many(ranges))
+                self.assertEqual(7, stream.tell())
+                self.assertEqual([], input_.pread_many([]))
+            finally:
+                input_.close()
+            self.assertFalse(stream.closed)
+            input_.close()
+            with self.assertRaisesRegex(ValueError, "closed"):
+                input_.pread_many([(0, 1)])
+
+    def test_concurrent_callbacks_share_worker_limit(self):
+        entered = threading.Event()
+        release = threading.Event()
+        lock = threading.Lock()
+        active = 0
+        peak = 0
+
+        class Stream:
+            def read_at(self, length, offset):
+                nonlocal active, peak
+                with lock:
+                    active += 1
+                    peak = max(peak, active)
+                    if active == 2:
+                        entered.set()
+                try:
+                    if not release.wait(5):
+                        raise TimeoutError("Readers were not released")
+                    return bytes([offset]) * length
+                finally:
+                    with lock:
+                        active -= 1
+
+        input_ = PaimonVindexInput(Stream(), parallelism=2)
+        with ThreadPoolExecutor(2) as callers:
+            try:
+                first = callers.submit(input_.pread_many, [(3, 2), (1, 4), (2, 
1)])
+                second = callers.submit(input_.pread_many, [(4, 1)])
+                self.assertTrue(entered.wait(5), "Position reads did not 
overlap")
+                release.set()
+                self.assertEqual([b"\x03" * 2, b"\x01" * 4, b"\x02"], 
first.result(5))
+                self.assertEqual([b"\x04"], second.result(5))
+                self.assertEqual(2, peak)
+            finally:
+                release.set()
+                input_.close()
+
+    def test_failure_waits_for_other_reads_before_returning(self):
+        started = threading.Event()
+        release = threading.Event()
+        finished = threading.Event()
+        error = OSError("range read failed")
+
+        class Stream:
+            def read_at(self, length, offset):
+                if offset == 0:
+                    if not started.wait(5):
+                        raise TimeoutError("Second range did not start")
+                    raise error
+                started.set()
+                if not release.wait(5):
+                    raise TimeoutError("Second range was not released")
+                finished.set()
+                return b"x"
+
+        input_ = PaimonVindexInput(Stream(), parallelism=2)
+        with ThreadPoolExecutor(1) as caller:
+            try:
+                result = caller.submit(input_.pread_many, [(0, 1), (1, 1)])
+                self.assertTrue(started.wait(5))
+                with self.assertRaises(FutureTimeoutError):
+                    result.result(timeout=0.05)
+                release.set()
+                with self.assertRaises(OSError) as raised:
+                    result.result(5)
+                self.assertIs(error, raised.exception)
+                self.assertTrue(finished.is_set())
+            finally:
+                release.set()
+                input_.close()
+
+    def test_seek_read_fallback_is_serial_across_callbacks(self):
+        class Stream(io.BytesIO):
+            def __init__(self):
+                super().__init__(b"abcdefgh")
+                self.guard = threading.Lock()
+
+            def seek(self, offset):
+                if not self.guard.acquire(blocking=False):
+                    raise AssertionError("Concurrent seek/read")
+                return super().seek(offset)
+
+            def read(self, length):
+                try:
+                    return super().read(length)
+                finally:
+                    self.guard.release()
+
+        input_ = PaimonVindexInput(Stream(), parallelism=4)
+        try:
+            with ThreadPoolExecutor(4) as pool:
+                results = list(pool.map(input_.pread_many, [[(3, 2), (0, 3)]] 
* 20))
+            self.assertEqual([[b"de", b"abc"]] * 20, results)
+            self.assertIsNone(input_._executor)
+        finally:
+            input_.close()
+
+    def test_serial_and_single_range_reads_do_not_start_workers(self):
+        stream = mock.Mock(spec=["read_at"])
+        stream.read_at.return_value = b"x"
+        for parallelism, ranges in ((1, [(0, 1), (1, 1)]), (4, [(0, 1)]), (4, 
[])):
+            input_ = PaimonVindexInput(stream, parallelism)
+            try:
+                self.assertEqual([b"x"] * len(ranges), 
input_.pread_many(ranges))
+                self.assertIsNone(input_._executor)
+            finally:
+                input_.close()
+
+    def test_parallelism_defaults_and_validation(self):
+        for path in ("/tmp/index", "file:///tmp/index", "C:/index"):
+            self.assertEqual(1, _read_parallelism({}, path))
+        for path in ("s3://bucket/index", "hdfs://host/index", 
"oss://bucket/index"):
+            self.assertEqual(4, _read_parallelism({}, path))
+        for value in (1, "2", 8):
+            self.assertEqual(int(value), 
_read_parallelism({"vindex.read.parallelism": value}, "x"))
+        for value in (0, -1, "invalid", "1.5", 1.5, True):
+            with self.assertRaisesRegex(ValueError, "positive integer"):
+                _read_parallelism({"vindex.read.parallelism": value}, "x")
+
+    def test_reader_releases_workers_and_stream_on_open_failure(self):
+        self._check_reader_cleanup("initialize")
+
+    def test_reader_releases_workers_and_stream_on_close(self):
+        self._check_reader_cleanup("success")
+
+    def test_reader_releases_workers_when_native_constructor_fails(self):
+        self._check_reader_cleanup("constructor")
+
+    def test_reader_releases_workers_when_native_close_fails(self):
+        self._check_reader_cleanup("close")
+
+    def _check_reader_cleanup(self, phase):
+        stream = mock.Mock(spec=["read_at", "close"])
+        stream.read_at.return_value = b"x"
+        io_ = mock.Mock()
+        io_.new_input_stream.return_value = stream
+        native = mock.Mock()
+        inputs = []
+        workers = []
+
+        def open_reader(input_):
+            inputs.append(input_)
+            self.assertEqual([b"x", b"x"], input_.pread_many([(0, 1), (1, 1)]))
+            workers.extend(input_._executor._threads)
+            if phase == "constructor":
+                raise error
+            return native
+
+        error = OSError("native reader failed")
+        if phase == "initialize":
+            native.optimize_for_search.side_effect = error
+        if phase == "close":
+            native.close.side_effect = error
+        module = mock.Mock()
+        module.VectorIndexReader.side_effect = open_reader
+        with mock.patch.dict("sys.modules", {"paimon_vindex": module}):
+            reader = VindexVectorGlobalIndexReader(
+                io_, "s3://bucket", [GlobalIndexIOMeta(file_name="index", 
file_size=2)])
+            if phase in ("constructor", "initialize"):
+                with self.assertRaises(OSError) as raised:
+                    reader._ensure_loaded()
+                self.assertIs(error, raised.exception)
+            else:
+                reader._ensure_loaded()
+            if phase == "close":
+                with self.assertRaises(OSError) as raised:
+                    reader.close()
+                self.assertIs(error, raised.exception)
+            else:
+                reader.close()
+            reader.close()
+        if phase == "constructor":
+            native.close.assert_not_called()
+        else:
+            native.close.assert_called_once_with()
+        stream.close.assert_called_once_with()
+        self.assertTrue(workers)
+        self.assertTrue(all(not worker.is_alive() for worker in workers))
+        with self.assertRaisesRegex(ValueError, "closed"):
+            inputs[0].pread_many([(0, 1)])

Reply via email to