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 5f6a19b699 [Python] Preserve captured read views through Native Table 
(#10497)
5f6a19b699 is described below

commit 5f6a19b6997e85fe5412e534af0b107bf42389b4
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 11:26:39 2026 +0800

    [Python] Preserve captured read views through Native Table (#10497)
---
 paimon-python/pypaimon/read/native_plan.py         |   8 +-
 paimon-python/pypaimon/table/file_store_table.py   |   5 -
 .../pypaimon/table/source/native_hybrid_search.py  |   4 -
 .../pypaimon/table/source/native_vector_search.py  |  77 +++---
 paimon-python/pypaimon/tests/native_plan_test.py   |  15 +-
 .../pypaimon/tests/native_read_snapshot_test.py    | 262 +++++++++++++++++++++
 .../pypaimon/tests/native_vector_search_test.py    |  18 +-
 .../tests/primary_key_index_definitions_test.py    |   2 +-
 .../pypaimon/tests/search_snapshot_test.py         |   2 +-
 .../pypaimon/tests/vector_search_filter_test.py    |   1 -
 10 files changed, 334 insertions(+), 60 deletions(-)

diff --git a/paimon-python/pypaimon/read/native_plan.py 
b/paimon-python/pypaimon/read/native_plan.py
index 0a923cd701..a024b44c41 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -331,7 +331,7 @@ def _rest_data_token(table):
 
 
 def _native_table(table):
-    """Return the current schema and shared REST FileIO for Native 
operations."""
+    """Transport the resolved schema, shared FileIO and captured read view."""
     rest_response = _resolved_rest_table_response(table)
     file_io_options = _resolved_schema_file_io_options(table)
     if rest_response is not None:
@@ -365,6 +365,12 @@ def _native_table(table):
         branch = getattr(rt, 'branch', None)
         if not callable(branch) or branch() != table.current_branch():
             raise RuntimeError("Native table did not resolve the requested 
branch")
+    # Only explicitly stored state captures a view; None means a fixed empty 
view.
+    table_state = vars(table)
+    if '_read_snapshot' in table_state:
+        from pypaimon.common.json_util import JSON
+        snapshot = table_state['_read_snapshot']
+        rt = rt.copy_with_pinned_snapshot(JSON.to_json(snapshot) if snapshot 
is not None else None)
     return rt
 
 
diff --git a/paimon-python/pypaimon/table/file_store_table.py 
b/paimon-python/pypaimon/table/file_store_table.py
index 8dea054608..220f3b56f2 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -531,11 +531,6 @@ class FileStoreTable(Table):
         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.
-        # scan.version can resolve to a tag as well.
-        if snapshot is None or any(option.key() in self.table_schema.options 
for option in (
-                CoreOptions.SCAN_TAG_NAME, CoreOptions.SCAN_VERSION)):
-            options[CoreOptions.SCAN_NATIVE_PLAN_ENABLED.key()] = "false"
         table = self.copy_without_time_travel(options)
         table._read_snapshot = snapshot
         return table
diff --git a/paimon-python/pypaimon/table/source/native_hybrid_search.py 
b/paimon-python/pypaimon/table/source/native_hybrid_search.py
index a19391628f..a820660ab0 100644
--- a/paimon-python/pypaimon/table/source/native_hybrid_search.py
+++ b/paimon-python/pypaimon/table/source/native_hybrid_search.py
@@ -18,7 +18,6 @@
 """Transport an existing local hybrid query to one Rust core operation."""
 
 from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
-from pypaimon.common.json_util import JSON
 from pypaimon.globalindex.full_text.native_full_text_global_index_reader 
import NativeFullTextIndexOptions
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
 from pypaimon.read.native_plan import _native_table, _predicate_to_native, 
native_method_available
@@ -47,9 +46,6 @@ def try_native_hybrid_search(builder):
             table.options.options.to_map()).to_native_options().items()}
         table = table.copy(options)
     native = _native_table(table).new_hybrid_search_builder()
-    if hasattr(table, '_read_snapshot'):
-        snapshot = table._read_snapshot
-        native.with_snapshot(JSON.to_json(snapshot) if snapshot is not None 
else None)
     native.with_limit(builder._limit).with_ranker(builder._ranker)
     for route in builder._routes:
         if route.is_vector():
diff --git a/paimon-python/pypaimon/table/source/native_vector_search.py 
b/paimon-python/pypaimon/table/source/native_vector_search.py
index ce61d41b53..b41d8f0ca7 100644
--- a/paimon-python/pypaimon/table/source/native_vector_search.py
+++ b/paimon-python/pypaimon/table/source/native_vector_search.py
@@ -17,19 +17,15 @@
 
 """Local vector search through the Rust core Scan -> Plan -> Read path."""
 
-import logging
-
 from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
 from pypaimon.read.native_plan import (
     _catalog_metastore, _native_table, _partition_fields, _predicate_to_native,
-    _raise_if_native_fork_safety_error, native_method_available)
+    native_method_available)
 from pypaimon.read.split_serializer import deserialize_split_v1
 from pypaimon.table.source.primary_key_scored_result import (
     PrimaryKeyScoredResult, PrimaryKeySearchPosition)
 
