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 e93ab384de [python] Evaluate Ray vector prefilters on workers (#10446)
e93ab384de is described below

commit e93ab384dee11d690752507a1c78c6b9aa98306b
Author: chaoyang <[email protected]>
AuthorDate: Fri Oct 9 21:57:18 2026 +0800

    [python] Evaluate Ray vector prefilters on workers (#10446)
---
 docs/docs/pypaimon/multimodal-search.md            |   9 ++
 paimon-python/pypaimon/ray/batch_vector_search.py  |  32 +++--
 paimon-python/pypaimon/ray/vector_search.py        |  95 +++++++++++---
 .../table/source/global_index_live_row_filter.py   |  26 ++--
 .../pypaimon/table/source/vector_search_read.py    |  25 ++--
 .../tests/global_index_live_row_filter_test.py     |  49 +++++++
 .../pypaimon/tests/ray_batch_vector_search_test.py |  10 +-
 .../tests/ray_vector_filter_exactness_test.py      | 143 +++++++++++++++++++--
 .../pypaimon/tests/ray_vector_search_test.py       |   6 +-
 9 files changed, 330 insertions(+), 65 deletions(-)

diff --git a/docs/docs/pypaimon/multimodal-search.md 
b/docs/docs/pypaimon/multimodal-search.md
index 47b4f8698f..b317e75180 100644
--- a/docs/docs/pypaimon/multimodal-search.md
+++ b/docs/docs/pypaimon/multimodal-search.md
@@ -248,6 +248,15 @@ filtering and final row lookup.
 In particular, `pre_filter` filters candidates before ranking; `where()` 
filters
 the selected rows and can return fewer than the requested number of results.
 
+For indexed vector searches, workers evaluate scalar predicates and build
+live-row filters at the query snapshot. Tasks using the same scalar index files
+share one Ray scalar-index evaluation; its result is passed directly to 
dependent
+tasks without materializing the bitmap on the driver. Candidate verification
+reads only filter columns and row IDs in the task's shard range, without 
scanning
+the scalar indexes again. The final shard filter is reused across a batch's 
query
+vectors. This lets expensive verification scale with search concurrency; 
partially
+overlapping scalar index inputs can still cause repeated index reads.
+
 Index search, raw scans, refinement, lookup, and task retries use the same read
 snapshot. The existing `snapshot_id` and `tag_name` arguments to `search()` 
also
 work with Ray execution. A failed task fails the query rather than returning
diff --git a/paimon-python/pypaimon/ray/batch_vector_search.py 
b/paimon-python/pypaimon/ray/batch_vector_search.py
index b7b4303a6f..1a226020cb 100644
--- a/paimon-python/pypaimon/ray/batch_vector_search.py
+++ b/paimon-python/pypaimon/ray/batch_vector_search.py
@@ -21,11 +21,12 @@ from contextlib import closing
 
 from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
-from pypaimon.ray.vector_search import _execution_options, _map_tasks, 
_require_ray
+from pypaimon.ray.vector_search import (
+    _execution_options, _map_tasks, _require_ray, _shared_scalar_filters, 
_worker_pre_filter,
+)
 from pypaimon.table.source.vector_search_read import (
     BatchVectorSearchReadImpl, _filtered_raw_row_ranges, _offer_score, 
_scored_result,
 )
-from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
 def _execute_batch_vector_search(builder, *, concurrency=None, 
ray_remote_args=None):
@@ -47,12 +48,14 @@ class _RayBatchVectorSearchRead(BatchVectorSearchReadImpl):
         self._concurrency = concurrency
         self._remote_args = remote_args
 
-    def _search_index_splits(self, splits, query, search_limit, pre_filters, 
batch=False):
-        items = [(split, None if not pre_filters or pre_filters[i] is None
-                  else pre_filters[i].serialize()) for i, split in 
enumerate(splits)]
-        context = (self._table, self._vector_column, query, search_limit, 
self._options)
-        with closing(_map_tasks(
-                _search_batch_index_split, context, items, self._concurrency, 
self._remote_args, True)) as tasks:
+    def _search_index_splits(self, splits, query, search_limit, snapshot, 
batch=False):
+        # Send predicates and metadata, leaving each worker to build and 
consume
+        # its own filter. Batch queries reuse the same filter for every vector.
+        context = (self._table, self._vector_column, query, search_limit, 
self._options,
+                   self._filter, self._partition_filter, snapshot)
+        with _shared_scalar_filters(splits, self._filter, self._remote_args) 
as prepare, closing(_map_tasks(
+                _search_batch_index_split, context, splits, self._concurrency,
+                self._remote_args, True, prepare)) as tasks:
             for _, (metric, scores) in tasks:
                 if metric is not None:
                     self._set_index_metric(metric)
@@ -97,11 +100,14 @@ class _RayBatchVectorSearchRead(BatchVectorSearchReadImpl):
         return [_scored_result(heap) for heap in heaps]
 
 
-def _search_batch_index_split(context, item):
-    table, column, queries, limit, options = context
-    split, include_bytes = item
-    scorer = BatchVectorSearchReadImpl(table, limit, column, queries, 
options=options)
-    include = None if include_bytes is None else 
RoaringBitmap64.deserialize(include_bytes)
+def _search_batch_index_split(context, split, scalar_filter=None):
+    table, column, queries, limit, options, filter_, partition_filter, 
snapshot = context
+    # Explicit shard indexes are evaluated below. Candidate verification must
+    # not rediscover and scan table-wide scalar indexes in every worker.
+    table = table.copy({"global-index.enabled": "false"})
+    scorer = BatchVectorSearchReadImpl(
+        table, limit, column, queries, filter_, partition_filter, options)
+    include = _worker_pre_filter(scorer, split, snapshot, scalar_filter)
     reader, offset_reader = scorer._open_offset_reader(
         split.vector_index_files, split.row_range_start, split.row_range_end)
     try:
diff --git a/paimon-python/pypaimon/ray/vector_search.py 
b/paimon-python/pypaimon/ray/vector_search.py
index 9e99425827..0b592ec887 100644
--- a/paimon-python/pypaimon/ray/vector_search.py
+++ b/paimon-python/pypaimon/ray/vector_search.py
@@ -17,9 +17,11 @@
 
 """Ray execution of snapshot-pinned, single-vector queries."""
 
-from contextlib import closing
+from collections import Counter
+from contextlib import closing, contextmanager
 import math
 
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
 from pypaimon.table.source.vector_search_read import (
     DataEvolutionVectorRead,
@@ -27,6 +29,8 @@ from pypaimon.table.source.vector_search_read import (
     _offer_score,
     _scored_result,
 )
+from pypaimon.table.source import global_index_live_row_filter
+from pypaimon.utils.range import Range
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
@@ -74,14 +78,13 @@ class _RayVectorSearchRead(DataEvolutionVectorRead):
         self._concurrency = concurrency
         self._remote_args = remote_args
 
-    def _search_index_splits(self, splits, query, search_limit, pre_filters, 
batch=False):
-        # Filters are planned once on the driver, at the query snapshot. Each
-        # worker receives only its own include-row bitmap, not a table-wide 
set.
-        items = [(split, None if not pre_filters or pre_filters[i] is None
-                  else pre_filters[i].serialize()) for i, split in 
enumerate(splits)]
-        context = (self._table, self._vector_column, query, search_limit, 
self._options)
-        with closing(_map_tasks(
-                _search_index_split, context, items, self._concurrency, 
self._remote_args, True)) as tasks:
+    def _search_index_splits(self, splits, query, search_limit, snapshot, 
batch=False):
+        # Send predicates and metadata, leaving each worker to build and 
consume
+        # its own filter. Batch queries reuse the same filter for every vector.
+        context = (self._table, self._vector_column, query, search_limit, 
self._options,
+                   self._filter, self._partition_filter, snapshot)
+        with _shared_scalar_filters(splits, self._filter, self._remote_args) 
as prepare, closing(_map_tasks(
+                _search_index_split, context, splits, self._concurrency, 
self._remote_args, True, prepare)) as tasks:
             for _, (metric, scores) in tasks:
                 if metric is not None:
                     self._set_index_metric(metric)
@@ -114,11 +117,14 @@ class _RayVectorSearchRead(DataEvolutionVectorRead):
         return _scored_result(heap)
 
 
-def _search_index_split(context, item):
-    table, column, query, limit, options = context
-    split, include_bytes = item
-    reader = DataEvolutionVectorRead(table, limit, column, query, 
options=options)
-    include = None if include_bytes is None else 
RoaringBitmap64.deserialize(include_bytes)
+def _search_index_split(context, split, scalar_filter=None):
+    table, column, query, limit, options, filter_, partition_filter, snapshot 
= context
+    # Explicit shard indexes are evaluated below. Candidate verification must
+    # not rediscover and scan table-wide scalar indexes in every worker.
+    table = table.copy({"global-index.enabled": "false"})
+    reader = DataEvolutionVectorRead(
+        table, limit, column, query, filter_, partition_filter, options)
+    include = _worker_pre_filter(reader, split, snapshot, scalar_filter)
     result = reader._eval(
         split.row_range_start, split.row_range_end,
         split.vector_index_files, query, limit, include).result()
@@ -128,6 +134,62 @@ def _search_index_split(context, item):
     return reader._index_metric, _scores(result)
 
 
+def _worker_pre_filter(reader, split, snapshot, scalar_filter):
+    live_rows = global_index_live_row_filter.live_rows(
+        reader._table, reader._partition_filter, snapshot,
+        row_ranges=[Range(split.row_range_start, split.row_range_end)])
+    if scalar_filter is None:
+        matched = reader._scalar_matched_rows([split], snapshot)
+    else:
+        bitmap, exact = scalar_filter
+        result = None if bitmap is None else GlobalIndexResult.create(
+            RoaringBitmap64.deserialize(bitmap), is_exact=exact)
+        matched = reader._refine_scalar_result([split], result, snapshot)
+    filters = reader._combine_pre_filters([split], live_rows, matched)
+    return filters[0] if filters else None
+
+
+def _prepare_scalar_filter(context, split):
+    table, column, query, limit, options, filter_, partition_filter, snapshot 
= context
+    reader = DataEvolutionVectorRead(table, limit, column, query, filter_, 
partition_filter, options)
+    result = reader._scalar_index_result([split], snapshot)
+    return (None, False) if result is None else (result.results().serialize(), 
result.is_exact())
+
+
+@contextmanager
+def _shared_scalar_filters(splits, predicate, remote_args):
+    """Evaluate identical scalar-index inputs once, without fetching masks on 
the driver."""
+    import ray
+
+    def key(split):
+        return tuple(sorted(f.file_name for f in split.scalar_index_files))
+
+    counts = Counter(key(split) for split in splits) if predicate is not None 
else {}
+    shared = {files for files, count in counts.items() if files and count > 1}
+    pending = {}
+    remote = ray.remote(_prepare_scalar_filter).options(**remote_args) if 
shared else None
+
+    def prepare(context_ref, split):
+        files = key(split) if shared else None
+        if files not in shared:
+            return split, None
+        if files not in pending:
+            pending[files] = remote.remote(context_ref, split)
+        # A top-level ObjectRef dependency is resolved before allocating the
+        # search worker, so waiting searches cannot starve their filter tasks.
+        return split, pending[files]
+
+    try:
+        yield prepare
+    finally:
+        for ref in pending.values():
+            try:
+                ray.cancel(ref)
+            except Exception:
+                # Preserve the search failure if the cluster is already gone.
+                pass
+
+
 def _search_raw_split(context, split):
     from pypaimon.read.table_read import _ClosableArrowBatchReader
 
@@ -148,7 +210,7 @@ def _scores(result):
     return scores
 
 
-def _map_tasks(worker, context, items, concurrency, remote_args, 
ordered=False):
+def _map_tasks(worker, context, items, concurrency, remote_args, 
ordered=False, prepare_item=None):
     """Bound in-flight work and, optionally, completed results awaiting plan 
order."""
     import ray
 
@@ -168,7 +230,8 @@ def _map_tasks(worker, context, items, concurrency, 
remote_args, ordered=False):
                 if item is None:
                     break
                 ordinal, split = item
-                pending[remote.remote(context_ref, split)] = ordinal
+                args = (split,) if prepare_item is None else 
prepare_item(context_ref, split)
+                pending[remote.remote(context_ref, *args)] = ordinal
             if not pending:
                 break
             ready, _ = ray.wait(list(pending), num_returns=1)
diff --git 
a/paimon-python/pypaimon/table/source/global_index_live_row_filter.py 
b/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
index fe59df49c2..7e8565f9e2 100644
--- a/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
+++ b/paimon-python/pypaimon/table/source/global_index_live_row_filter.py
@@ -20,17 +20,19 @@
 from typing import Optional
 
 from pypaimon.deletionvectors.deletion_vector import DeletionVector
+from pypaimon.globalindex.indexed_split import IndexedSplit
 from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.split import DataSplit
 from pypaimon.utils.range import Range
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
-def live_rows(table, partition_filter=None, snapshot=None) -> 
Optional[RoaringBitmap64]:
+def live_rows(table, partition_filter=None, snapshot=None, row_ranges=None) -> 
Optional[RoaringBitmap64]:
     """Return live global row ids at ``snapshot`` for deletion-vector tables.
 
     ``None`` means no live-row filter is needed. This keeps tables without
     deletion vectors on the old zero-overhead path.
+    ``row_ranges`` restricts both file planning and the returned live-row 
bitmap.
     """
     options = getattr(table, "options", None)
     deletion_vectors_enabled = getattr(options, "deletion_vectors_enabled", 
None)
@@ -44,16 +46,20 @@ def live_rows(table, partition_filter=None, snapshot=None) 
-> Optional[RoaringBi
     if partition_filter is not None:
         read_builder = read_builder.with_partition_filter(partition_filter)
 
-    data_splits = [
-        (split.split if isinstance(split, QueryAuthSplit) else split)
-        for split in read_builder.new_scan().plan().splits()
-    ]
-    data_splits = [s for s in data_splits if isinstance(s, DataSplit)]
+    scan = read_builder.new_scan()
+    if row_ranges is not None:
+        scan = scan.with_row_ranges(row_ranges)
+    data_splits = []
+    for split in scan.plan().splits():
+        while isinstance(split, (QueryAuthSplit, IndexedSplit)):
+            split = split.split if isinstance(split, QueryAuthSplit) else 
split.data_split()
+        if isinstance(split, DataSplit):
+            data_splits.append(split)
 
     rows = RoaringBitmap64()
     # Phase 1: union every file's row-id range.
     for split in data_splits:
-        _add_row_ranges(rows, split)
+        _add_row_ranges(rows, split, row_ranges)
     # Phase 2: subtract each DV. Ranges are all unioned first (no re-add), and
     # peak memory stays at one DV at a time.
     for split in data_splits:
@@ -81,11 +87,13 @@ def for_range(live_row_ids: Optional[RoaringBitmap64],
     return None if include.cardinality() == row_range.count() else include
 
 
-def _add_row_ranges(rows: RoaringBitmap64, split: DataSplit) -> None:
+def _add_row_ranges(rows: RoaringBitmap64, split: DataSplit, row_ranges=None) 
-> None:
     for data_file in split.files:
         row_id_range = data_file.row_id_range()
         if row_id_range is not None:
-            rows.add_range(row_id_range.from_, row_id_range.to)
+            ranges = [row_id_range] if row_ranges is None else 
Range.and_([row_id_range], row_ranges)
+            for r in ranges:
+                rows.add_range(r.from_, r.to)
 
 
 def _subtract_deleted_rows(table, rows: RoaringBitmap64, split: DataSplit) -> 
None:
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 0df1fec983..f90d0a4d93 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -133,6 +133,10 @@ class AbstractVectorSearchReadImpl:
         live_rows = global_index_live_row_filter.live_rows(
             self._table, self._partition_filter, snapshot)
         matched_rows = self._scalar_matched_rows(splits, snapshot)
+        return self._combine_pre_filters(splits, live_rows, matched_rows)
+
+    @staticmethod
+    def _combine_pre_filters(splits, live_rows, matched_rows):
         if live_rows is None and matched_rows is None:
             return []
 
@@ -158,6 +162,10 @@ class AbstractVectorSearchReadImpl:
         if self._filter is None:
             return None
 
+        return self._refine_scalar_result(splits, 
self._scalar_index_result(splits, snapshot), snapshot)
+
+    def _scalar_index_result(self, splits, snapshot=None):
+        """Evaluate scalar indexes without reading candidate rows."""
         # Collect scalar index files across splits, deduplicated by file name.
         seen = set()
         scalar_files = []
@@ -169,7 +177,7 @@ class AbstractVectorSearchReadImpl:
                 scalar_files.append(index_file)
 
         if not scalar_files:
-            return RoaringBitmap64()
+            return GlobalIndexResult.create_empty()
 
         from pypaimon.globalindex.data_evolution_global_index_scanner import 
DataEvolutionGlobalIndexScanner
         scanner = DataEvolutionGlobalIndexScanner.create(
@@ -179,11 +187,13 @@ class AbstractVectorSearchReadImpl:
             snapshot=snapshot,
         )
         if scanner is None:
-            return RoaringBitmap64()
+            return GlobalIndexResult.create_empty()
         try:
-            result = scanner.scan(self._filter)
+            return scanner.scan(self._filter)
         finally:
             scanner.close()
+
+    def _refine_scalar_result(self, splits, result, snapshot=None):
         if result is not None and result.is_exact():
             return result.results()
         if not self._table.options.global_index_filter_refine_from_data():
@@ -489,7 +499,8 @@ class AbstractVectorSearchReadImpl:
         future.add_done_callback(lambda _: reader.close())
         return future
 
-    def _search_index_splits(self, splits, query, search_limit, pre_filters, 
batch=False):
+    def _search_index_splits(self, splits, query, search_limit, snapshot, 
batch=False):
+        pre_filters = self._pre_filters(splits, snapshot)
         # Native readers finish their search before returning a completed 
Future.
         # Schedule the entire open/search/close operation, not just 
Future.result().
         option = CoreOptions.GLOBAL_INDEX_THREAD_NUM
@@ -681,10 +692,9 @@ class 
DataEvolutionVectorRead(AbstractVectorSearchReadImpl, VectorSearchRead):
     def _read_indexed(self, splits, query_vector, snapshot):
         index_type = _vector_index_type(splits)
         search_limit = self._indexed_search_limit(index_type)
-        pre_filters = self._pre_filters(splits, snapshot)
         merged_scores = {}
         with closing(self._search_index_splits(
-                splits, query_vector, search_limit, pre_filters)) as results:
+                splits, query_vector, search_limit, snapshot)) as results:
             for split_result in results:
                 _merge_index_scores(merged_scores, split_result)
                 del split_result
@@ -717,11 +727,10 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
         # passing that split's pre-filter. Each future returns n per-query 
results.
         index_type = _vector_index_type(index_splits)
         search_limit = self._indexed_search_limit(index_type)
-        pre_filters = self._pre_filters(index_splits, snapshot)
         # Merge each query vector's indexed results across index splits.
         merged_scores = [{} for _ in range(n)]
         with closing(self._search_index_splits(
-                index_splits, self._query_vectors, search_limit, pre_filters, 
batch=True)) as results:
+                index_splits, self._query_vectors, search_limit, snapshot, 
batch=True)) as results:
             for split_results in results:
                 for i in range(n):
                     _merge_index_scores(merged_scores[i], split_results[i])
diff --git a/paimon-python/pypaimon/tests/global_index_live_row_filter_test.py 
b/paimon-python/pypaimon/tests/global_index_live_row_filter_test.py
new file mode 100644
index 0000000000..4348cfed71
--- /dev/null
+++ b/paimon-python/pypaimon/tests/global_index_live_row_filter_test.py
@@ -0,0 +1,49 @@
+# 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.
+
+from unittest.mock import patch
+
+import pyarrow as pa
+
+import pypaimon.multimodal as pm
+from pypaimon.deletionvectors.deletion_vector import DeletionVector
+from pypaimon.table.source.global_index_live_row_filter import live_rows
+from pypaimon.utils.range import Range
+
+
+def test_scoped_live_rows_only_load_relevant_deletions(tmp_path):
+    table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
+        "rows", schema=pa.schema([("id", pa.int64())]), 
options={"file.format": "parquet"})
+    table.add(pa.table({"id": [0, 1, 2]}))
+    table.add(pa.table({"id": [3, 4, 5]}))
+    snapshot = table.raw_table.snapshot_manager().get_latest_snapshot()
+    table.delete("id = 0 OR id = 3")
+    with patch.object(DeletionVector, "read", wraps=DeletionVector.read) as 
read:
+        assert list(live_rows(table.raw_table)) == [1, 2, 4, 5]
+        all_reads = read.call_count
+        read.reset_mock()
+        # These bounds lie inside a file range and produce IndexedSplit 
wrappers.
+        assert list(live_rows(table.raw_table, row_ranges=[Range(0, 1)])) == 
[1]
+        assert 0 < read.call_count < all_reads
+        read.reset_mock()
+        assert list(live_rows(table.raw_table, row_ranges=[Range(3, 4)])) == 
[4]
+        assert 0 < read.call_count < all_reads
+        read.reset_mock()
+        assert list(live_rows(table.raw_table, snapshot=snapshot, 
row_ranges=[Range(0, 1)])) == [0, 1]
+        read.assert_not_called()
+        assert list(live_rows(table.raw_table, row_ranges=[])) == []
+        read.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/ray_batch_vector_search_test.py 
b/paimon-python/pypaimon/tests/ray_batch_vector_search_test.py
index a28c82f90b..05a8e1d6a8 100644
--- a/paimon-python/pypaimon/tests/ray_batch_vector_search_test.py
+++ b/paimon-python/pypaimon/tests/ray_batch_vector_search_test.py
@@ -252,7 +252,7 @@ def test_global_candidates_and_duplicate_precedence(table):
             return
         # First query must exclude exact nearest row 3; second query has its 
