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 d41db48f02 [python] Evaluate Ray full-text prefilters on workers 
(#10584)
d41db48f02 is described below

commit d41db48f02da55f9806dc40bc7f2342e290eae5e
Author: chaoyang <[email protected]>
AuthorDate: Fri Oct 16 13:01:23 2026 +0800

    [python] Evaluate Ray full-text prefilters on workers (#10584)
---
 paimon-python/pypaimon/ray/full_text_search.py     | 65 ++++++++++++++-----
 paimon-python/pypaimon/ray/vector_search.py        |  4 +-
 .../pypaimon/table/source/full_text_read.py        | 34 ++++++----
 .../pypaimon/tests/ray_full_text_search_test.py    | 72 +++++++++++++++++++++-
 4 files changed, 143 insertions(+), 32 deletions(-)

diff --git a/paimon-python/pypaimon/ray/full_text_search.py 
b/paimon-python/pypaimon/ray/full_text_search.py
index a9d54e9e85..29114a0c36 100644
--- a/paimon-python/pypaimon/ray/full_text_search.py
+++ b/paimon-python/pypaimon/ray/full_text_search.py
@@ -21,10 +21,14 @@ from concurrent.futures import ThreadPoolExecutor, 
as_completed
 from contextlib import closing
 from threading import Event
 
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
-from pypaimon.ray.vector_search import _execution_options, _map_tasks, 
_require_ray, _scores
-from pypaimon.table.source.full_text_read import DataEvolutionFullTextRead
+from pypaimon.ray.vector_search import _execution_options, _map_tasks, 
_require_ray, _scores, _shared_scalar_filters
+from pypaimon.table.source.full_text_read import (
+    DataEvolutionFullTextRead, _index_type, _raw_row_ranges, 
_split_search_splits,
+)
 from pypaimon.table.source import global_index_live_row_filter
+from pypaimon.utils.range import Range
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
@@ -48,21 +52,26 @@ class _RayFullTextRead(DataEvolutionFullTextRead):
         self._remote_args = remote_args
 
     def _worker_reader(self):
-        return DataEvolutionFullTextRead(
-            self._table, self._limit, self._text_columns, self._query,
+        reader = DataEvolutionFullTextRead(
+            self._table.copy({"global-index.enabled": "false"}), self._limit, 
self._text_columns, self._query,
             self._partition_filter, self._filter)
+        reader._snapshot = self._snapshot
+        return reader
+
+    def read(self, splits):
+        # Keep bitmap construction and candidate data reads off the driver.
+        index_splits, raw_splits = _split_search_splits(splits)
+        indexed = self._eval_column_query({self._text_columns[0].name: 
index_splits}, None)
+        raw = self._read_raw_search(_raw_row_ranges(raw_splits), 
_index_type(index_splits))
+        return indexed.or_(raw).top_k(self._limit)
 
     def _eval_column_query(self, splits_by_column, live_rows):
-        items = []
-        for split in splits_by_column.get(self._text_columns[0].name, []):
-            include = global_index_live_row_filter.for_range(
-                live_rows, split.row_range_start, split.row_range_end)
-            if include is not None and include.is_empty():
-                continue
-            items.append((split, None if include is None else 
include.serialize()))
+        items = splits_by_column.get(self._text_columns[0].name, [])
         merged = {}
-        with closing(_map_tasks(_search_index, self._worker_reader(), items,
-                                self._concurrency, self._remote_args, True)) 
as tasks:
+        with _shared_scalar_filters(items, self._filter, self._remote_args,
+                                    _prepare_scalar_filter) as prepare, 
closing(_map_tasks(
+                                        _search_index, self._worker_reader(), 
items,
+                                        self._concurrency, self._remote_args, 
True, prepare)) as tasks:
             for _, scores in tasks:
                 # Duplicate row IDs keep the first planned shard's score.
                 for row_id, score in scores.items():
@@ -81,16 +90,40 @@ class _RayFullTextRead(DataEvolutionFullTextRead):
                 return DictBasedScoredIndexResult(scores)
 
 
-def _search_index(reader, item):
-    split, include = item
-    include = None if include is None else RoaringBitmap64.deserialize(include)
+def _search_index(reader, split, scalar_filter=None):
+    live = global_index_live_row_filter.live_rows(
+        reader._table, reader._partition_filter, reader._snapshot,
+        row_ranges=[Range(split.row_range_start, split.row_range_end)])
+    if scalar_filter is None:
+        include = reader._matched_indexed_rows([split], live)
+    else:
+        bitmap, exact = scalar_filter
+        result = None if bitmap is None else GlobalIndexResult.create(
+            RoaringBitmap64.deserialize(bitmap), is_exact=exact)
+        include = reader._matched_indexed_rows([split], live, result)
+    if include is not None and include.is_empty():
+        return {}
     return _scores(reader._eval(split.row_range_start, split.row_range_end,
                                 split.full_text_index_files, include).result())
 
 
+def _prepare_scalar_filter(reader, split):
+    result = reader._scalar_index_result(split.scalar_index_files, 
reader._snapshot)
+    return (None, False) if result is None else (result.results().serialize(), 
result.is_exact())
+
+
 def _search_raw(reader, item):
     ranges, index_type, include = item
     include = None if include is None else RoaringBitmap64.deserialize(include)
+    if reader._filter is not None:
+        live = global_index_live_row_filter.live_rows(
+            reader._table, reader._partition_filter, reader._snapshot, 
row_ranges=ranges)
+        candidates = GlobalIndexResult.from_ranges(ranges).results()
+        if live is not None:
+            candidates = RoaringBitmap64.and_(candidates, live)
+        if include is not None:
+            candidates = RoaringBitmap64.and_(candidates, include)
+        include = reader._matching_rows(candidates)
     return _scores(reader._read_raw_search(ranges, index_type, include))
 
 
diff --git a/paimon-python/pypaimon/ray/vector_search.py 
b/paimon-python/pypaimon/ray/vector_search.py
index 4e7239bfd6..fcc6a0e8c7 100644
--- a/paimon-python/pypaimon/ray/vector_search.py
+++ b/paimon-python/pypaimon/ray/vector_search.py
@@ -165,7 +165,7 @@ def _prepare_scalar_filter(context, split):
 
 
 @contextmanager
-def _shared_scalar_filters(splits, predicate, remote_args):
+def _shared_scalar_filters(splits, predicate, remote_args, worker=None):
     """Evaluate identical scalar-index inputs once, without fetching masks on 
the driver."""
     import ray
 
@@ -175,7 +175,7 @@ def _shared_scalar_filters(splits, predicate, remote_args):
     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
+    remote = ray.remote(worker or 
_prepare_scalar_filter).options(**remote_args) if shared else None
 
     def prepare(context_ref, split):
         files = key(split) if shared else None
diff --git a/paimon-python/pypaimon/table/source/full_text_read.py 
b/paimon-python/pypaimon/table/source/full_text_read.py
index 022e448c71..21cc5a5cf9 100644
--- a/paimon-python/pypaimon/table/source/full_text_read.py
+++ b/paimon-python/pypaimon/table/source/full_text_read.py
@@ -41,6 +41,8 @@ from pypaimon.table.source.full_text_scan import 
FullTextScanPlan
 from pypaimon.utils.range import Range
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
+_UNSET = object()
+
 
 class FullTextRead(ABC):
     """Full-text read to read index files."""
@@ -111,12 +113,11 @@ class DataEvolutionFullTextRead(FullTextRead):
         table = self._table.copy({'scalar-index.search-mode': 'full'})
         return matching_rows(table, self._filter, candidates, 
self._partition_filter)
 
-    def _matched_indexed_rows(self, splits, live_rows):
+    def _matched_indexed_rows(self, splits, live_rows, scalar_result=_UNSET):
         if self._filter is None:
             return live_rows
         from pypaimon.common.options.core_options import GlobalIndexSearchMode
         from pypaimon.globalindex.data_evolution_global_index_coverage import 
DataEvolutionGlobalIndexCoverage
-        from pypaimon.globalindex.data_evolution_global_index_scanner import 
DataEvolutionGlobalIndexScanner
         from pypaimon.snapshot.time_travel_util import TimeTravelUtil
         from pypaimon.table.source.global_index_row_filter import is_exact
 
@@ -140,17 +141,11 @@ class DataEvolutionFullTextRead(FullTextRead):
         decided = RoaringBitmap64.remove_all(covered, unindexed)
         matched = RoaringBitmap64()
         if not decided.is_empty():
-            scanner = DataEvolutionGlobalIndexScanner.create(self._table, 
index_files=scalar_files,
-                                                             
snapshot=snapshot, partition_filter=self._partition_filter)
-            evaluation = None
-            if scanner is not None:
-                try:
-                    evaluation = scanner.scan_with_coverage(self._filter)
-                finally:
-                    scanner.close()
-            if evaluation is not None:
-                candidates = RoaringBitmap64.and_(evaluation.result.results(), 
decided)
-                if is_exact(self._filter, evaluation.result):
+            if scalar_result is _UNSET:
+                scalar_result = self._scalar_index_result(scalar_files, 
snapshot)
+            if scalar_result is not None:
+                candidates = RoaringBitmap64.and_(scalar_result.results(), 
decided)
+                if is_exact(self._filter, scalar_result):
                     matched = candidates
                 elif 
self._table.options.global_index_filter_refine_from_data():
                     matched = self._matching_rows(candidates)
@@ -158,6 +153,19 @@ class DataEvolutionFullTextRead(FullTextRead):
                 unindexed = RoaringBitmap64.or_(unindexed, decided)
         return RoaringBitmap64.or_(matched, self._matching_rows(unindexed))
 
+    def _scalar_index_result(self, scalar_files, snapshot):
+        from pypaimon.globalindex.data_evolution_global_index_scanner import 
DataEvolutionGlobalIndexScanner
+
+        scanner = DataEvolutionGlobalIndexScanner.create(
+            self._table, index_files=scalar_files, snapshot=snapshot, 
partition_filter=self._partition_filter)
+        if scanner is None:
+            return None
+        try:
+            evaluation = scanner.scan_with_coverage(self._filter)
+            return None if evaluation is None else evaluation.result
+        finally:
+            scanner.close()
+
     def _eval_column_query(
             self,
             splits_by_column: Dict[str, List[IndexFullTextSearchSplit]],
diff --git a/paimon-python/pypaimon/tests/ray_full_text_search_test.py 
b/paimon-python/pypaimon/tests/ray_full_text_search_test.py
index 057662e6b8..0c7f1bcf44 100644
--- a/paimon-python/pypaimon/tests/ray_full_text_search_test.py
+++ b/paimon-python/pypaimon/tests/ray_full_text_search_test.py
@@ -91,5 +91,75 @@ def test_multiple_index_shards_and_worker_failure(docs, 
ray_cluster):
     assert query.to_list(execution="ray") == query.to_list()
 
 
-def _fail_index(reader, item):
+def _fail_index(reader, item, scalar_filter=None):
     raise RuntimeError("injected full-text failure")
+
+
[email protected]("scalar_index", [None, "btree", "bitmap"])
+def test_prefilter_and_deletion_vector_reads_stay_off_driver(docs, 
ray_cluster, scalar_index):
+    from pypaimon.table.source.full_text_read import DataEvolutionFullTextRead
+    from pypaimon.table.source import global_index_live_row_filter
+
+    if scalar_index:
+        docs.raw_table.copy({"deletion-vectors.enabled": 
"false"}).create_global_index("label", scalar_index)
+    text_fixtures.append_rows(docs)
+    docs.delete("id = 1")
+    query = docs.search("paimon", column="text", pre_filter="label LIKE 
'%target%'").select(
+        ["id"]).with_score().order_by_score()
+    expected = query.to_list()
+    # Workers load their own module globals; these guards apply to the driver.
+    with patch.object(DataEvolutionFullTextRead, "_matched_indexed_rows",
+                      side_effect=AssertionError("driver scalar evaluation")), 
\
+            patch.object(DataEvolutionFullTextRead, "_matching_rows",
+                         side_effect=AssertionError("driver candidate data 
read")), \
+            patch.object(global_index_live_row_filter, "live_rows",
+                         side_effect=AssertionError("driver deletion-vector 
read")):
+        assert query.to_list(execution="ray", concurrency=2) == expected
+
+
+def test_shared_scalar_indexes_are_evaluated_once(tmp_path, ray_cluster):
+    import pyarrow as pa
+
+    docs = pm.connect(options={"warehouse": str(tmp_path / 
"warehouse")}).create_table(
+        "docs", schema=pa.schema([("id", pa.int64()), ("text", pa.string()), 
("label", pa.string())]),
+        options={"file.format": "parquet", "write.native.enabled": "false", 
"commit.native.enabled": "false",
+                 "full-text-index.search-mode": "full"})
+    docs.add([{"id": i, "text": "paimon " * (i + 1), "label": "keep" if i % 2 
else "drop"}
+              for i in range(6)])
+    table = docs.raw_table.copy({"deletion-vectors.enabled": "false"})
+    table.create_global_index("label", "btree")
+    table.create_global_index("text", "full-text", 
options={"global-index.row-count-per-shard": "2"})
+    query = docs.search("paimon", column="text", pre_filter="label = 
'keep'").select(
+        ["id"]).with_score().order_by_score()
+    expected = query.to_list()
+    calls_path = str(tmp_path / "filter-calls")
+    original = module._prepare_scalar_filter
+
+    def record(reader, split):
+        with open(calls_path, "a") as calls:
+            calls.write("filter\n")
+        return original(reader, split)
+
+    with patch.object(module, "_prepare_scalar_filter", record):
+        assert query.to_list(execution="ray", concurrency=2) == expected
+    with open(calls_path) as calls:
+        assert calls.read().splitlines() == ["filter"]
+
+
+def test_worker_filters_use_planned_snapshot(docs, ray_cluster):
+    query = docs.search("paimon", column="text", pre_filter="id >= 
0").select(["id"]).order_by_score()
+    expected = query.to_list()
+    original = module._map_tasks
+    committed = []
+
+    def commit_before_dispatch(*args):
+        if not committed:
+            docs.delete("id = 0")
+            committed.append(True)
+        return original(*args)
+
+    with patch.object(module, "_map_tasks", commit_before_dispatch):
+        assert query.to_list(execution="ray", concurrency=2) == expected
+    assert committed == [True]
+    assert query.to_list(execution="ray") == query.to_list()
+    assert query.to_list() != expected

Reply via email to