-logger = logging.getLogger(__name__)
-
 
 def try_native_vector_search(builder, queries, batch=False):
     """Return existing Python result types, or None to use the Python reader.
@@ -39,47 +35,48 @@ def try_native_vector_search(builder, queries, batch=False):
     """
     table = builder._table
     reject_search_under_query_auth(table)
+    native_enabled = getattr(table.options, 'native_read_enabled', None)
+    if not callable(native_enabled) or not native_enabled():
+        return None
+    loader = getattr(getattr(table, 'catalog_environment', None), 
'catalog_loader', None)
+    if loader is None or _catalog_metastore(loader) != 'rest':
+        return None
+    context_fn = getattr(loader, 'context', None)
+    if not callable(context_fn):
+        return None
+    context = context_fn()
+    if any(getattr(context, attr, None) is not None for attr in (
+            'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')):
+        return None
+    # Python's batch reader currently supports only global row-ID results.
+    if batch and builder._is_primary_key_vector_search():
+        return None
+    method = 'new_batch_vector_search_builder' if batch else 
'new_vector_search_builder'
+    if not native_method_available('Table', method):
+        return None
+    native = getattr(_native_table(table), method)()
+    if builder._vector_column is not None:
+        native.with_vector_column(builder._vector_column.name)
+    native.with_limit(builder._limit).with_options(builder._options)
     try:
-        native_enabled = getattr(table.options, 'native_read_enabled', None)
-        if not callable(native_enabled) or not native_enabled():
-            return None
-        loader = getattr(getattr(table, 'catalog_environment', None), 
'catalog_loader', None)
-        if loader is None or _catalog_metastore(loader) != 'rest':
-            return None
-        context_fn = getattr(loader, 'context', None)
-        if not callable(context_fn):
-            return None
-        context = context_fn()
-        if any(getattr(context, attr, None) is not None for attr in (
-                'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')):
-            return None
-        # Python's batch reader currently supports only global row-ID results.
-        if batch and builder._is_primary_key_vector_search():
-            return None
-        method = 'new_batch_vector_search_builder' if batch else 
'new_vector_search_builder'
-        if not native_method_available('Table', method):
-            return None
-        native = getattr(_native_table(table), method)()
-        if builder._vector_column is not None:
-            native.with_vector_column(builder._vector_column.name)
-        native.with_limit(builder._limit).with_options(builder._options)
         if builder._filter is not None:
             native.with_filter(_predicate_to_native(builder._filter))
         if builder._partition_filter is not None:
             
