From 2e60d80ea5ae8169fc2d25bf9a17587bd35addc1 Mon Sep 17 00:00:00 2001 From: chaoyang Date: Tue, 15 Sep 2026 17:29:16 +0800 Subject: [PATCH 1/2] [python] Keep local search and lookup on one snapshot --- docs/docs/pypaimon/multimodal-search.md | 8 + .../data_evolution_global_index_scanner.py | 2 + paimon-python/pypaimon/multimodal/query.py | 23 +- paimon-python/pypaimon/read/table_scan.py | 6 +- .../pypaimon/snapshot/time_travel_util.py | 10 + .../pypaimon/table/file_store_table.py | 23 +- .../pypaimon/table/source/full_text_read.py | 6 + .../pypaimon/table/source/full_text_scan.py | 17 +- .../source/global_index_live_row_filter.py | 13 +- .../table/source/hybrid_search_builder.py | 8 +- .../source/primary_key_full_text_scan.py | 20 +- .../table/source/primary_key_vector_scan.py | 20 +- .../table/source/vector_search_scan.py | 9 +- .../pypaimon/tests/multimodal_table_test.py | 38 ++- .../pypaimon/tests/search_snapshot_test.py | 227 ++++++++++++++++++ .../tests/vector_search_filter_test.py | 13 +- 16 files changed, 357 insertions(+), 86 deletions(-) create mode 100644 paimon-python/pypaimon/tests/search_snapshot_test.py diff --git a/docs/docs/pypaimon/multimodal-search.md b/docs/docs/pypaimon/multimodal-search.md index c2685f5b29ed..2a2940fe1c10 100644 --- a/docs/docs/pypaimon/multimodal-search.md +++ b/docs/docs/pypaimon/multimodal-search.md @@ -63,6 +63,14 @@ filter the rows read from the search result. Both `pre_filter` and `where()` accept SQL-like predicate strings. For full-text search, `pre_filter` must only reference partition columns. +Each execution of `search`, `search_vectors`, or `search_hybrid` reads one +snapshot across candidate search, filtering, reranking, and result lookup. +Concurrent commits become visible on the next execution, including when reusing +the same query object. Explicit snapshot, tag, and timestamp selectors are +honored. All routes in a hybrid search and all vectors in a batch share that +execution's snapshot. Keep the snapshot's data files available for the duration +of the query; capturing a read view does not prevent snapshot expiration. + ```python neighbors = ( docs.search( diff --git a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py index dde7f24f570e..e7acdee35c39 100644 --- a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py +++ b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py @@ -344,6 +344,8 @@ def close(self): def _resolve_snapshot(table, snapshot): if snapshot is not None: return snapshot + if hasattr(table, "_read_snapshot"): + return table._read_snapshot snapshot_manager = table.snapshot_manager() if snapshot_manager is None: return None diff --git a/paimon-python/pypaimon/multimodal/query.py b/paimon-python/pypaimon/multimodal/query.py index 489ff641525b..ae17ee2a5eb3 100644 --- a/paimon-python/pypaimon/multimodal/query.py +++ b/paimon-python/pypaimon/multimodal/query.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +from copy import copy from typing import Any, Callable, Dict, List, Optional, Tuple import pyarrow as pa @@ -446,6 +447,15 @@ def _and_predicate(self, left, right): class _PreFilterQuery(ScanQuery): + def _for_execution(self): + from pypaimon.snapshot.time_travel_util import TimeTravelUtil + query = copy(self) + query._table = self._table._copy_with_snapshot(TimeTravelUtil.resolve_snapshot(self._table)) + return query + + def to_arrow(self): + return ScanQuery.to_arrow(self._for_execution()) + def __init__( self, table, @@ -504,7 +514,7 @@ def __init__( def _execute_vector(self, query): limit = query._limit if query._limit is not None else 10 builder = ( - self._table.new_vector_search_builder() + query._table.new_vector_search_builder() .with_vector_column(self._vector_column) .with_query_vector(self._vector) .with_limit(limit) @@ -526,7 +536,7 @@ def __init__(self, table, text_query, pre_filter=None): def _execute_fts(self, query): limit = query._limit if query._limit is not None else 10 builder = ( - self._table.new_full_text_search_builder() + query._table.new_full_text_search_builder() .with_query(self._text_query["column"], self._text_query["query"]) .with_limit(limit) ) @@ -561,7 +571,7 @@ def _execute_hybrid(self, query): final_limit = query._limit if query._limit is not None else 10 route_limit = self._route_limit or final_limit builder = ( - self._table.new_hybrid_search_builder() + query._table.new_hybrid_search_builder() .with_limit(final_limit) .with_ranker(self._ranker) ) @@ -602,9 +612,10 @@ def __init__( super().__init__(table, pre_filter=pre_filter) def to_arrow(self): + query = self._for_execution() return [ - self._read_global_index_result(result) - for result in self._execute_batch_vector(self) + query._read_global_index_result(result) + for result in query._execute_batch_vector(query) ] def to_pandas(self): @@ -616,7 +627,7 @@ def to_list(self) -> List[List[dict]]: def _execute_batch_vector(self, query): limit = query._limit if query._limit is not None else 10 builder = ( - self._table.new_batch_vector_search_builder() + query._table.new_batch_vector_search_builder() .with_vector_column(self._vector_column) .with_query_vectors(self._vectors) .with_limit(limit) diff --git a/paimon-python/pypaimon/read/table_scan.py b/paimon-python/pypaimon/read/table_scan.py index d2ee59a4b68e..8ca5c0117ac1 100755 --- a/paimon-python/pypaimon/read/table_scan.py +++ b/paimon-python/pypaimon/read/table_scan.py @@ -433,9 +433,7 @@ def incremental_manifest(): if has_time_travel: def time_travel_manifest_scanner(): - snapshot = TimeTravelUtil.try_travel_to_snapshot( - options, self.table.tag_manager(), snapshot_manager - ) + snapshot = TimeTravelUtil.resolve_snapshot(self.table) if snapshot is None: raise ValueError( "Could not resolve time travel snapshot from scan options." @@ -452,7 +450,7 @@ def time_travel_manifest_scanner(): ) def all_manifests(): - snapshot = snapshot_manager.get_latest_snapshot() + snapshot = TimeTravelUtil.resolve_snapshot(self.table) return manifest_list_manager.read_all(snapshot), snapshot return FileScanner( diff --git a/paimon-python/pypaimon/snapshot/time_travel_util.py b/paimon-python/pypaimon/snapshot/time_travel_util.py index 8a8609b147ae..f73a593850ad 100644 --- a/paimon-python/pypaimon/snapshot/time_travel_util.py +++ b/paimon-python/pypaimon/snapshot/time_travel_util.py @@ -65,6 +65,16 @@ def _parse_timestamp_to_millis(timestamp_str: str) -> int: class TimeTravelUtil: """The util class of resolve snapshot from scan params for time travel.""" + @staticmethod + def resolve_snapshot(table): + """Resolve the table's read view, including an explicitly pinned empty view.""" + if hasattr(table, "_read_snapshot"): + return table._read_snapshot + manager = table.snapshot_manager() + snapshot = TimeTravelUtil.try_travel_to_snapshot( + table.options.options, table.tag_manager(), manager) + return snapshot if snapshot is not None else manager.get_latest_snapshot() + @staticmethod def try_travel_to_snapshot( options: Options, diff --git a/paimon-python/pypaimon/table/file_store_table.py b/paimon-python/pypaimon/table/file_store_table.py index 5519ee087dc4..99495cadab7f 100644 --- a/paimon-python/pypaimon/table/file_store_table.py +++ b/paimon-python/pypaimon/table/file_store_table.py @@ -516,6 +516,20 @@ def copy_without_time_travel(self, options: dict) -> 'FileStoreTable': """Copy this table while preserving its already resolved schema.""" return self._copy(options, resolve_time_travel=False) + def _copy_with_snapshot(self, snapshot): + """Keep one resolved read view, including tag metadata and empty tables.""" + from pypaimon.snapshot.time_travel_util import SCAN_KEYS + options = {key: None for key in SCAN_KEYS if key in self.table_schema.options} + options[CoreOptions.SCAN_MODE.key()] = "from-snapshot" if snapshot is not None else "default" + if snapshot is not None: + options[CoreOptions.SCAN_SNAPSHOT_ID.key()] = str(snapshot.id) + # Native planning cannot consume retained tag metadata or a pinned empty view. + if snapshot is None or CoreOptions.SCAN_TAG_NAME.key() in self.table_schema.options: + options[CoreOptions.SCAN_NATIVE_PLAN_ENABLED.key()] = "false" + table = self.copy_without_time_travel(options) + table._read_snapshot = snapshot + return table + def _copy(self, options: dict, resolve_time_travel: bool) -> 'FileStoreTable': if CoreOptions.BUCKET.key() in options and int(options.get(CoreOptions.BUCKET.key())) != self.options.bucket(): raise ValueError("Cannot change bucket number") @@ -531,7 +545,12 @@ def _copy(self, options: dict, resolve_time_travel: bool) -> 'FileStoreTable': # Cumulative copy() overrides (removals kept as None) vs the on-disk schema. applied_options = {**getattr(self, '_applied_dynamic_options', {}), **options} - if resolve_time_travel: + from pypaimon.snapshot.time_travel_util import SCAN_KEYS + preserve_snapshot = hasattr(self, "_read_snapshot") and not any( + key in options for key in SCAN_KEYS + [ + CoreOptions.SCAN_MODE.key(), CoreOptions.BRANCH.key(), + CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key()]) + if resolve_time_travel and not preserve_snapshot: time_travel_schema = self._try_time_travel(Options(new_options), set(applied_options)) if time_travel_schema is not None: new_table_schema = time_travel_schema @@ -554,6 +573,8 @@ def _copy(self, options: dict, resolve_time_travel: bool) -> 'FileStoreTable': new_table = FileStoreTable(self.file_io, new_identifier, self.table_path, new_table_schema, catalog_env) new_table._applied_dynamic_options = applied_options + if preserve_snapshot: + new_table._read_snapshot = self._read_snapshot return new_table def _try_time_travel(self, options: Options, dynamic_option_keys: Set[str]) -> Optional[TableSchema]: diff --git a/paimon-python/pypaimon/table/source/full_text_read.py b/paimon-python/pypaimon/table/source/full_text_read.py index 036a6d9d3d69..e6ddbbcd525f 100644 --- a/paimon-python/pypaimon/table/source/full_text_read.py +++ b/paimon-python/pypaimon/table/source/full_text_read.py @@ -18,6 +18,7 @@ """Full-text read to read index files.""" from abc import ABC, abstractmethod +from copy import copy from concurrent.futures import wait from io import BytesIO from typing import Dict, List @@ -72,6 +73,11 @@ def __init__( self._query = query self._partition_filter = partition_filter + def read_plan(self, plan: FullTextScanPlan) -> GlobalIndexResult: + reader = copy(self) + reader._table = global_index_live_row_filter.table_at_snapshot(self._table, plan.snapshot()) + return reader.read(plan.splits()) + def read(self, splits: List[FullTextSearchSplit]) -> GlobalIndexResult: index_splits, raw_splits = _split_search_splits(splits) if not index_splits and not raw_splits: diff --git a/paimon-python/pypaimon/table/source/full_text_scan.py b/paimon-python/pypaimon/table/source/full_text_scan.py index 1387b02536bc..29b44ee5b0d4 100644 --- a/paimon-python/pypaimon/table/source/full_text_scan.py +++ b/paimon-python/pypaimon/table/source/full_text_scan.py @@ -36,8 +36,12 @@ class FullTextScanPlan: """Plan of full-text scan.""" - def __init__(self, splits: List[FullTextSearchSplit]): + def __init__(self, splits: List[FullTextSearchSplit], snapshot=None): self._splits = splits + self._snapshot = snapshot + + def snapshot(self): + return self._snapshot def splits(self) -> List[FullTextSearchSplit]: return self._splits @@ -73,14 +77,9 @@ def scan(self) -> FullTextScanPlan: id_to_column = {field.id: field.name for field in self._text_columns} from pypaimon.snapshot.time_travel_util import TimeTravelUtil - from pypaimon.common.options.options import Options - snapshot = TimeTravelUtil.try_travel_to_snapshot( - Options(self._table.table_schema.options), - self._table.tag_manager(), - self._table.snapshot_manager(), - ) + snapshot = TimeTravelUtil.resolve_snapshot(self._table) if snapshot is None: - snapshot = self._table.snapshot_manager().get_latest_snapshot() + return FullTextScanPlan([]) index_file_handler = IndexFileHandler(table=self._table) partition_filter = self._partition_filter @@ -129,7 +128,7 @@ def index_file_filter(entry): if raw_row_ranges: splits.append(RawFullTextSearchSplit(raw_row_ranges)) - return FullTextScanPlan(splits) + return FullTextScanPlan(splits, snapshot) def _supports_full_text_search(index_type): 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 af96b85e5b9a..fe59df49c24a 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 @@ -19,7 +19,6 @@ from typing import Optional -from pypaimon.common.options.core_options import CoreOptions from pypaimon.deletionvectors.deletion_vector import DeletionVector from pypaimon.read.query_auth_split import QueryAuthSplit from pypaimon.read.split import DataSplit @@ -67,17 +66,7 @@ def table_at_snapshot(table, snapshot): if snapshot is None: return table - pin_options = { - CoreOptions.SCAN_MODE.key(): "from-snapshot", - CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot.id), - } - for option in (CoreOptions.SCAN_TAG_NAME, - CoreOptions.SCAN_WATERMARK, - CoreOptions.SCAN_TIMESTAMP, - CoreOptions.SCAN_TIMESTAMP_MILLIS): - if option.key() in table.table_schema.options: - pin_options[option.key()] = None - return table.copy_without_time_travel(pin_options) + return table._copy_with_snapshot(snapshot) def for_range(live_row_ids: Optional[RoaringBitmap64], diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py b/paimon-python/pypaimon/table/source/hybrid_search_builder.py index e05c248a63ad..a3e43c3fb6cc 100644 --- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py +++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py @@ -19,6 +19,7 @@ import heapq import math +from copy import copy from abc import ABC, abstractmethod from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait from dataclasses import dataclass, field @@ -312,16 +313,19 @@ def add_route(self, route: HybridSearchRoute) -> 'HybridSearchBuilder': def route_builders(self) -> List[HybridSearchRouteBuilder]: self._validate_search() + from pypaimon.snapshot.time_travel_util import TimeTravelUtil + execution = copy(self) + execution._table = self._table._copy_with_snapshot(TimeTravelUtil.resolve_snapshot(self._table)) builders = [] for route in self._routes: if route.is_vector(): builders.append( HybridSearchRouteBuilder( - route, self._new_vector_search_builder(route))) + route, execution._new_vector_search_builder(route))) else: builders.append( HybridSearchRouteBuilder( - route, self._new_full_text_search_builder(route))) + route, execution._new_full_text_search_builder(route))) return builders def to_route_result( diff --git a/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py b/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py index dab5f0e66ea2..4a7d7c515eb5 100644 --- a/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py +++ b/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py @@ -17,8 +17,6 @@ from dataclasses import dataclass -from pypaimon.common.options.core_options import CoreOptions -from pypaimon.common.options.options import Options from pypaimon.globalindex.indexed_split import IndexedSplit from pypaimon.index.index_file_handler import IndexFileHandler from pypaimon.index.pk.primary_key_index_source_meta import ( @@ -28,6 +26,7 @@ from pypaimon.read.query_auth_split import QueryAuthSplit from pypaimon.read.split import DataSplit from pypaimon.snapshot.time_travel_util import TimeTravelUtil +from pypaimon.table.source.global_index_live_row_filter import table_at_snapshot from pypaimon.table.source.full_text_scan import FullTextScan, FullTextScanPlan @@ -48,24 +47,11 @@ def __init__(self, table, definition, partition_filter=None): self._partition_filter = partition_filter def scan(self): - snapshot = TimeTravelUtil.try_travel_to_snapshot( - Options(self._table.table_schema.options), self._table.tag_manager(), - self._table.snapshot_manager()) - if snapshot is None: - snapshot = self._table.snapshot_manager().get_latest_snapshot() + snapshot = TimeTravelUtil.resolve_snapshot(self._table) if snapshot is None: return PrimaryKeyFullTextScanPlan(0, []) - pin_options = { - CoreOptions.SCAN_MODE.key(): "from-snapshot", - CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot.id)} - for option in (CoreOptions.SCAN_TAG_NAME, - CoreOptions.SCAN_WATERMARK, - CoreOptions.SCAN_TIMESTAMP, - CoreOptions.SCAN_TIMESTAMP_MILLIS): - if option.key() in self._table.table_schema.options: - pin_options[option.key()] = None - scan_table = self._table.copy(pin_options) + scan_table = table_at_snapshot(self._table, snapshot) builder = scan_table.new_read_builder() if self._partition_filter is not None: builder = builder.with_partition_filter(self._partition_filter) diff --git a/paimon-python/pypaimon/table/source/primary_key_vector_scan.py b/paimon-python/pypaimon/table/source/primary_key_vector_scan.py index 99156528f061..0ccdf9bf3fc6 100644 --- a/paimon-python/pypaimon/table/source/primary_key_vector_scan.py +++ b/paimon-python/pypaimon/table/source/primary_key_vector_scan.py @@ -17,8 +17,8 @@ from dataclasses import dataclass -from pypaimon.common.options.options import Options from pypaimon.common.options.core_options import CoreOptions +from pypaimon.common.options.options import Options from pypaimon.index.index_file_handler import IndexFileHandler from pypaimon.index.pk.primary_key_index_source_meta import PrimaryKeyIndexSourceMeta from pypaimon.index.pk.primary_key_index_source_policy import ( @@ -28,6 +28,7 @@ from pypaimon.globalindex.indexed_split import IndexedSplit from pypaimon.deletionvectors.deletion_vector import DeletionVector from pypaimon.snapshot.time_travel_util import TimeTravelUtil +from pypaimon.table.source.global_index_live_row_filter import table_at_snapshot from pypaimon.table.source.vector_search_scan import VectorSearchScan, VectorSearchScanPlan from pypaimon.table.row.generic_row import GenericRow from pypaimon.utils.range import Range @@ -54,24 +55,11 @@ def __init__(self, table, vector_column, filter_=None, self._index_type = index_type def scan(self): - snapshot = TimeTravelUtil.try_travel_to_snapshot( - Options(self._table.table_schema.options), self._table.tag_manager(), - self._table.snapshot_manager()) - if snapshot is None: - snapshot = self._table.snapshot_manager().get_latest_snapshot() + snapshot = TimeTravelUtil.resolve_snapshot(self._table) if snapshot is None: return PrimaryKeyVectorScanPlan(0, []) - pin_options = { - CoreOptions.SCAN_MODE.key(): "from-snapshot", - CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot.id)} - for option in (CoreOptions.SCAN_TAG_NAME, - CoreOptions.SCAN_WATERMARK, - CoreOptions.SCAN_TIMESTAMP, - CoreOptions.SCAN_TIMESTAMP_MILLIS): - if option.key() in self._table.table_schema.options: - pin_options[option.key()] = None - scan_table = self._table.copy(pin_options) + scan_table = table_at_snapshot(self._table, snapshot) builder = scan_table.new_read_builder() if self._partition_filter is not None: builder = builder.with_partition_filter(self._partition_filter) diff --git a/paimon-python/pypaimon/table/source/vector_search_scan.py b/paimon-python/pypaimon/table/source/vector_search_scan.py index 8c94390b511f..8484588f3562 100644 --- a/paimon-python/pypaimon/table/source/vector_search_scan.py +++ b/paimon-python/pypaimon/table/source/vector_search_scan.py @@ -77,7 +77,6 @@ def __init__( def scan(self): # type: () -> VectorSearchScanPlan - from pypaimon.common.options.options import Options from pypaimon.index.index_file_handler import IndexFileHandler from pypaimon.read.push_down_utils import _get_all_fields from pypaimon.snapshot.time_travel_util import TimeTravelUtil @@ -95,13 +94,9 @@ def scan(self): if field is not None: filter_field_ids.add(field.id) - snapshot = TimeTravelUtil.try_travel_to_snapshot( - Options(self._table.table_schema.options), - self._table.tag_manager(), - self._table.snapshot_manager(), - ) + snapshot = TimeTravelUtil.resolve_snapshot(self._table) if snapshot is None: - snapshot = self._table.snapshot_manager().get_latest_snapshot() + return VectorSearchScanPlan([]) index_file_handler = IndexFileHandler(table=self._table) diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py b/paimon-python/pypaimon/tests/multimodal_table_test.py index 50975a3892f6..e4264ae91488 100644 --- a/paimon-python/pypaimon/tests/multimodal_table_test.py +++ b/paimon-python/pypaimon/tests/multimodal_table_test.py @@ -2199,7 +2199,9 @@ def with_filter(self, predicate): def execute_local(self): return GlobalIndexResult.from_range(Range(1, 1)) - docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder() + patcher = patch.object(type(docs.raw_table), "new_vector_search_builder", return_value=FakeVectorBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search([0.0, 1.0, 0.0]) @@ -2250,7 +2252,9 @@ def with_filter(self, predicate): def execute_local(self): return GlobalIndexResult.from_range(Range(0, 0)) - docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder() + patcher = patch.object(type(docs.raw_table), "new_vector_search_builder", return_value=FakeVectorBuilder()) + patcher.start() + self.addCleanup(patcher.stop) docs.search( [1.0, 0.0, 0.0], @@ -2400,7 +2404,9 @@ def with_options(self, options): def execute_local(self): return GlobalIndexResult.from_range(Range(0, 0)) - docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder() + patcher = patch.object(type(docs.raw_table), "new_vector_search_builder", return_value=FakeVectorBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = docs.search((v for v in [1.0, 0.0, 0.0])).limit(1).to_list() @@ -2435,7 +2441,9 @@ def with_options(self, options): def execute_local(self): return GlobalIndexResult.from_range(Range(0, 0)) - docs.raw_table.new_vector_search_builder = lambda: FakeVectorBuilder() + patcher = patch.object(type(docs.raw_table), "new_vector_search_builder", return_value=FakeVectorBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search([1.0, 0.0, 0.0], column="embedding") @@ -2505,8 +2513,10 @@ def execute_batch_local(self): GlobalIndexResult.from_range(Range(2, 2)), ] - docs.raw_table.new_batch_vector_search_builder = ( - lambda: FakeBatchVectorBuilder()) + patcher = patch.object(type(docs.raw_table), "new_batch_vector_search_builder", + return_value=FakeBatchVectorBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search_vectors( @@ -2573,7 +2583,9 @@ def with_limit(self, limit): def execute_local(self): return GlobalIndexResult.from_range(Range(0, 0)) - docs.raw_table.new_full_text_search_builder = lambda: FakeFullTextBuilder() + patcher = patch.object(type(docs.raw_table), "new_full_text_search_builder", return_value=FakeFullTextBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search("paimon vector") @@ -2664,7 +2676,9 @@ def with_filter(self, predicate): def execute_local(self): return GlobalIndexResult.from_range(Range(0, 1)) - docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder() + patcher = patch.object(type(docs.raw_table), "new_hybrid_search_builder", return_value=FakeHybridBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search_hybrid( @@ -2726,7 +2740,9 @@ def with_filter(self, predicate): def execute_local(self): return GlobalIndexResult.from_range(Range(0, 0)) - docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder() + patcher = patch.object(type(docs.raw_table), "new_hybrid_search_builder", return_value=FakeHybridBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search_hybrid( @@ -2805,7 +2821,9 @@ def add_full_text_route( def execute_local(self): return GlobalIndexResult.from_range(Range(0, 0)) - docs.raw_table.new_hybrid_search_builder = lambda: FakeHybridBuilder() + patcher = patch.object(type(docs.raw_table), "new_hybrid_search_builder", return_value=FakeHybridBuilder()) + patcher.start() + self.addCleanup(patcher.stop) result = ( docs.search_hybrid( diff --git a/paimon-python/pypaimon/tests/search_snapshot_test.py b/paimon-python/pypaimon/tests/search_snapshot_test.py new file mode 100644 index 000000000000..b92ce26c2579 --- /dev/null +++ b/paimon-python/pypaimon/tests/search_snapshot_test.py @@ -0,0 +1,227 @@ +# 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 threading +from unittest.mock import patch + +import pyarrow as pa +import pytest + +import pypaimon.multimodal as pm +from pypaimon.multimodal.query import ScanQuery +from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult +from pypaimon.table.source.full_text_scan import FullTextScanPlan +from pypaimon.table.source.full_text_search_split import RawFullTextSearchSplit +from pypaimon.table.source.vector_search_scan import DataEvolutionVectorScan +from pypaimon.utils.range import Range + +SCHEMA = pa.schema([ + ("id", pa.int64()), ("category", pa.string()), ("embedding", pa.list_(pa.float32(), 2))]) +DATA = pa.table({"id": [1, 2], "category": ["allowed", "allowed"], + "embedding": [[0, 0], [10, 0]]}, schema=SCHEMA) + + +@pytest.fixture +def table(tmp_path): + return pm.connect(options={"warehouse": str(tmp_path)}).create_table( + "vectors", schema=SCHEMA, + options={"file.format": "parquet", "vector.file.format": "parquet", + "vector-index.search-mode": "full"}) + + +def search(table, kind="vector", **options): + if kind == "batch": + query = table.search_vectors([[0, 0], [0, 0]], **options) + elif kind == "hybrid": + query = table.search_hybrid([ + pm.vector_route("embedding", [0, 0]), pm.vector_route("embedding", [1, 0])], **options) + else: + query = table.search([0, 0], **options) + return query.pre_filter("category = 'allowed'").select(["id", "category"]).limit(1) + + +@pytest.mark.parametrize("kind", ["vector", "batch", "hybrid"]) +@pytest.mark.parametrize("change", ["update", "delete"]) +def test_search_and_lookup_share_snapshot_and_query_is_reusable(table, kind, change): + table.add(DATA) + query = search(table, kind) + original_table = query._table + expected = query.to_list() + expected_row = [{"id": 1, "category": "allowed"}] + assert expected == ([expected_row, expected_row] if kind == "batch" else expected_row) + lookup = ScanQuery._read_global_index_result + lookups = [] + + def commit_before_lookup(execution, result): + if not lookups: + if change == "delete": + table.delete("id = 1") + else: + table.update("id = 1", {"category": "blocked"}) + lookups.append(execution._table) + return lookup(execution, result) + + with patch.object(ScanQuery, "_read_global_index_result", commit_before_lookup): + assert query.to_list() == expected + assert query._table is original_table + assert all(t is lookups[0] for t in lookups) + latest = [{"id": 2, "category": "allowed"}] + assert query.to_list() == ([latest, latest] if kind == "batch" else latest) + + +def test_hybrid_builder_pins_snapshot_before_routes_start(table): + table.add(DATA) + builder = table.raw_table.new_hybrid_search_builder().with_limit(1) + builder.add_vector_route("embedding", [0, 0], limit=1) + builder.add_vector_route("embedding", [1, 0], limit=1) + original_scan = DataEvolutionVectorScan.scan + snapshots = [] + lock = threading.Lock() + + def scan_and_commit(scan): + with lock: + plan = original_scan(scan) + if not snapshots: + table.delete("id = 1") + snapshots.append(plan.snapshot().id) + return plan + + with patch.object(DataEvolutionVectorScan, "scan", scan_and_commit): + result = builder.execute_local() + assert snapshots == [1, 1] + assert list(result.results()) == [0] + + +def test_empty_search_does_not_follow_first_commit(table): + query = search(table) + original_scan = DataEvolutionVectorScan.scan + + def append_before_scan(scan): + table.add(DATA) + return original_scan(scan) + + with patch.object(DataEvolutionVectorScan, "scan", append_before_scan): + assert query.to_list() == [] + assert query.to_list() == [{"id": 1, "category": "allowed"}] + + +@pytest.mark.parametrize("selector", ["snapshot", "tag", "timestamp"]) +def test_time_travel_survives_concurrent_writes_and_retained_tag_snapshot(table, selector): + table.add(DATA) + source = table.raw_table.snapshot_manager().get_latest_snapshot() + if selector == "tag": + table.raw_table.create_tag("training", snapshot_id=source.id) + query = search(table, tag_name="training") + # Tags retain manifests even after their original snapshot JSON expires. + table.add(pa.table({"id": [3], "category": ["allowed"], + "embedding": [[20, 0]]}, schema=SCHEMA)) + table.raw_table.file_io.delete(table.raw_table.snapshot_manager().get_snapshot_path(source.id)) + elif selector == "timestamp": + from pypaimon.multimodal.query import VectorQuery + read_table = table.raw_table.copy({"scan.timestamp-millis": str(source.time_millis)}) + query = VectorQuery(read_table, [0, 0], "embedding").select(["id", "category"]).limit(1) + else: + query = search(table, snapshot_id=source.id) + lookup = ScanQuery._read_global_index_result + + def update_before_lookup(execution, result): + table.update("id = 1", {"category": "blocked"}) + if selector == "tag": + table.raw_table.replace_tag("training") + # Read-option copies must retain the captured snapshot too. + execution._table = execution._table.copy({"read.batch-size": "2"}) + return lookup(execution, result) + + with patch.object(ScanQuery, "_read_global_index_result", update_before_lookup): + assert query.to_list() == [{"id": 1, "category": "allowed"}] + + +def test_full_text_plan_pins_live_rows_and_raw_fallback_without_leaking_to_read(table): + table.add(DATA) + snapshot = table.raw_table.snapshot_manager().get_latest_snapshot() + splits = [RawFullTextSearchSplit([Range(0, 1)])] + plan = FullTextScanPlan(splits, snapshot) + reader = (table.raw_table.new_full_text_search_builder() + .with_query("category", "allowed").with_limit(2).new_full_text_read()) + table.delete("id = 1") + live, raw = [], [] + + def indexed_query(unused, rows): + live.append(list(rows)) + return DictBasedScoredIndexResult({}) + + def raw_index(row_ids, texts, offset): + raw.append((row_ids, texts)) + return None + + with patch.object(reader, "_eval_column_query", indexed_query), \ + patch.object(reader, "_build_raw_index", raw_index): + reader.read_plan(plan) + reader.read(splits) + assert live == [[0, 1], [1]] + assert raw == [([0, 1], ["allowed", "allowed"]), ([1], ["allowed"])] + + +def test_read_view_copy_retains_snapshot_unless_selector_changes(table): + from pypaimon.multimodal.query import VectorQuery + + table.add(DATA) + view = search(table)._for_execution()._table + table.update("id = 1", {"category": "blocked"}) + + def read(read_table): + return VectorQuery(read_table, [0, 0], "embedding").select(["category"]).limit(1).to_list() + + assert read(view.copy({"read.batch-size": "2"})) == [{"category": "allowed"}] + latest = table.raw_table.snapshot_manager().get_latest_snapshot() + assert read(view.copy({"scan.snapshot-id": str(latest.id)})) == [{"category": "blocked"}] + assert read(view.copy({"scan.snapshot-id": None, "scan.mode": "default"})) == [{"category": "blocked"}] + table.raw_table.branch_manager().create_branch("empty") + assert read(view.copy({"branch": "empty", "scan.snapshot-id": None, "scan.mode": "default"})) == [] + + +def test_indexed_search_lookup_uses_planned_snapshot(tmp_path): + pytest.importorskip("paimon_vindex") + from pypaimon.read.table_scan import TableScan + + table = pm.connect(options={"warehouse": str(tmp_path)}).create_table( + "indexed", schema=SCHEMA, + options={"file.format": "parquet", "vector.file.format": "parquet", + "deletion-vectors.enabled": "false"}) + table.add(DATA) + table.create_index("embedding", index_type="ivf-flat", options={ + "ivf-flat.dimension": "2", "ivf-flat.nlist": "1", "ivf-flat.distance.metric": "l2"}) + query = table.search([0, 0]).select(["id"]).limit(1) + snapshot = table.raw_table.snapshot_manager().get_latest_snapshot() + lookup = ScanQuery._read_global_index_result + plan = TableScan.plan + lookup_snapshots = [] + + def record_plan(scan): + result = plan(scan) + lookup_snapshots.append(result.snapshot_id) + return result + + def append_before_lookup(execution, result): + table.add(DATA) + with patch.object(TableScan, "plan", record_plan): + return lookup(execution, result) + + with patch.object(ScanQuery, "_read_global_index_result", append_before_lookup): + assert query.to_list() == [{"id": 1}] + assert lookup_snapshots == [snapshot.id] + assert table.raw_table.snapshot_manager().get_latest_snapshot().id > snapshot.id diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py b/paimon-python/pypaimon/tests/vector_search_filter_test.py index 39daa2bbd52b..1d2731666f21 100644 --- a/paimon-python/pypaimon/tests/vector_search_filter_test.py +++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py @@ -44,6 +44,7 @@ from pypaimon.index.index_file_meta import IndexFileMeta from pypaimon.manifest.index_manifest_entry import IndexManifestEntry from pypaimon.schema.data_types import AtomicType, DataField +from pypaimon.table.file_store_table import FileStoreTable from pypaimon.table.row.generic_row import GenericRow from pypaimon.table.source.vector_search_builder import VectorSearchBuilderImpl from pypaimon.utils.roaring_bitmap import RoaringBitmap64 @@ -106,7 +107,7 @@ def tag_manager(self): return None def snapshot_manager(self): - return None + return types.SimpleNamespace(get_latest_snapshot=lambda: None) def path_factory(self): class _P: @@ -121,7 +122,10 @@ def copy(self, options): return self def copy_without_time_travel(self, options): - return self + from copy import copy + return copy(self) + + _copy_with_snapshot = FileStoreTable._copy_with_snapshot def new_vector_search_builder(self): from pypaimon.table.source.vector_search_builder import ( @@ -361,6 +365,7 @@ def deletion_vectors_enabled(self_inner, default=False): return False class _Table: + _copy_with_snapshot = FileStoreTable._copy_with_snapshot options = _Options() def new_read_builder(self_inner): @@ -412,6 +417,7 @@ def new_scan(self_inner): return _Scan() class _Table: + _copy_with_snapshot = FileStoreTable._copy_with_snapshot options = _Options() table_schema = _StubSchema() file_io = object() @@ -491,6 +497,7 @@ def deletion_vectors_enabled(self_inner, default=False): return True class _Table: + _copy_with_snapshot = FileStoreTable._copy_with_snapshot options = _Options() file_io = object() @@ -3146,7 +3153,9 @@ def test_read_plan_clears_conflicting_time_travel_options(self): CoreOptions.SCAN_SNAPSHOT_ID.key(): "7", CoreOptions.SCAN_TAG_NAME.key(): None, CoreOptions.SCAN_TIMESTAMP.key(): None, + CoreOptions.SCAN_NATIVE_PLAN_ENABLED.key(): "false", }) + self.assertIs(read_table._read_snapshot, snapshot) def tearDown(self): mock.patch.stopall() From fb38651521922b8aae1261282c888c18a1424326 Mon Sep 17 00:00:00 2001 From: chaoyang Date: Tue, 15 Sep 2026 19:40:43 +0800 Subject: [PATCH 2/2] [python] Adapt PK snapshot scan test to pinned table views --- .../tests/primary_key_index_definitions_test.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py index 5a599f2699e1..2b9329dcd2c4 100644 --- a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py +++ b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py @@ -18,12 +18,14 @@ from unittest import mock from types import SimpleNamespace -from pypaimon.common.options.core_options import GlobalIndexSearchMode +from pypaimon.common.options.core_options import CoreOptions, GlobalIndexSearchMode +from pypaimon.common.options.options import Options from pypaimon.index.pk.primary_key_index_definitions import PrimaryKeyIndexDefinitions from pypaimon.index.pk.primary_key_index_source_file import PrimaryKeyIndexSourceFile from pypaimon.index.pk.primary_key_index_source_meta import PrimaryKeyIndexSourceMeta from pypaimon.schema.data_types import AtomicType, DataField from pypaimon.schema.table_schema import TableSchema +from pypaimon.table.file_store_table import FileStoreTable from pypaimon.table.source.full_text_search_builder import FullTextSearchBuilderImpl from pypaimon.table.source.primary_key_full_text_read import PrimaryKeyFullTextRead from pypaimon.table.source.primary_key_full_text_scan import PrimaryKeyFullTextScan @@ -311,6 +313,7 @@ def test_pk_vector_scan_pins_source_and_index_to_same_snapshot(self): "scan.tag-name": "old-tag"}) snapshot = SimpleNamespace(id=17) copied_options = [] + source_snapshots = [] index_snapshots = [] class _ScanTable: @@ -318,6 +321,7 @@ class _ScanTable: fields = schema.fields def new_read_builder(self): + source_snapshots.append(self._read_snapshot) plan = SimpleNamespace(splits=lambda: []) scan = SimpleNamespace(plan=lambda: plan) return SimpleNamespace(new_scan=lambda: scan) @@ -325,6 +329,8 @@ def new_read_builder(self): class _Table: table_schema = schema fields = schema.fields + options = CoreOptions(Options(schema.options)) + _copy_with_snapshot = FileStoreTable._copy_with_snapshot def tag_manager(self): return None @@ -332,7 +338,7 @@ def tag_manager(self): def snapshot_manager(self): return SimpleNamespace(get_latest_snapshot=lambda: snapshot) - def copy(self, options): + def copy_without_time_travel(self, options): copied_options.append(options) return _ScanTable() @@ -354,10 +360,12 @@ def scan(self, selected_snapshot, entry_filter): _Table(), schema.fields[0], index_type="vindex").scan() self.assertEqual(17, plan.snapshot_id) + self.assertEqual([snapshot], source_snapshots) self.assertEqual([snapshot], index_snapshots) self.assertEqual("from-snapshot", copied_options[0]["scan.mode"]) self.assertEqual("17", copied_options[0]["scan.snapshot-id"]) self.assertIsNone(copied_options[0]["scan.tag-name"]) + self.assertEqual("false", copied_options[0]["scan.native-plan.enabled"]) def test_pk_index_source_policy_matches_java(self): compact = SimpleNamespace(file_source=1, level=1)