own candidates.
         # Duplicate row 0 must retain the earlier split's score for the second 
query.
-        assert args[-1] is True
+        assert args[2] is True
         yield 0, ("l2", [{0: 10., 1: 9.}, {0: 1., 1: 9.}])
         yield 1, ("l2", [{2: 8., 3: 7.}, {0: 100., 2: 8., 3: 7.}])
 
@@ -277,7 +277,7 @@ def test_metric_mismatch_in_empty_shard_closes_tasks(table):
 
     with patch.object(search_module, "_map_tasks", completed), \
             pytest.raises(ValueError, match="different metrics"):
-        list(distributed._search_index_splits([None, None], QUERIES, 1, [], 
batch=True))
+        list(distributed._search_index_splits([None, None], QUERIES, 1, None, 
batch=True))
     assert closed == [True]
 
 
@@ -297,13 +297,13 @@ def 
test_index_reader_is_reused_and_closed_between_query_blocks(table, fail_seco
 
     offset = SimpleNamespace(visit_batch_vector_search=search)
     split = IndexVectorSearchSplit(0, 1, [])
-    context = (reader._table, reader._vector_column, queries, 1, {})
+    context = (reader._table, reader._vector_column, queries, 1, {}, None, 
None, None)
     with patch.object(BatchVectorSearchReadImpl, "_open_offset_reader", 
return_value=(native, offset)) as opened:
         if fail_second_block:
             with pytest.raises(ValueError, match="failed query block"):
-                search_module._search_batch_index_split(context, (split, None))
+                search_module._search_batch_index_split(context, split)
         else:
-            _, results = search_module._search_batch_index_split(context, 
(split, None))
+            _, results = search_module._search_batch_index_split(context, 
split)
             assert results == [{0: 1.}] * 65
     assert sizes == [64, 1]
     opened.assert_called_once()
diff --git a/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py 
b/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
index eae220840a..983ba96ef8 100644
--- a/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
+++ b/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
@@ -38,23 +38,144 @@ def 
test_ray_applies_exact_row_filter_before_worker_top_k(table, ray_cluster, ba
     scalar_index(table)
     table.raw_table = table.raw_table.copy({
         "vector-index.search-mode": mode, 
"global-index.filter.refine-from-data": str(refine).lower()})
-    original = AbstractVectorSearchReadImpl._matching_candidate_rows
-    arrow_read = TableRead._new_arrow_batch_reader
+    from pypaimon.ray import batch_vector_search, vector_search
+    from pypaimon.table.source import global_index_live_row_filter
+
+    module = batch_vector_search if batch else vector_search
+    original_map = module._map_tasks
     calls = []
 
-    def no_vectors(read, *args, **kwargs):
-        assert "embedding" not in [field.name for field in read.read_type]
-        return arrow_read(read, *args, **kwargs)
+    def record(worker, context, items, *args):
+        def checked(context, split, scalar_filter=None):
+            # These patches run in the actual Ray worker, not on the driver.
+            original = AbstractVectorSearchReadImpl._matching_candidate_rows
+            arrow_read = TableRead._new_arrow_batch_reader
+            observed = []
+
+            def no_vectors(read, *args, **kwargs):
+                assert "embedding" not in [field.name for field in 
read.read_type]
+                return arrow_read(read, *args, **kwargs)
+
+            def verify(reader, candidates, snapshot):
+                observed.append(list(candidates))
+                assert snapshot is not None
+                assert all(split.row_range_start <= row_id <= 
split.row_range_end for row_id in candidates)
+                from pypaimon.globalindex.data_evolution_global_index_scanner 
import DataEvolutionGlobalIndexScanner
+                with patch.object(TableRead, "_new_arrow_batch_reader", 
no_vectors), \
+                        patch.object(DataEvolutionGlobalIndexScanner, "create",
+                                     
wraps=DataEvolutionGlobalIndexScanner.create) as scan:
+                    result = original(reader, candidates, snapshot)
+                    scan.assert_not_called()
+                    return result
 
-    def verify(reader, candidates, snapshot):
-        calls.append(list(candidates))
-        assert snapshot is not None
-        with patch.object(TableRead, "_new_arrow_batch_reader", no_vectors):
-            return original(reader, candidates, snapshot)
+            with patch.object(AbstractVectorSearchReadImpl, 
"_matching_candidate_rows", verify):
+                result = worker(context, split, scalar_filter)
+            return result, observed
 
-    with patch.object(AbstractVectorSearchReadImpl, 
"_matching_candidate_rows", verify):
+        for ordinal, (result, observed) in original_map(checked, context, 
items, *args):
+            calls.extend(observed)
+            yield ordinal, result
+
+    with patch.object(module, "_map_tasks", record), \
+            patch.object(AbstractVectorSearchReadImpl, "_pre_filters",
+                         side_effect=AssertionError("driver must not build 
filters")), \
+            patch.object(global_index_live_row_filter, "live_rows",
+                         side_effect=AssertionError("driver must not read 
deletion vectors")):
         result = query(table, "name LIKE '%zeta%'", 
batch).to_arrow(execution="ray", concurrency=2)
     actual = [value.to_pylist() for value in result] if batch else 
result.to_pylist()
     expected = [{"id": 1}] if refine else []
     assert actual == ([expected, expected] if batch else expected)
+    # The whole batch shares a single verification read in its index task.
     assert calls == ([[0, 1, 2]] if refine else [])
+
+
[email protected]("batch", [False, True])
[email protected]("kind", ["btree", "bitmap"])
+def 
test_worker_filters_overlapping_scalar_shards_at_planned_snapshot(tmp_path, 
ray_cluster, batch, kind):
+    import pyarrow as pa
+    import pypaimon.multimodal as pm
+    from pypaimon.ray import batch_vector_search, vector_search
+
+    pytest.importorskip("paimon_vindex")
+    schema = pa.schema([("id", pa.int64()), ("name", pa.string()),
+                        ("embedding", pa.list_(pa.float32(), 2))])
+    table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
+        "vectors", schema=schema, options={
+            "file.format": "parquet", "vector.file.format": "parquet",
+            "global-index.filter.refine-from-data": "true"})
+    for start in range(0, 12, 3):
+        row_ids = list(range(start, start + 3))
+        table.add(pa.table({"id": row_ids, "name": ["keep" if i % 2 == 0 else 
"drop" for i in row_ids],
+                            "embedding": [[float(i), 1.] for i in row_ids]}, 
schema=schema))
+    table.raw_table.create_global_index("embedding", "ivf-flat", options={
+        "global-index.row-count-per-shard": "3", "ivf-flat.nlist": "1"})
+    # Scalar and vector shard boundaries intentionally differ.
+    table.raw_table.copy({"deletion-vectors.enabled": 
"false"}).create_global_index(
+        "name", kind, options={"global-index.row-count-per-shard": "5"})
+    table.delete("id = 2")
+    search = table.search_vectors([[0., 1.]] * 2, pre_filter="name LIKE 
'%keep%'") if batch else table.search(
+        [0., 1.], pre_filter="name LIKE '%keep%'")
+    search = search.select(["id"]).limit(3)
+    expected = [{"id": 0}, {"id": 4}, {"id": 6}]
+    expected = [expected, expected] if batch else expected
+    assert search.to_list() == expected
+    module = batch_vector_search if batch else vector_search
+    original_map = module._map_tasks
+    dispatched = []
+
+    def commit_before_workers(worker, context, splits, *args):
+        if not dispatched:
+            table.delete("id = 0")
+            dispatched.append(True)
+        return original_map(worker, context, splits, *args)
+
+    with patch.object(module, "_map_tasks", commit_before_workers), \
+            patch.object(AbstractVectorSearchReadImpl, "_pre_filters",
+                         side_effect=AssertionError("driver filter")):
+        result = search.to_list(execution="ray", concurrency=2)
+    assert result == expected
+    assert dispatched == [True]
+    assert search.to_list(execution="ray", concurrency=2) == search.to_list()
+    assert search.to_list() != expected
+
+
[email protected]("batch", [False, True])
[email protected]("mode", ["exact", "candidate", "unavailable"])
+def test_shared_scalar_index_is_evaluated_once_on_ray(table, tmp_path, 
ray_cluster, batch, mode):
+    import os
+    from pathlib import Path
+
+    import pyarrow as pa
+    from pypaimon.ray import vector_search
+
+    schema = pa.schema([("id", pa.int64()), ("name", pa.string()),
+                        ("embedding", pa.list_(pa.float32(), 2))])
+    table.add(pa.table({"id": [3, 4, 5], "name": ["delta zeta", "epsilon", 
"zeta"],
+                        "embedding": [[0.5, 1.], [3., 1.], [4., 1.]]}, 
schema=schema))
+    table.raw_table.create_global_index("embedding", "ivf-flat", options={
+        "ivf-flat.nlist": "1", "ivf-flat.distance.metric": "l2"})
+    scalar_index(table, "bitmap" if mode == "exact" else "btree")
+    options = {"global-index.filter.refine-from-data": "true"}
+    if mode == "unavailable":
+        options["btree-index.fallback-scan-max-size"] = "0 b"
+    table.raw_table = table.raw_table.copy(options)
+    marker = str(tmp_path / "scalar-evaluations")
+    original = vector_search._prepare_scalar_filter
+
+    def record(context, split):
+        with open(marker, "a") as output:
+            output.write(str(os.getpid()) + "\n")
+        return original(context, split)
+
+    with patch.object(vector_search, "_prepare_scalar_filter", record), \
+            patch.object(AbstractVectorSearchReadImpl, "_scalar_index_result",
+                         side_effect=AssertionError("driver scalar 
evaluation")):
+        # Each task requires the whole two-CPU cluster. Pending searches must
+        # not occupy resources while waiting for their shared filter 
dependency.
+        result = query(table, "name LIKE '%zeta%'", batch).to_list(
+            execution="ray", concurrency=2, ray_remote_args={"num_cpus": 2})
+    expected = [{"id": 3}]
+    assert result == ([expected, expected] if batch else expected)
+    worker_pids = Path(marker).read_text().splitlines()
+    assert len(worker_pids) == 1
+    assert int(worker_pids[0]) != os.getpid()
diff --git a/paimon-python/pypaimon/tests/ray_vector_search_test.py 
b/paimon-python/pypaimon/tests/ray_vector_search_test.py
index ca1b361521..fe560bccdd 100644
--- a/paimon-python/pypaimon/tests/ray_vector_search_test.py
+++ b/paimon-python/pypaimon/tests/ray_vector_search_test.py
@@ -327,7 +327,7 @@ def test_refinement_uses_global_candidates(table):
             return
         # Row 3 is the true nearest, but is outside the GLOBAL approximate 
top-2.
         # Refining independently per shard would incorrectly bring it back in.
-        assert args[-1] is True  # Index tasks request plan-ordered results.
+        assert args[2] is True  # Index tasks request plan-ordered results.
         yield 0, ("l2", {0: 10., 1: 9.})
         yield 1, ("l2", {2: 8., 3: 7.})
 
@@ -348,7 +348,7 @@ def test_duplicate_scores_keep_plan_order(table):
     splits = [IndexVectorSearchSplit(0, 1, [index_file]), 
IndexVectorSearchSplit(0, 3, [index_file])]
 
     def completed(*args):
-        assert args[-1] is True
+        assert args[5] is True
         yield 0, ("l2", {0: 1., 1: 2.})
         yield 1, ("l2", {0: 100.})
 
@@ -376,7 +376,7 @@ def 
test_metric_mismatch_fails_even_when_one_shard_has_no_hits(table):
     with patch.object(search_module, "_map_tasks", completed), \
             pytest.raises(ValueError, match="different metrics"):
         list(distributed._search_index_splits(
-            [IndexVectorSearchSplit(0, 1, []), IndexVectorSearchSplit(2, 3, 
[])], [1., 1.], 1, []))
+            [IndexVectorSearchSplit(0, 1, []), IndexVectorSearchSplit(2, 3, 
[])], [1., 1.], 1, None))
     assert closed == [True]
 
 

Reply via email to