native.with_partition_filter(_predicate_to_native(builder._partition_filter))
-        if batch:
-            native.with_query_vectors(queries)
-            results = native.execute_batch_local()
-            return [DictBasedScoredIndexResult(result.row_ids()) for result in 
results]
-        native.with_query_vector(queries)
-        result = native.execute_local()
-        if builder._is_primary_key_vector_search():
-            return _NativePrimaryKeyScoredResult(table, result)
-        return DictBasedScoredIndexResult(result.row_ids())
-    except Exception as error:
-        _raise_if_native_fork_safety_error(error)
-        logger.warning('Native vector search failed, using the Python reader: 
%s', error)
+    except ValueError:
+        # Qualify unsupported typed literals before searching.
         return None
+    # Once the Native route is chosen, errors must not trigger another search
+    # against a potentially newer snapshot.
+    if batch:
+        native.with_query_vectors(queries)
+        results = native.execute_batch_local()
+        return [DictBasedScoredIndexResult(result.row_ids()) for result in 
results]
+    native.with_query_vector(queries)
+    result = native.execute_local()
+    if builder._is_primary_key_vector_search():
+        return _NativePrimaryKeyScoredResult(table, result)
+    return DictBasedScoredIndexResult(result.row_ids())
 
 
 class _NativePrimaryKeyScoredResult(PrimaryKeyScoredResult):
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py 
b/paimon-python/pypaimon/tests/native_plan_test.py
index 7a56edac8d..92378ec1fd 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -902,13 +902,23 @@ class NativePlanTest(unittest.TestCase):
                 patch('pypaimon.read.native_plan._resolved_schema_json', 
return_value=resolved):
             for _ in range(3):
                 self.assertIs(_native_read_builder(table), 
native_table.new_read_builder.return_value)
+            native_table.copy_with_pinned_snapshot.assert_not_called()
+            # Capturing empty uses a read-view copy, keeping the cached base
+            # reusable when a later caller has no captured snapshot.
+            pinned_table = native_table.copy_with_pinned_snapshot.return_value
+            table._read_snapshot = None
+            self.assertIs(_native_read_builder(table), 
pinned_table.new_read_builder.return_value)
+            
native_table.copy_with_pinned_snapshot.assert_called_once_with(None)
+            del table._read_snapshot
+            self.assertIs(_native_read_builder(table), 
native_table.new_read_builder.return_value)
         fake_df.PaimonCatalog.assert_not_called()
         fake_df.Table.from_rest_response.assert_called_once_with(
             response, database='db', table='t$branch_dev',
             rest_options=_catalog_options(table))
         self.assertEqual(native_table.copy_with_resolved_schema.call_args_list,
-                         [call(resolved, branch='dev')] * 3)
-        self.assertEqual(native_table.new_read_builder.call_count, 3)
+                         [call(resolved, branch='dev')] * 5)
+        self.assertEqual(native_table.new_read_builder.call_count, 4)
+        pinned_table.new_read_builder.assert_called_once_with()
 
     def test_native_rest_cache_invalidation_and_serialization(self):
         import pickle
@@ -1207,6 +1217,7 @@ class NativePlanTest(unittest.TestCase):
             plan = native_plan(table)
             self.assertEqual(plan.snapshot_id, 7)
             scan.plan.assert_called_once_with()
+            rt.copy_with_pinned_snapshot.assert_not_called()
             rt.branch.return_value = 'main'
             with self.assertRaisesRegex(RuntimeError, 'requested branch'):
                 native_plan(table)
