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 cd39bd7056 [python] Fix union global index reader semantics (#8742)
cd39bd7056 is described below

commit cd39bd70567eb27909142523c0b4faace2c8b540
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jul 20 13:37:00 2026 +0800

    [python] Fix union global index reader semantics (#8742)
    
    - propagate unsupported scalar results across every child of
    `UnionGlobalIndexReader`
    - make the default batch-vector fan-out non-blocking and preserve
    asynchronous failures
    - close every union child before propagating the first close failure
    - add focused regression tests for scalar, batch-vector, and close
    semantics
---
 .../pypaimon/globalindex/global_index_reader.py    |  35 ++++-
 .../globalindex/union_global_index_reader.py       |  61 +++++---
 .../tests/union_global_index_reader_test.py        | 160 +++++++++++++++++++++
 3 files changed, 228 insertions(+), 28 deletions(-)

diff --git a/paimon-python/pypaimon/globalindex/global_index_reader.py 
b/paimon-python/pypaimon/globalindex/global_index_reader.py
index d3915583ca..2a808cc34c 100644
--- a/paimon-python/pypaimon/globalindex/global_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/global_index_reader.py
@@ -17,6 +17,7 @@
 
 """Global index reader interface."""
 
+import threading
 from abc import ABC, abstractmethod
 from concurrent.futures import Future
 from typing import List, Optional
@@ -52,6 +53,32 @@ def _map_future(source, transform):
     return result
 
 
+def _collect_futures(futures):
+    """Collect futures in input order without blocking the caller."""
+    result = Future()
+    if not futures:
+        result.set_result([])
+        return result
+
+    remaining = [len(futures)]
+    lock = threading.Lock()
+
+    def on_done(_):
+        with lock:
+            remaining[0] -= 1
+            is_last = remaining[0] == 0
+        if not is_last:
+            return
+        try:
+            result.set_result([future.result() for future in futures])
+        except Exception as e:
+            result.set_exception(e)
+
+    for future in futures:
+        future.add_done_callback(on_done)
+    return result
+
+
 class GlobalIndexReader(ABC):
     """Index reader for global index. All visit methods return 
Future[Optional[GlobalIndexResult]]."""
 
@@ -61,14 +88,10 @@ class GlobalIndexReader(ABC):
     def visit_batch_vector_search(
             self, batch_vector_search: 'BatchVectorSearch'
     ) -> 'Future[List[Optional[GlobalIndexResult]]]':
-        """Default: fan out to single-vector search; result ``i`` maps to 
``vectors[i]``.
-
-        Blocks per future (fine while readers return completed futures); an
-        async reader should override.
-        """
+        """Fan out asynchronously; result ``i`` maps to ``vectors[i]``."""
         singles = [self.visit_vector_search(batch_vector_search.for_index(i))
                    for i in range(batch_vector_search.vector_count)]
-        return _completed_future([f.result() for f in singles])
+        return _collect_futures(singles)
 
     def visit_full_text_search(self, full_text_search: 'FullTextSearch') -> 
'Future[Optional[GlobalIndexResult]]':
         raise NotImplementedError("Full-text search not supported by this 
reader")
diff --git a/paimon-python/pypaimon/globalindex/union_global_index_reader.py 
b/paimon-python/pypaimon/globalindex/union_global_index_reader.py
index 40d5e57386..8230acd84f 100644
--- a/paimon-python/pypaimon/globalindex/union_global_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/union_global_index_reader.py
@@ -34,8 +34,13 @@ class UnionGlobalIndexReader(GlobalIndexReader):
     def __init__(self, readers: List[GlobalIndexReader]):
         self._readers = readers
 
-    def _union_futures(self, visitor: Callable[[GlobalIndexReader], 
'Future[Optional[GlobalIndexResult]]']
-                       ) -> 'Future[Optional[GlobalIndexResult]]':
+    def _union_futures(
+            self,
+            visitor: Callable[
+                [GlobalIndexReader], 'Future[Optional[GlobalIndexResult]]'
+            ],
+            propagate_unsupported: bool = False,
+    ) -> 'Future[Optional[GlobalIndexResult]]':
         futures = [visitor(reader) for reader in self._readers]
 
         if not futures:
@@ -51,9 +56,12 @@ class UnionGlobalIndexReader(GlobalIndexReader):
                 if remaining[0] == 0:
                     try:
                         result: Optional[GlobalIndexResult] = None
-                        for f in futures:
-                            current = f.result()
+                        results = [f.result() for f in futures]
+                        for current in results:
                             if current is None:
+                                if propagate_unsupported:
+                                    all_done.set_result(None)
+                                    return
                                 continue
                             if result is None:
                                 result = current
@@ -68,6 +76,11 @@ class UnionGlobalIndexReader(GlobalIndexReader):
 
         return all_done
 
+    def _union_scalar_futures(
+            self, visitor: Callable[[GlobalIndexReader], 
'Future[Optional[GlobalIndexResult]]']
+    ) -> 'Future[Optional[GlobalIndexResult]]':
+        return self._union_futures(visitor, propagate_unsupported=True)
+
     # ---- vector / full-text search ----------------------------------------
 
     def visit_vector_search(self, vector_search) -> 
'Future[Optional[GlobalIndexResult]]':
@@ -79,56 +92,60 @@ class UnionGlobalIndexReader(GlobalIndexReader):
     # ---- scalar predicates (every reader sees the visit) ------------------
 
     def visit_equal(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_equal(field_ref, literal))
+        return self._union_scalar_futures(lambda r: r.visit_equal(field_ref, 
literal))
 
     def visit_not_equal(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_not_equal(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_not_equal(field_ref, literal))
 
     def visit_less_than(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_less_than(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_less_than(field_ref, literal))
 
     def visit_less_or_equal(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_less_or_equal(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_less_or_equal(field_ref, literal))
 
     def visit_greater_than(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_greater_than(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_greater_than(field_ref, literal))
 
     def visit_greater_or_equal(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: 
r.visit_greater_or_equal(field_ref, literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_greater_or_equal(field_ref, literal))
 
     def visit_is_null(self, field_ref: FieldRef) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_is_null(field_ref))
+        return self._union_scalar_futures(lambda r: r.visit_is_null(field_ref))
 
     def visit_is_not_null(self, field_ref: FieldRef) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_is_not_null(field_ref))
+        return self._union_scalar_futures(lambda r: 
r.visit_is_not_null(field_ref))
 
     def visit_in(self, field_ref: FieldRef, literals) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_in(field_ref, literals))
+        return self._union_scalar_futures(lambda r: r.visit_in(field_ref, 
literals))
 
     def visit_not_in(self, field_ref: FieldRef, literals) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_not_in(field_ref, 
literals))
+        return self._union_scalar_futures(lambda r: r.visit_not_in(field_ref, 
literals))
 
     def visit_starts_with(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_starts_with(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_starts_with(field_ref, literal))
 
     def visit_ends_with(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_ends_with(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_ends_with(field_ref, literal))
 
     def visit_contains(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_contains(field_ref, 
literal))
+        return self._union_scalar_futures(lambda r: 
r.visit_contains(field_ref, literal))
 
     def visit_like(self, field_ref: FieldRef, literal) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_like(field_ref, literal))
+        return self._union_scalar_futures(lambda r: r.visit_like(field_ref, 
literal))
 
     def visit_between(self, field_ref: FieldRef, from_v, to_v) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_between(field_ref, 
from_v, to_v))
+        return self._union_scalar_futures(lambda r: r.visit_between(field_ref, 
from_v, to_v))
 
     def visit_not_between(self, field_ref: FieldRef, from_v, to_v) -> 
'Future[Optional[GlobalIndexResult]]':
-        return self._union_futures(lambda r: r.visit_not_between(field_ref, 
from_v, to_v))
+        return self._union_scalar_futures(lambda r: 
r.visit_not_between(field_ref, from_v, to_v))
 
     def close(self) -> None:
+        first_error: Optional[Exception] = None
         for reader in self._readers:
             try:
                 reader.close()
-            except Exception:
-                pass
+            except Exception as e:
+                if first_error is None:
+                    first_error = e
+        if first_error is not None:
+            raise first_error
diff --git a/paimon-python/pypaimon/tests/union_global_index_reader_test.py 
b/paimon-python/pypaimon/tests/union_global_index_reader_test.py
new file mode 100644
index 0000000000..935bf45f31
--- /dev/null
+++ b/paimon-python/pypaimon/tests/union_global_index_reader_test.py
@@ -0,0 +1,160 @@
+# 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 unittest
+from concurrent.futures import Future, ThreadPoolExecutor
+from unittest.mock import Mock
+
+from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
+from pypaimon.globalindex.global_index_reader import (
+    FieldRef,
+    GlobalIndexReader,
+    _completed_future,
+)
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.globalindex.union_global_index_reader import 
UnionGlobalIndexReader
+from pypaimon.utils.range import Range
+
+
+class _EqualReader(GlobalIndexReader):
+
+    def __init__(self, result):
+        self._result = result
+
+    def visit_equal(self, field_ref, literal):
+        return _completed_future(self._result)
+
+    def close(self):
+        pass
+
+
+class _VectorReader(GlobalIndexReader):
+
+    def __init__(self, result_future):
+        self._result_future = result_future
+
+    def visit_vector_search(self, vector_search):
+        return self._result_future
+
+    def close(self):
+        pass
+
+
+class UnionGlobalIndexReaderTest(unittest.TestCase):
+
+    def test_scalar_unsupported_is_propagated(self):
+        supported = GlobalIndexResult.from_range(Range(1, 2))
+        reader = UnionGlobalIndexReader([_EqualReader(supported), 
_EqualReader(None)])
+
+        result = reader.visit_equal(FieldRef(0, "f0", "INT"), 1).result()
+
+        self.assertIsNone(result)
+
+    def test_scalar_child_failure_is_not_masked_by_unsupported(self):
+        field_ref = FieldRef(0, "f0", "INT")
+        unsupported_reader = Mock(spec=GlobalIndexReader)
+        unsupported_reader.visit_equal.return_value = _completed_future(None)
+        failed_future = Future()
+        failed_future.set_exception(RuntimeError("child failure"))
+        failed_reader = Mock(spec=GlobalIndexReader)
+        failed_reader.visit_equal.return_value = failed_future
+
+        for readers in [
+            [unsupported_reader, failed_reader],
+            [failed_reader, unsupported_reader],
+        ]:
+            with self.subTest(readers=readers):
+                reader = UnionGlobalIndexReader(readers)
+
+                with self.assertRaisesRegex(RuntimeError, "child failure"):
+                    reader.visit_equal(field_ref, 1).result()
+
+    def test_all_scalar_visitors_propagate_unsupported(self):
+        field_ref = FieldRef(0, "f0", "INT")
+        cases = [
+            ("visit_not_equal", (field_ref, 1)),
+            ("visit_less_than", (field_ref, 1)),
+            ("visit_less_or_equal", (field_ref, 1)),
+            ("visit_greater_than", (field_ref, 1)),
+            ("visit_greater_or_equal", (field_ref, 1)),
+            ("visit_is_null", (field_ref,)),
+            ("visit_is_not_null", (field_ref,)),
+            ("visit_in", (field_ref, [1, 2])),
+            ("visit_not_in", (field_ref, [1, 2])),
+            ("visit_starts_with", (field_ref, "1")),
+            ("visit_ends_with", (field_ref, "1")),
+            ("visit_contains", (field_ref, "1")),
+            ("visit_like", (field_ref, "1%")),
+            ("visit_between", (field_ref, 1, 2)),
+            ("visit_not_between", (field_ref, 1, 2)),
+        ]
+
+        for method_name, args in cases:
+            with self.subTest(method=method_name):
+                supported_reader = Mock(spec=GlobalIndexReader)
+                unsupported_reader = Mock(spec=GlobalIndexReader)
+                supported_result = GlobalIndexResult.from_range(Range(1, 2))
+                getattr(supported_reader, method_name).return_value = 
_completed_future(
+                    supported_result)
+                getattr(unsupported_reader, method_name).return_value = 
_completed_future(None)
+                reader = UnionGlobalIndexReader([supported_reader, 
unsupported_reader])
+
+                result = getattr(reader, method_name)(*args).result()
+
+                self.assertIsNone(result)
+
+    def test_batch_vector_search_returns_before_children_complete(self):
+        child_result = Future()
+        reader = UnionGlobalIndexReader([_VectorReader(child_result)])
+        search = BatchVectorSearch(vectors=[[1.0]], limit=1, field_name="f0")
+        executor = ThreadPoolExecutor(max_workers=1)
+        invocation = executor.submit(reader.visit_batch_vector_search, search)
+
+        try:
+            result_future = invocation.result(timeout=1)
+            self.assertFalse(result_future.done())
+        finally:
+            child_result.set_result(None)
+            executor.shutdown()
+
+        self.assertEqual([None], result_future.result(timeout=1))
+
+    def test_batch_vector_search_propagates_failure_asynchronously(self):
+        child_result = Future()
+        reader = UnionGlobalIndexReader([_VectorReader(child_result)])
+        search = BatchVectorSearch(vectors=[[1.0]], limit=1, field_name="f0")
+
+        result_future = reader.visit_batch_vector_search(search)
+        child_result.set_exception(RuntimeError("async failure"))
+
+        with self.assertRaisesRegex(RuntimeError, "async failure"):
+            result_future.result(timeout=1)
+
+    def test_close_propagates_failure_after_closing_all_readers(self):
+        failing_reader = Mock(spec=GlobalIndexReader)
+        failing_reader.close.side_effect = OSError("close failed")
+        remaining_reader = Mock(spec=GlobalIndexReader)
+        reader = UnionGlobalIndexReader([failing_reader, remaining_reader])
+
+        with self.assertRaisesRegex(OSError, "close failed"):
+            reader.close()
+
+        remaining_reader.close.assert_called_once_with()
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to