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-vector-index.git


The following commit(s) were added to refs/heads/main by this push:
     new 1499263  Batch positional reads across C and Python (#60)
1499263 is described below

commit 1499263aeadfb3f3d94111c2718e8a22bc7dc443
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 22 15:51:27 2026 +0800

    Batch positional reads across C and Python (#60)
---
 README.md                        |   6 +-
 c/test_vindex.c                  |  52 ++++++++++------
 cpp/test_vindex.cpp              |  16 ++++-
 ffi/src/lib.rs                   | 127 +++++++++++++++++++++++++++++++++------
 include/paimon_vindex.hpp        |  13 ++--
 python/paimon_vindex/__init__.py |  41 ++++++++-----
 python/paimon_vindex/_ffi.py     |  19 +++++-
 python/tests/test_vindex.py      |  29 +++++++++
 tools/README.md                  |  19 ++++++
 tools/check_license_headers.py   |   1 +
 10 files changed, 259 insertions(+), 64 deletions(-)

diff --git a/README.md b/README.md
index a949786..4129065 100644
--- a/README.md
+++ b/README.md
@@ -197,8 +197,10 @@ paimon_vindex_reader_search(
 paimon_vindex_reader_free(reader);
 ```
 
-`PaimonVindexOutputFile` and `PaimonVindexInputFile` are callback structs. C
-callers own result buffers and pass their capacity to search calls. Functions
+`PaimonVindexOutputFile` and `PaimonVindexInputFile` are callback structs. The
+input callback receives all positional read ranges for one I/O batch in a
+single call, so object-store implementations can issue the reads concurrently.
+C callers own result buffers and pass their capacity to search calls. Functions
 return `0` on success and `-1` on error; `paimon_vindex_last_error()` returns a
 thread-local error string.
 
diff --git a/c/test_vindex.c b/c/test_vindex.c
index 7fa3f90..1cb05cb 100644
--- a/c/test_vindex.c
+++ b/c/test_vindex.c
@@ -47,6 +47,7 @@ struct MemBuffer {
     size_t len;
     size_t cap;
     size_t pos;
+    size_t max_read_request_count;
 };
 
 enum {
@@ -113,17 +114,27 @@ static int64_t mem_pos(void *ctx) {
     return (int64_t)buf->pos;
 }
 
-static int mem_read_at(void *ctx, uint64_t offset, uint8_t *dst, uintptr_t 
len) {
+static int mem_read_ranges(
+        void *ctx,
+        struct PaimonVindexReadRequest *requests,
+        uintptr_t request_count) {
     struct MemBuffer *buf = (struct MemBuffer *)ctx;
-    if (offset > SIZE_MAX || len > SIZE_MAX) {
-        return -1;
+    if (request_count > buf->max_read_request_count) {
+        buf->max_read_request_count = (size_t)request_count;
     }
-    size_t off = (size_t)offset;
-    size_t n = (size_t)len;
-    if (off > buf->len || n > buf->len - off) {
-        return -1;
+    for (uintptr_t i = 0; i < request_count; i++) {
+        uint64_t offset = requests[i].offset;
+        uintptr_t len = requests[i].len;
+        if (offset > SIZE_MAX || len > SIZE_MAX) {
+            return -1;
+        }
+        size_t off = (size_t)offset;
+        size_t n = (size_t)len;
+        if (off > buf->len || n > buf->len - off) {
+            return -1;
+        }
+        memcpy(requests[i].buf, buf->data + off, n);
     }
-    memcpy(dst, buf->data + off, n);
     return 0;
 }
 
@@ -139,11 +150,13 @@ static int failing_flush(void *ctx) {
     return -1;
 }
 
-static int failing_read_at(void *ctx, uint64_t offset, uint8_t *dst, uintptr_t 
len) {
+static int failing_read_ranges(
+        void *ctx,
+        struct PaimonVindexReadRequest *requests,
+        uintptr_t request_count) {
     (void)ctx;
-    (void)offset;
-    (void)dst;
-    (void)len;
+    (void)requests;
+    (void)request_count;
     return -1;
 }
 
@@ -235,7 +248,7 @@ static void run_roundtrip(
 
     struct PaimonVindexInputFile input = {
         .ctx = &buf,
-        .read_at_fn = mem_read_at,
+        .read_ranges_fn = mem_read_ranges,
     };
     PaimonVindexReaderHandle *reader = paimon_vindex_reader_open(input);
     if (reader == NULL) {
@@ -269,6 +282,9 @@ static void run_roundtrip(
     }
     assert_id_in_cluster(result_ids[0], 0);
     ASSERT_TRUE(isfinite(result_distances[0]));
+    if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_PQ) {
+        ASSERT_TRUE(buf.max_read_request_count > 1);
+    }
     if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_RQ) {
         search_params.query_bits = 4;
         if (paimon_vindex_reader_search(
@@ -370,16 +386,16 @@ static void 
test_output_flush_callback_error_propagates(void) {
     printf("PASS output_flush_callback_error_propagates\n");
 }
 
-static void test_input_read_callback_error_propagates(void) {
+static void test_input_read_ranges_callback_error_propagates(void) {
     struct PaimonVindexInputFile input = {
         .ctx = NULL,
-        .read_at_fn = failing_read_at,
+        .read_ranges_fn = failing_read_ranges,
     };
 
     PaimonVindexReaderHandle *reader = paimon_vindex_reader_open(input);
     ASSERT_TRUE(reader == NULL);
-    assert_last_error_contains("read_at callback failed");
-    printf("PASS input_read_callback_error_propagates\n");
+    assert_last_error_contains("read_ranges callback failed");
+    printf("PASS input_read_ranges_callback_error_propagates\n");
 }
 
 static void test_supported_index_roundtrips(void) {
@@ -443,6 +459,6 @@ int main(void) {
     test_supported_index_roundtrips();
     test_output_write_callback_error_propagates();
     test_output_flush_callback_error_propagates();
-    test_input_read_callback_error_propagates();
+    test_input_read_ranges_callback_error_propagates();
     return 0;
 }
diff --git a/cpp/test_vindex.cpp b/cpp/test_vindex.cpp
index be842ae..9cc31d6 100644
--- a/cpp/test_vindex.cpp
+++ b/cpp/test_vindex.cpp
@@ -43,6 +43,7 @@
 struct MemBuffer {
     std::vector<uint8_t> data;
     size_t pos = 0;
+    mutable size_t max_read_request_count = 0;
 };
 
 constexpr size_t kRoundtripDimension = 8;
@@ -64,9 +65,15 @@ static paimon::vindex::OutputFile make_output(MemBuffer& 
buf) {
 
 static paimon::vindex::InputFile make_input(const MemBuffer& buf) {
     paimon::vindex::InputFile in;
-    in.read_at_fn = [&buf](uint64_t offset, uint8_t* dst, size_t len) -> int {
-        if (offset + len > buf.data.size()) return -1;
-        memcpy(dst, buf.data.data() + offset, len);
+    in.read_ranges_fn = [&buf](
+            paimon::vindex::ReadRequest* requests,
+            size_t request_count) -> int {
+        buf.max_read_request_count = std::max(buf.max_read_request_count, 
request_count);
+        for (size_t i = 0; i < request_count; i++) {
+            const auto& request = requests[i];
+            if (request.offset + request.len > buf.data.size()) return -1;
+            memcpy(request.buf, buf.data.data() + request.offset, request.len);
+        }
         return 0;
     };
     return in;
@@ -153,6 +160,9 @@ static void run_roundtrip(
     ASSERT_EQ(result.ids.size(), 2);
     assert_id_in_cluster(result.ids[0], 0);
     ASSERT_TRUE(std::isfinite(result.distances[0]));
+    if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_PQ) {
+        ASSERT_TRUE(buf.max_read_request_count > 1);
+    }
     if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_RQ) {
         auto query_bits_result =
             reader.search(query.data(), paimon::vindex::SearchParams{2, 4, 16, 
4});
diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs
index 46ae9c8..6e9eb94 100644
--- a/ffi/src/lib.rs
+++ b/ffi/src/lib.rs
@@ -161,10 +161,25 @@ impl SeekWrite for FfiOutputFile {
     }
 }
 
+#[repr(C)]
+pub struct PaimonVindexReadRequest {
+    /// Absolute byte offset in the input file.
+    pub offset: u64,
+    /// Destination buffer that the callback must fill before returning.
+    pub buf: *mut u8,
+    /// Destination buffer length in bytes.
+    pub len: usize,
+}
+
 #[repr(C)]
 pub struct PaimonVindexInputFile {
     pub ctx: *mut c_void,
-    pub read_at_fn: Option<unsafe extern "C" fn(*mut c_void, u64, *mut u8, 
usize) -> c_int>,
+    /// Reads every request in the batch, preferably concurrently.
+    ///
+    /// Request descriptors and their buffers are valid only for the duration 
of
+    /// the callback and must not be retained by the implementation.
+    pub read_ranges_fn:
+        Option<unsafe extern "C" fn(*mut c_void, *mut PaimonVindexReadRequest, 
usize) -> c_int>,
 }
 
 struct FfiInputFile {
@@ -175,27 +190,26 @@ unsafe impl Send for FfiInputFile {}
 
 impl SeekRead for FfiInputFile {
     fn pread(&mut self, ranges: &mut [ReadRequest<'_>]) -> io::Result<()> {
-        if let Some(read_at_fn) = self.raw.read_at_fn {
-            for range in ranges {
-                let result = unsafe {
-                    read_at_fn(
-                        self.raw.ctx,
-                        range.pos,
-                        range.buf.as_mut_ptr(),
-                        range.buf.len(),
-                    )
-                };
-                if result != 0 {
-                    return Err(io::Error::other(format!(
-                        "read_at callback failed at offset {} length {}",
-                        range.pos,
-                        range.buf.len()
-                    )));
-                }
+        if ranges.is_empty() {
+            return Ok(());
+        }
+        if let Some(read_ranges_fn) = self.raw.read_ranges_fn {
+            let mut requests = ranges
+                .iter_mut()
+                .map(|range| PaimonVindexReadRequest {
+                    offset: range.pos,
+                    buf: range.buf.as_mut_ptr(),
+                    len: range.buf.len(),
+                })
+                .collect::<Vec<_>>();
+            let result =
+                unsafe { read_ranges_fn(self.raw.ctx, requests.as_mut_ptr(), 
requests.len()) };
+            if result != 0 {
+                return Err(io::Error::other("read_ranges callback failed"));
             }
             Ok(())
         } else {
-            Err(io::Error::other("read_at_fn is null"))
+            Err(io::Error::other("read_ranges_fn is null"))
         }
     }
 }
@@ -802,3 +816,78 @@ pub unsafe extern "C" fn 
paimon_vindex_reader_search_batch_with_roaring_filter(
         )
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    struct BatchReadState {
+        data: Vec<u8>,
+        calls: usize,
+        range_count: usize,
+    }
+
+    unsafe extern "C" fn read_ranges(
+        ctx: *mut c_void,
+        requests: *mut PaimonVindexReadRequest,
+        request_count: usize,
+    ) -> c_int {
+        let state = unsafe { &mut *(ctx as *mut BatchReadState) };
+        state.calls += 1;
+        state.range_count = request_count;
+        let requests = unsafe { std::slice::from_raw_parts_mut(requests, 
request_count) };
+        for request in requests {
+            let start = request.offset as usize;
+            let end = start + request.len;
+            let destination = unsafe { 
std::slice::from_raw_parts_mut(request.buf, request.len) };
+            destination.copy_from_slice(&state.data[start..end]);
+        }
+        0
+    }
+
+    #[test]
+    fn ffi_pread_forwards_all_ranges_in_one_callback() {
+        let mut state = BatchReadState {
+            data: (0u8..32).collect(),
+            calls: 0,
+            range_count: 0,
+        };
+        let raw = PaimonVindexInputFile {
+            ctx: (&mut state as *mut BatchReadState).cast(),
+            read_ranges_fn: Some(read_ranges),
+        };
+        let mut input = FfiInputFile { raw };
+        let mut first = [0u8; 3];
+        let mut second = [0u8; 4];
+
+        input
+            .pread(&mut [
+                ReadRequest::new(2, &mut first),
+                ReadRequest::new(11, &mut second),
+            ])
+            .unwrap();
+
+        assert_eq!(state.calls, 1);
+        assert_eq!(state.range_count, 2);
+        assert_eq!(first, [2, 3, 4]);
+        assert_eq!(second, [11, 12, 13, 14]);
+    }
+
+    #[test]
+    fn ffi_pread_skips_callback_for_empty_ranges() {
+        let mut state = BatchReadState {
+            data: Vec::new(),
+            calls: 0,
+            range_count: 0,
+        };
+        let raw = PaimonVindexInputFile {
+            ctx: (&mut state as *mut BatchReadState).cast(),
+            read_ranges_fn: Some(read_ranges),
+        };
+        let mut input = FfiInputFile { raw };
+
+        input.pread(&mut []).unwrap();
+
+        assert_eq!(state.calls, 0);
+    }
+}
diff --git a/include/paimon_vindex.hpp b/include/paimon_vindex.hpp
index 8584f09..294e482 100644
--- a/include/paimon_vindex.hpp
+++ b/include/paimon_vindex.hpp
@@ -51,8 +51,10 @@ struct OutputFile {
     std::function<int64_t()> get_pos_fn;
 };
 
+using ReadRequest = PaimonVindexReadRequest;
+
 struct InputFile {
-    std::function<int(uint64_t offset, uint8_t* buf, size_t len)> read_at_fn;
+    std::function<int(ReadRequest* requests, size_t request_count)> 
read_ranges_fn;
 };
 
 namespace detail {
@@ -86,10 +88,13 @@ inline int64_t stream_get_pos(void* ctx) noexcept {
     }
 }
 
-inline int input_read_at(void* ctx, uint64_t offset, uint8_t* buf, size_t len) 
noexcept {
+inline int input_read_ranges(
+        void* ctx,
+        PaimonVindexReadRequest* raw_requests,
+        size_t request_count) noexcept {
     try {
         auto* cbs = static_cast<InputFile*>(ctx);
-        return cbs->read_at_fn(offset, buf, len);
+        return cbs->read_ranges_fn(raw_requests, request_count);
     } catch (...) {
         return -1;
     }
@@ -326,7 +331,7 @@ public:
     explicit Reader(InputFile input) : 
input_(std::make_shared<InputFile>(std::move(input))) {
         PaimonVindexInputFile raw;
         raw.ctx = input_.get();
-        raw.read_at_fn = detail::input_read_at;
+        raw.read_ranges_fn = detail::input_read_ranges;
         handle_ = paimon_vindex_reader_open(raw);
         if (!handle_) throw Error("failed to open vector index reader");
     }
diff --git a/python/paimon_vindex/__init__.py b/python/paimon_vindex/__init__.py
index 4bdd4a3..2113220 100644
--- a/python/paimon_vindex/__init__.py
+++ b/python/paimon_vindex/__init__.py
@@ -136,6 +136,29 @@ def _option_arrays(options: Mapping[str, str]):
     return option_items, key_bytes, value_bytes, keys, values
 
 
+def _make_read_ranges_callback(input):
+    @_ffi.READ_RANGES_FN
+    def read_ranges_callback(ctx, requests, request_count):
+        try:
+            ranges = [
+                (requests[i].offset, requests[i].len)
+                for i in range(request_count)
+            ]
+            chunks = input.pread_many(ranges)
+            if len(chunks) != request_count:
+                return -1
+            for i, chunk in enumerate(chunks):
+                data = bytes(chunk)
+                if len(data) != requests[i].len:
+                    return -1
+                ctypes.memmove(requests[i].buf, data, len(data))
+            return 0
+        except Exception:
+            return -1
+
+    return read_ranges_callback
+
+
 class VectorIndexTraining:
     def __init__(self, handle):
         self._closed = False
@@ -382,24 +405,10 @@ class VectorIndexReader:
         self._input = input
         self._closed = False
 
-        @_ffi.READ_AT_FN
-        def read_at_callback(ctx, offset, buf, length):
-            try:
-                chunks = self._input.pread_many([(offset, length)])
-                if len(chunks) != 1:
-                    return -1
-                data = bytes(chunks[0])
-                if len(data) != length:
-                    return -1
-                ctypes.memmove(buf, data, length)
-                return 0
-            except Exception:
-                return -1
-
-        self._read_at_callback = read_at_callback
+        self._read_ranges_callback = _make_read_ranges_callback(self._input)
         input_file = _ffi.PaimonVindexInputFile()
         input_file.ctx = None
-        input_file.read_at_fn = self._read_at_callback
+        input_file.read_ranges_fn = self._read_ranges_callback
         self._handle = lib.paimon_vindex_reader_open(input_file)
         if not self._handle:
             _check_error("failed to open reader")
diff --git a/python/paimon_vindex/_ffi.py b/python/paimon_vindex/_ffi.py
index c084a2e..fbd87fc 100644
--- a/python/paimon_vindex/_ffi.py
+++ b/python/paimon_vindex/_ffi.py
@@ -85,7 +85,22 @@ lib = _load_library()
 WRITE_FN = CFUNCTYPE(c_int, c_void_p, POINTER(c_uint8), c_size_t)
 FLUSH_FN = CFUNCTYPE(c_int, c_void_p)
 GET_POS_FN = CFUNCTYPE(c_int64, c_void_p)
-READ_AT_FN = CFUNCTYPE(c_int, c_void_p, c_uint64, POINTER(c_uint8), c_size_t)
+
+
+class PaimonVindexReadRequest(Structure):
+    _fields_ = [
+        ("offset", c_uint64),
+        ("buf", POINTER(c_uint8)),
+        ("len", c_size_t),
+    ]
+
+
+READ_RANGES_FN = CFUNCTYPE(
+    c_int,
+    c_void_p,
+    POINTER(PaimonVindexReadRequest),
+    c_size_t,
+)
 
 
 class PaimonVindexOutputFile(Structure):
@@ -100,7 +115,7 @@ class PaimonVindexOutputFile(Structure):
 class PaimonVindexInputFile(Structure):
     _fields_ = [
         ("ctx", c_void_p),
-        ("read_at_fn", READ_AT_FN),
+        ("read_ranges_fn", READ_RANGES_FN),
     ]
 
 
diff --git a/python/tests/test_vindex.py b/python/tests/test_vindex.py
index e4c0f94..c8c8ebf 100644
--- a/python/tests/test_vindex.py
+++ b/python/tests/test_vindex.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import ctypes
 import io
 
 import numpy as np
@@ -55,6 +56,34 @@ def reader_from_bytes(data):
     return VectorIndexReader(VectorIndexInput(data))
 
 
+def test_python_read_callback_forwards_ranges_in_one_batch():
+    from paimon_vindex import _make_read_ranges_callback
+    from paimon_vindex import _ffi
+
+    class RecordingInput(VectorIndexInput):
+        def __init__(self, data):
+            super().__init__(data)
+            self.calls = []
+
+        def pread_many(self, ranges):
+            self.calls.append(list(ranges))
+            return super().pread_many(ranges)
+
+    source = RecordingInput(bytes(range(32)))
+    callback = _make_read_ranges_callback(source)
+    first = (ctypes.c_uint8 * 3)()
+    second = (ctypes.c_uint8 * 4)()
+    requests = (_ffi.PaimonVindexReadRequest * 2)(
+        _ffi.PaimonVindexReadRequest(2, first, 3),
+        _ffi.PaimonVindexReadRequest(11, second, 4),
+    )
+
+    assert callback(None, requests, 2) == 0
+    assert source.calls == [[(2, 3), (11, 4)]]
+    assert bytes(first) == bytes([2, 3, 4])
+    assert bytes(second) == bytes([11, 12, 13, 14])
+
+
 def test_python_ffi_roundtrips_supported_indexes():
     configs = [
         (
diff --git a/tools/README.md b/tools/README.md
index e6a55a7..ff94ed5 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -1,3 +1,22 @@
+<!--
+  ~ 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.
+  -->
+
 # Release tools
 
 This directory contains helper scripts used by release managers and committers.
diff --git a/tools/check_license_headers.py b/tools/check_license_headers.py
index ec763f4..7add236 100755
--- a/tools/check_license_headers.py
+++ b/tools/check_license_headers.py
@@ -38,6 +38,7 @@ EXEMPT_FILES = {
     "core/tests/fixtures/ivf_hnsw_sq_v1.hex",
     "core/tests/fixtures/ivf_pq_4bit_v1.hex",
     "core/tests/fixtures/ivf_pq_v1.hex",
+    "core/tests/fixtures/ivf_rq_v1.hex",
 }
 
 

Reply via email to