diff --git a/paimon-python/pypaimon/tests/native_read_snapshot_test.py 
b/paimon-python/pypaimon/tests/native_read_snapshot_test.py
new file mode 100644
index 0000000000..74370736d7
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_read_snapshot_test.py
@@ -0,0 +1,262 @@
+# 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.
+
+"""Resolved read views travel through Native planning and every local search 
API."""
+
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.read.scanner.file_scanner import FileScanner
+from pypaimon.table.source.full_text_search_builder import 
FullTextSearchBuilderImpl
+from pypaimon.table.source.vector_search_read import DataEvolutionVectorRead, 
BatchVectorSearchReadImpl
+from pypaimon.tests import native_hybrid_search_test as hybrid, 
native_vector_search_test as vectors
+from pypaimon.tests.native_full_text_search_test import _scores
+from pypaimon.tests.vector_search_filter_test import match_query
+
+rest_catalog = hybrid.rest_catalog
+golden_catalog = vectors.golden_catalog
+pk_vector_table = vectors.pk_vector_table
+pytestmark = pytest.mark.native_plan
+
+
+@contextmanager
+def _native_only():
+    with patch.object(FileScanner, 'scan', side_effect=AssertionError('Python 
file scan')), \
+            patch.object(DataEvolutionVectorRead, 'read_plan', 
side_effect=AssertionError('Python vector read')), \
+            patch.object(BatchVectorSearchReadImpl, 'read_batch_plan',
+                         side_effect=AssertionError('Python batch read')), \
+            patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
+                         side_effect=AssertionError('Python text scan')):
+        yield
+
+
+def _search(table, family):
+    if family == 'scan':
+        return hybrid.indexes._ids(table)
+    if family == 'text':
+        return 
_scores(table.new_full_text_search_builder().with_query('content', 
match_query('paimon'))
+                       .with_limit(16).execute_local())
+    if family == 'batch':
+        return [_scores(result) for result in 
table.new_batch_vector_search_builder()
+                .with_vector_column('embedding').with_query_vectors([[0., 0.], 
[6., 0.], [0., 0.]])
+                .with_limit(16).execute_batch_local()]
+    return 
_scores(table.new_vector_search_builder().with_vector_column('embedding')
+                   .with_query_vector([0., 0.]).with_limit(16).execute_local())
+
+
[email protected]('family', ['scan', 'vector', 'batch', 'text'])
+def test_fixed_empty_view_does_not_follow_latest(rest_catalog, family):
+    catalog, _ = rest_catalog
+    table = hybrid._table(catalog)
+    fixed = table._copy_with_snapshot(None)
+    with _native_only():
+        result = _search(fixed, family)
+    assert result == ([{}, {}, {}] if family == 'batch' else [] if family == 
'scan' else {})
+    assert hybrid.indexes._ids(table) == list(range(8))
+
+
[email protected]('family', ['scan', 'vector', 'batch', 'text'])
+def 
test_retained_tag_metadata_is_used_without_original_snapshot_file(rest_catalog, 
family):
+    catalog, _ = rest_catalog
+    table = hybrid._table(catalog)
+    snapshot = table.snapshot_manager().get_latest_snapshot()
+    table.tag_manager().create_tag(snapshot, 'before')
+    fixed = table.copy({'scan.tag-name': 
'before'})._copy_with_snapshot(snapshot)
+    hybrid.indexes._append(table, hybrid._rows(8, 4), schema=hybrid.SCHEMA)
+    
table.file_io.delete(table.snapshot_manager().get_snapshot_path(snapshot.id))
+    classic = fixed.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    expected = _search(classic, family)
+    with _native_only():
+        result = _search(fixed, family)
+    if family == 'scan':
+        assert result == expected == list(range(8))
+    elif family == 'batch':
+        assert len(result) == 3
+        for actual, reference in zip(result, expected):
+            assert actual == pytest.approx(reference, abs=1e-6)
+    else:
+        assert result == pytest.approx(expected, abs=1e-6)
+    assert hybrid.indexes._ids(table) == list(range(12))
+
+
+def _query(catalog, table, family):
+    from pypaimon.multimodal.table import MultimodalTable, text_route, 
vector_route
+    multimodal = MultimodalTable(catalog, table.identifier, table)
+    if family == 'batch':
+        query = multimodal.search_vectors([[0., 0.], [6., 0.]], 
column='embedding')
+    elif family == 'text':
+        query = multimodal.search('paimon', column='content')
+    elif family == 'hybrid':
+        query = multimodal.search_hybrid([
+            vector_route('embedding', [0., 0.]), 
text_route(match_query('paimon'), column='content')])
+    else:
+        query = multimodal.search([0., 0.], column='embedding')
+    return query.pre_filter("label = 'keep'").select(['id', 'label']).limit(16)
+
+
[email protected]('family', ['vector', 'batch', 'text', 'hybrid'])
[email protected]('change', ['update', 'delete'])
+def test_native_search_and_lookup_use_one_view_across_commit(rest_catalog, 
family, change):
+    from pypaimon.multimodal.query import ScanQuery
+    catalog, _ = rest_catalog
+    schema = pa.schema([pa.field('embedding', pa.list_(pa.float32(), 2))
+                        if field.name == 'embedding' else field for field in 
hybrid.SCHEMA])
+    table = hybrid.indexes._create(catalog, schema=schema, partitioned=True, 
options={
+        'read.native.enabled': 'true', 'vector-index.search-mode': 'full',
+        'full-text-index.search-mode': 'full'})
+    hybrid.indexes._append(table, hybrid._rows(), schema=schema)
+    table.create_global_index('content', index_type='full-text')
+    table.create_global_index('embedding', index_type='ivf-flat', options={
+        'ivf-flat.dimension': '2', 'ivf-flat.nlist': '1', 
'ivf-flat.distance.metric': 'l2'})
+    table = table.copy({'deletion-vectors.enabled': 'true'})
+    query = _query(catalog, table, family)
+    expected = query.to_list()
+    lookup = ScanQuery._read_global_index_result
+    views = []
+    write_builder = table.new_batch_write_builder()
+    updater = write_builder.new_update()
+    predicate = table.new_read_builder().new_predicate_builder().equal('id', 1)
+    messages = (updater.update_by_predicate(predicate, {'label': 'drop'}) if 
change == 'update'
+                else updater.delete_by_predicate(predicate))
+    commit = write_builder.new_commit()
+
+    def commit_before_lookup(execution, result):
+        if not views:
+            commit.commit(messages)
+        views.append(execution._table)
+        return lookup(execution, result)
+
+    try:
+        with _native_only(), hybrid._core_only(), \
+                patch.object(ScanQuery, '_read_global_index_result', 
commit_before_lookup):
+            assert query.to_list() == expected
+    finally:
+        commit.close()
+    assert all(view._read_snapshot.id == views[0]._read_snapshot.id for view 
in views)
+    with _native_only(), hybrid._core_only():
+        latest = query.to_list()
+    assert latest != expected
+    for rows in (latest if family == 'batch' else [latest]):
+        assert 1 not in {row['id'] for row in rows}
+    assert not hasattr(query._table, '_read_snapshot')
+
+
[email protected]('family', ['scan', 'vector', 'batch', 'text'])
+def 
test_captured_view_skips_rest_snapshot_lookup_and_keeps_cache_isolated(rest_catalog,
 family):
+    from pypaimon.api.api_response import ErrorResponse
+    catalog, server = rest_catalog
+    table = hybrid._table(catalog)
+    first = table.snapshot_manager().get_latest_snapshot()
+    fixed = table._copy_with_snapshot(first).copy({'read.batch-size': '1'})
+    expected = _search(fixed.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'}), family)
+    hybrid.indexes._append(table, hybrid._rows(8, 4), schema=hybrid.SCHEMA)
+    reply = ErrorResponse('TABLE', 'indexes', 'latest must not be loaded', 503)
+    with patch.object(server, '_table_snapshot_handle', 
return_value=server._mock_response(reply, 503)) as latest:
+        with _native_only():
+            actual = _search(fixed, family)
+            empty = _search(table._copy_with_snapshot(None), family)
+        assert latest.call_count == 0
+    if family == 'batch':
+        for scores, reference in zip(actual, expected):
+            assert scores == pytest.approx(reference, abs=1e-6)
+        assert empty == [{}, {}, {}]
+    else:
+        assert actual == (expected if family == 'scan' else 
pytest.approx(expected, abs=1e-6))
+        assert empty == ([] if family == 'scan' else {})
+    # The cached REST Table is a base view. Pinning copies cannot change it.
+    assert hybrid.indexes._ids(table) == list(range(12))
+    changed = fixed.copy({'scan.snapshot-id': None, 'scan.mode': 'default'})
+    assert not hasattr(changed, '_read_snapshot')
+    assert hybrid.indexes._ids(changed) == list(range(12))
+
+
[email protected]('family', ['vector', 'batch', 'text'])
[email protected]('response', ['empty', 'error'])
+def 
test_rest_snapshot_response_is_authoritative_without_search_retry(rest_catalog, 
family, response):
+    from pypaimon.api.api_response import ErrorResponse, 
GetTableSnapshotResponse
+    catalog, server = rest_catalog
+    table = hybrid._table(catalog)
+    reply = GetTableSnapshotResponse() if response == 'empty' else 
ErrorResponse(
+        'TABLE', 'indexes', 'snapshot unavailable', 503)
+    code = 200 if response == 'empty' else 503
+    with patch.object(server, '_table_snapshot_handle', 
return_value=server._mock_response(reply, code)) as latest:
+        with _native_only():
+            if response == 'error':
+                with pytest.raises(Exception, match='snapshot unavailable'):
+                    _search(table, family)
+            else:
+                assert _search(table, family) == ([{}, {}, {}] if family == 
'batch' else {})
+        assert latest.call_count == 1
+    assert hybrid.indexes._ids(table) == list(range(8))
+
+
[email protected]('family', ['vector', 'batch', 'text'])
+def test_missing_index_preserves_native_error_without_retry(rest_catalog, 
family):
+    catalog, _ = rest_catalog
+    table = hybrid._table(catalog)
+    index_type = 'full-text' if family == 'text' else 'ivf-flat'
+    index = next(file for file in hybrid.indexes._files(table) if 
file.index_type == index_type)
+    table.file_io.delete(hybrid.indexes._path(table, index))
+    with _native_only(), pytest.raises(Exception, match='not found|No such 
file|does not exist|NotFound'):
+        _search(table, family)
+    assert hybrid.indexes._ids(table) == list(range(8))
+
+
+def test_snapshot_json_binding_requires_java_snapshot_metadata(rest_catalog):
+    from pypaimon.read.native_plan import _native_table
+    catalog, _ = rest_catalog
+    table = hybrid._table(catalog)
+    with pytest.raises(ValueError, match='Invalid snapshot JSON'):
+        _native_table(table).copy_with_pinned_snapshot('{}')
+    with pytest.raises(ValueError, match='Invalid snapshot JSON'):
+        _native_table(table).copy_with_pinned_snapshot('not json')
+    assert hybrid.indexes._ids(table) == list(range(8))
+
+
[email protected]('view', ['empty', 'tag'])
+def test_primary_key_vector_search_uses_captured_metadata(pk_vector_table, 
view):
+    from pypaimon.table.source.primary_key_vector_read import 
PrimaryKeyVectorRead
+    table = pk_vector_table
+    snapshot = table.snapshot_manager().get_latest_snapshot()
+    if view == 'tag':
+        table.tag_manager().create_tag(snapshot, 'before')
+    fixed = table._copy_with_snapshot(snapshot if view == 'tag' else None)
+    if view == 'tag':
+        
table.file_io.delete(table.snapshot_manager().get_snapshot_path(snapshot.id))
+
+    def search(read_table):
+        return 
(read_table.new_vector_search_builder().with_vector_column('embedding')
+                .with_query_vector([1., 0., 0., 
0.]).with_limit(2).execute_local())
+
+    classic = fixed.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    expected = search(classic)
+    native = fixed.copy({'read.native.enabled': 'true', 
'scan.native-plan.enabled': 'true'})
+    with _native_only(), patch.object(PrimaryKeyVectorRead, 'read_plan',
+                                      side_effect=AssertionError('Python 
primary-key vector read')):
+        result = search(native)
+        read = native.new_read_builder()
+        assert read.new_scan().plan().snapshot_id == (snapshot.id if view == 
'tag' else None)
+        rows = read.new_read().to_arrow(result.splits)
+    assert result.snapshot_id == expected.snapshot_id
+    assert [(position.bucket, position.data_file_name, position.row_position) 
for position in result.positions] == [
+        (position.bucket, position.data_file_name, position.row_position) for 
position in expected.positions]
+    assert [position.score for position in result.positions] == pytest.approx([
+        position.score for position in expected.positions])
+    assert sorted(rows.column('id').to_pylist()) == ([1, 2] if view == 'tag' 
else [])
diff --git a/paimon-python/pypaimon/tests/native_vector_search_test.py 
b/paimon-python/pypaimon/tests/native_vector_search_test.py
index cecd4ca927..5f078bf2cb 100644
--- a/paimon-python/pypaimon/tests/native_vector_search_test.py
+++ b/paimon-python/pypaimon/tests/native_vector_search_test.py
@@ -215,12 +215,20 @@ def 
test_java_pk_index_results_are_physical_selections(pk_vector_table, filtered
         assert split.scores is not None
 
 
-def test_native_search_failure_falls_back(vector_table):
-    native = vector_table.copy({'read.native.enabled': 'true'})
-    expected = _scores(_single(native.copy({'read.native.enabled': 
'false'})).execute_local())
[email protected]('batch', [False, True])
+def test_native_search_failure_is_propagated_without_retry(vector_table, 
batch):
     from pypaimon.table.source import native_vector_search
-    with patch.object(native_vector_search, '_native_table', 
side_effect=RuntimeError('unavailable')):
-        _assert_scores(_scores(_single(native).execute_local()), expected)
+    from pypaimon.table.source.vector_search_read import 
DataEvolutionVectorRead, BatchVectorSearchReadImpl
+    table = vector_table.copy({'read.native.enabled': 'true'})
+    builder = 
(table.new_batch_vector_search_builder().with_vector_column('embedding')
+               .with_query_vectors([[1, 0], [0, 1]]).with_limit(3)) if batch 
else _single(table)
+    method = 'execute_batch_local' if batch else 'execute_local'
+    with patch.object(native_vector_search, '_native_table', 
side_effect=RuntimeError('unavailable')), \
+            patch.object(DataEvolutionVectorRead, 'read_plan', 
side_effect=AssertionError('Python search retried')), \
+            patch.object(BatchVectorSearchReadImpl, 'read_batch_plan',
+                         side_effect=AssertionError('Python batch search 
retried')):
+        with pytest.raises(RuntimeError, match='unavailable'):
+            getattr(builder, method)()
 
 
 def test_explicit_disable_uses_python(vector_table):
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 28b67863c1..1f22cd7d65 100644
--- a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
+++ b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
@@ -366,7 +366,7 @@ class PrimaryKeyIndexDefinitionsTest(unittest.TestCase):
         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"])
+        self.assertNotIn("scan.native-plan.enabled", copied_options[0])
 
     def test_pk_index_source_policy_matches_java(self):
         compact = SimpleNamespace(file_source=1, level=1)
diff --git a/paimon-python/pypaimon/tests/search_snapshot_test.py 
b/paimon-python/pypaimon/tests/search_snapshot_test.py
index 39bd387c9c..b19e9eda1e 100644
--- a/paimon-python/pypaimon/tests/search_snapshot_test.py
+++ b/paimon-python/pypaimon/tests/search_snapshot_test.py
@@ -147,7 +147,7 @@ def 
test_time_travel_survives_concurrent_writes_and_retained_tag_snapshot(table,
         table.update("id = 1", {"category": "blocked"})
         if selector in ("tag", "version"):
             table.raw_table.replace_tag("training")
-            assert not 
execution._table.options.native_plan_enabled(default=True)
+            assert execution._table._read_snapshot.id == source.id
         # Read-option copies must retain the captured snapshot too.
         execution._table = execution._table.copy({"read.batch-size": "2"})
         return lookup(execution, result)
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 655080f8bc..578f252b83 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -3231,7 +3231,6 @@ class VectorSearchManySplitsTest(unittest.TestCase):
             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)
 

Reply via email to