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 487a411492 [python] Use Native StreamTableScan for streaming reads 
(#10434)
487a411492 is described below

commit 487a4114923a7f7c29966ce8fa460f301ba75ce0
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 19:25:28 2026 +0800

    [python] Use Native StreamTableScan for streaming reads (#10434)
---
 paimon-python/conftest.py                          |  13 +
 paimon-python/pypaimon/read/native_plan.py         |  18 +-
 paimon-python/pypaimon/read/stream_read_builder.py |  20 +-
 .../pypaimon/read/streaming_table_scan.py          | 181 +++++----
 .../pypaimon/tests/native_pk_blob_limit_test.py    |  12 +-
 .../pypaimon/tests/native_plan_incremental_test.py |  16 +-
 .../tests/native_stream_table_scan_test.py         | 405 +++++++++++++++++++++
 .../pypaimon/tests/stream_read_builder_test.py     |  23 ++
 .../pypaimon/tests/streaming_table_scan_test.py    | 161 --------
 9 files changed, 600 insertions(+), 249 deletions(-)

diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index 0ca0ac11a0..85c1f635a0 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -113,6 +113,19 @@ def pytest_configure(config):
 
         TableScan._try_native_plan = tracked_plan
 
+        from pypaimon.read.streaming_table_scan import StreamTableScan
+
+        original_stream_plan = StreamTableScan.plan
+
+        def tracked_stream_plan(self, *args, **kwargs):
+            global _native_plan_count
+            plan = original_stream_plan(self, *args, **kwargs)
+            if plan is not None and _force_native_for_test:
+                _native_plan_count += 1
+            return plan
+
+        StreamTableScan.plan = tracked_stream_plan
+
     if _native_read_enabled():
         from pypaimon.read.table_read import TableRead
 
diff --git a/paimon-python/pypaimon/read/native_plan.py 
b/paimon-python/pypaimon/read/native_plan.py
index 60ece775c9..bd41fb6185 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -449,7 +449,23 @@ def native_plan(
         scan = scan.with_chunk_shuffle(str(seed), chunk_size)
     if shard is not None:
         scan = scan.with_shard(*shard)
-    rust_plan = scan.plan()
+    return _from_native_plan(table, scan.plan())
+
+
+def native_stream_scan(table, predicate=None, read_type=None, 
bucket_filter=None, consumer_id=None):
+    """Create Rust's stateful StreamTableScan with the canonical reader 
type."""
+    builder = _configure_native_read_builder(
+        _native_read_builder(table), predicate, None, None, 
read_type=read_type)
+    scan = builder.new_stream_scan()
+    if bucket_filter is not None:
+        scan.with_bucket_filter(bucket_filter)
+    if consumer_id is not None:
+        scan.with_consumer_id(consumer_id)
+    return scan
+
+
+def _from_native_plan(table, rust_plan) -> Plan:
+    """Keep one split/metadata bridge for batch and streaming planning."""
     rust_splits = rust_plan.splits()
     pfields = _partition_fields(table)
     # Trimmed primary keys decode per-file min/max keys (PK merge-on-read).
diff --git a/paimon-python/pypaimon/read/stream_read_builder.py 
b/paimon-python/pypaimon/read/stream_read_builder.py
index 13ac1185e7..a3b48c000e 100644
--- a/paimon-python/pypaimon/read/stream_read_builder.py
+++ b/paimon-python/pypaimon/read/stream_read_builder.py
@@ -22,12 +22,15 @@ This module provides a builder for configuring streaming 
reads from Paimon
 tables, similar to ReadBuilder but for continuous streaming use cases.
 """
 
+import logging
+
 from typing import Callable, Dict, List, Optional, Set, Union
 
 from pypaimon.common.predicate import Predicate
 from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.read.native_plan import _raise_if_native_fork_safety_error
 from pypaimon.read.read_builder import ReadBuilder
-from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan
+from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan, 
StreamTableScan
 from pypaimon.read.table_read import TableRead
 from pypaimon.schema.data_types import DataField
 
@@ -122,10 +125,21 @@ class StreamReadBuilder:
         bucket_set: Set[int] = set(bucket_ids)
         return self.with_bucket_filter(lambda bucket: bucket in bucket_set)
 
-    def new_streaming_scan(self) -> AsyncStreamingTableScan:
-        """Create a new AsyncStreamingTableScan with this builder's 
settings."""
+    def new_streaming_scan(self) -> Union[AsyncStreamingTableScan, 
StreamTableScan]:
+        """Create a stateful streaming scan using the configured backend."""
         projection = self._projection_builder()
         projection._validate_map_key_filter()
+        if (self.table.options.native_plan_enabled()
+                and self.table.catalog_environment.table_query_auth(
+                    self.table.options, self.table.identifier) is None):
+            try:
+                return StreamTableScan(
+                    self.table, predicate=self._predicate, 
read_type=projection.read_type(),
+                    poll_interval_ms=self._poll_interval_ms, 
bucket_filter=self._bucket_filter,
+                    consumer_id=self._consumer_id)
+            except Exception as error:
+                _raise_if_native_fork_safety_error(error)
+                logging.warning("Cannot create Native stream scan, using 
Python: %s", error)
         scan = AsyncStreamingTableScan(
             table=self.table,
             predicate=self._predicate,
diff --git a/paimon-python/pypaimon/read/streaming_table_scan.py 
b/paimon-python/pypaimon/read/streaming_table_scan.py
index cc904e3961..bb666901f3 100644
--- a/paimon-python/pypaimon/read/streaming_table_scan.py
+++ b/paimon-python/pypaimon/read/streaming_table_scan.py
@@ -50,6 +50,7 @@ from pypaimon.read.scanner.incremental_diff_scanner import \
 from pypaimon.read.scanner.primary_key_table_split_generator import \
     PrimaryKeyTableSplitGenerator
 from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.table.bucket_mode import BucketMode
 
 
 class AsyncStreamingTableScan:
@@ -145,9 +146,10 @@ class AsyncStreamingTableScan:
         if self.next_snapshot_id is None:
             latest_snapshot = self._snapshot_manager.get_latest_snapshot()
             if latest_snapshot:
+                plan = self._create_initial_plan(latest_snapshot)
                 self.next_snapshot_id = latest_snapshot.id + 1
                 self._stage_consumer()
-                yield self._create_initial_plan(latest_snapshot)
+                yield plan
                 # Resumes here when caller calls __anext__() — after caller 
processed the plan.
                 self._flush_pending_consumer()
 
@@ -211,14 +213,15 @@ class AsyncStreamingTableScan:
                     self.follow_up_scanner.should_scan,
                     lookahead_size=self._lookahead_size
                 )
-                self._lookahead_skips += skipped_count
-                self.next_snapshot_id = next_id
-
                 # Check if we found a scannable snapshot or skipped some
                 snapshot_processed = skipped_count > 0 or snapshot is not None
 
                 if snapshot is not None:
                     plan = self._create_follow_up_plan(snapshot)
+                # Advance only after constructing the selected frame. A failed
+                # callback/read must leave that snapshot available for retry.
+                self._lookahead_skips += skipped_count
+                self.next_snapshot_id = next_id
 
             if plan is not None:
                 # Start prefetching next scannable snapshot before yielding
@@ -334,6 +337,12 @@ class AsyncStreamingTableScan:
             return [e for e in entries if self._bucket_filter(e.bucket)]
         return entries
 
+    def _only_read_real_buckets(self) -> bool:
+        # Java DataTableStreamScan leaves postpone pending data visible only
+        # when there is no changelog producer to emit it after assignment.
+        return (self.table.options.bucket() == BucketMode.POSTPONE_BUCKET.value
+                and self.table.options.changelog_producer() != 
ChangelogProducer.NONE)
+
     def __create_initial_plan_raw(self, snapshot, auth_result=None):
         def all_manifests():
             return self._manifest_list_manager.read_all(snapshot), snapshot
@@ -344,90 +353,29 @@ class AsyncStreamingTableScan:
             predicate=self.predicate,
             limit=None
         )
+        starting_scanner.only_read_real_buckets = 
self._only_read_real_buckets()
         if auth_result is not None:
             from pypaimon.read.table_scan import prune_scanner_by_auth
             prune_scanner_by_auth(self.table, starting_scanner, auth_result)
-        return starting_scanner.scan()
+        plan = starting_scanner.scan()
+        if self._bucket_filter is not None:
+            plan = Plan([split for split in plan.splits() if 
self._bucket_filter(split.bucket)],
+                        snapshot_id=plan.snapshot_id)
+        return plan
 
     def _create_initial_plan(self, snapshot: Snapshot) -> Plan:
         """Create a Plan for the initial full scan of the latest snapshot."""
         auth_result = self.__auth_query()
-        plan = None
-        if auth_result is None:
-            plan = self._try_native_plan(snapshot.id)
-        if plan is None:
-            plan = self.__create_initial_plan_raw(snapshot, auth_result)
+        plan = self.__create_initial_plan_raw(snapshot, auth_result)
         return wrap_plan_with_auth(auth_result, plan)
 
     def _create_delta_plan(self, snapshot: Snapshot) -> Plan:
         """Read new files from delta_manifest_list 
(changelog-producer=none)."""
-        plan = self._try_native_plan(
-            snapshot.id, incremental_range=(snapshot.id - 1, snapshot.id))
-        if plan is not None:
-            return plan
         manifest_files = self._manifest_list_manager.read_delta(snapshot)
         return self._create_plan_from_manifests(manifest_files, snapshot.id)
 
-    def _try_native_plan(self, expected_snapshot_id: int,
-                         incremental_range=None,
-                         incremental_mode='delta') -> Optional[Plan]:
-        """Plan an initial, delta or changelog streaming frame with 
pypaimon-rust.
-
-        An arbitrary Python bucket predicate cannot be represented by the Rust
-        planner, so sharded stream consumers retain the Python plan. Any native
-        setup or planning error other than a fork-safety failure falls back
-        before a plan is returned.
-        """
-        if (not self.table.options.native_plan_enabled()
-                or self._bucket_filter is not None):
-            return None
-        # Rust's full-snapshot scan currently has batch visibility semantics.
-        # For first-row and non-MOR deletion-vector tables it hides level-0
-        # files, while an initial streaming scan must include those files.
-        # Incremental scans already use Rust's streaming split mode and do not
-        # need this fallback.
-        if (incremental_range is None
-                and self.table.options.batch_scan_skip_level0()):
-            return None
-        try:
-            from pypaimon.read.native_plan import native_plan
-            plan = native_plan(
-                self.table,
-                predicate=self.predicate,
-                projection=(
-                    [field.name for field in self._read_type]
-                    if self._read_type is not None else None),
-                incremental_range=incremental_range,
-                incremental_mode=incremental_mode,
-            )
-            if plan.snapshot_id != expected_snapshot_id:
-                logging.warning(
-                    "Native streaming plan resolved snapshot %s, expected %s; "
-                    "falling back to Python planning",
-                    plan.snapshot_id, expected_snapshot_id)
-                return None
-            return plan
-        except Exception as error:
-            _raise_if_native_fork_safety_error(error)
-            logging.warning(
-                "Native streaming plan failed, falling back to Python 
planning: %s",
-                error)
-            return None
-
     def _create_changelog_plan(self, snapshot: Snapshot) -> Plan:
-        """Read from changelog_manifest_list 
(changelog-producer=input/full-compaction/lookup)."""
-        # Java's range-based incremental CHANGELOG scan skips OVERWRITE,
-        # whereas ChangelogFollowUpScanner reads any follow-up snapshot with a
-        # changelog manifest. Rust's incremental API implements the former, so
-        # keep OVERWRITE on the per-snapshot Python planner.
-        if snapshot.commit_kind != 'OVERWRITE':
-            plan = self._try_native_plan(
-                snapshot.id,
-                incremental_range=(snapshot.id - 1, snapshot.id),
-                incremental_mode='changelog',
-            )
-            if plan is not None:
-                return plan
+        """Java ChangelogFollowUpScanner consumes any snapshot with a 
changelog."""
         manifest_files = self._manifest_list_manager.read_changelog(snapshot)
         return self._create_plan_from_manifests(manifest_files, snapshot.id)
 
@@ -452,6 +400,8 @@ class AsyncStreamingTableScan:
         )
 
         # Apply shard/bucket filtering for parallel consumption
+        if self._only_read_real_buckets():
+            entries = [entry for entry in entries if entry.bucket >= 0]
         entries = self._filter_entries_for_shard(entries) if entries else []
         if not entries:
             return Plan([], snapshot_id=snapshot_id)
@@ -511,3 +461,88 @@ class AsyncStreamingTableScan:
         else:
             plan = IncrementalDiffScanner(self.table).scan(start_snapshot, 
end_snapshot)
         return wrap_plan_with_auth(auth_result, plan)
+
+
+class StreamTableScan:
+    """Python iterator adapter for Rust's stateful StreamTableScan.
+
+    Rust owns snapshot selection, split planning, checkpoints and consumer
+    persistence. Python only polls, converts splits and acknowledges a yielded
+    plan when the caller resumes the iterator after processing it.
+    """
+
+    def __init__(self, table, predicate=None, read_type=None, 
poll_interval_ms=1000,
+                 bucket_filter=None, consumer_id=None):
+        from pypaimon.read.native_plan import native_stream_scan
+        self.table = table
+        self.poll_interval = poll_interval_ms / 1000.0
+        self._scan = native_stream_scan(
+            table, predicate=predicate, read_type=read_type,
+            bucket_filter=bucket_filter, consumer_id=consumer_id)
+        self._pending_consumer_snapshot = None
+
+    @property
+    def next_snapshot_id(self):
+        return self.checkpoint()
+
+    @next_snapshot_id.setter
+    def next_snapshot_id(self, next_snapshot_id):
+        self.restore(next_snapshot_id)
+
+    def checkpoint(self):
+        return self._scan.checkpoint()
+
+    def watermark(self):
+        return self._scan.watermark()
+
+    def restore(self, next_snapshot_id=None):
+        self._scan.restore(next_snapshot_id)
+        self._pending_consumer_snapshot = None
+
+    def notify_checkpoint_complete(self, next_snapshot):
+        self._scan.notify_checkpoint_complete(next_snapshot)
+
+    def plan(self):
+        from pypaimon.read.native_plan import _from_native_plan
+        # Decode before returning or acknowledging progress. A failed 
conversion
+        # must leave the selected frame available for retry.
+        checkpoint = self.checkpoint()
+        native_plan = self._scan.plan()
+        if native_plan is None:
+            return None
+        try:
+            return _from_native_plan(self.table, native_plan)
+        except Exception:
+            self._scan.restore(checkpoint)
+            raise
+
+    def _flush_pending_consumer(self):
+        if self._pending_consumer_snapshot is not None:
+            self.notify_checkpoint_complete(self._pending_consumer_snapshot)
+            self._pending_consumer_snapshot = None
+
+    async def stream(self):
+        while True:
+            self._flush_pending_consumer()
+            checkpoint = self.checkpoint()
+            plan = self.plan()
+            if plan is None:
+                next_snapshot = self.checkpoint()
+                if next_snapshot is not None and next_snapshot != checkpoint:
+                    # Skipped frames have no rows awaiting caller processing.
+                    self._pending_consumer_snapshot = next_snapshot
+                    self._flush_pending_consumer()
+                await asyncio.sleep(self.poll_interval)
+                continue
+            self._pending_consumer_snapshot = self.checkpoint()
+            yield plan
+
+    def stream_sync(self):
+        loop = asyncio.new_event_loop()
+        iterator = self.stream()
+        try:
+            while True:
+                yield loop.run_until_complete(iterator.__anext__())
+        finally:
+            loop.run_until_complete(iterator.aclose())
+            loop.close()
diff --git a/paimon-python/pypaimon/tests/native_pk_blob_limit_test.py 
b/paimon-python/pypaimon/tests/native_pk_blob_limit_test.py
index c3a49b79d0..140faa3bf9 100644
--- a/paimon-python/pypaimon/tests/native_pk_blob_limit_test.py
+++ b/paimon-python/pypaimon/tests/native_pk_blob_limit_test.py
@@ -249,7 +249,11 @@ def 
test_streaming_pk_blob_row_kinds_resolve_only_selected_payload(tmp_path, pay
                    dict(id=2, tag=1, payload=missing, output=missing)])
     builder = table.new_stream_read_builder().with_projection(['id', 
'payload'])
     scan = builder.new_streaming_scan()
-    splits = 
scan._create_delta_plan(table.snapshot_manager().get_latest_snapshot()).splits()
+    if table.options.native_plan_enabled():
+        scan.restore(table.snapshot_manager().get_latest_snapshot().id)
+        splits = scan.plan().splits()
+    else:
+        splits = 
scan._create_delta_plan(table.snapshot_manager().get_latest_snapshot()).splits()
     assert splits and all(split.is_streaming for split in splits)
     predicate = builder.new_predicate_builder().equal('payload', b'selected') 
if payload_filter else None
     reader = TableRead(table, predicate, builder.read_type(), 
include_row_kind=True, limit=1)
@@ -268,7 +272,11 @@ def 
test_pk_row_kind_limit_is_global_across_snapshot_and_delta_splits(tmp_path):
     splits = table.new_read_builder().new_scan().plan().splits()
     _write(table, [dict(id=2, tag=1, payload=missing, output=missing, pt='b')])
     scan = table.new_stream_read_builder().new_streaming_scan()
-    splits += 
scan._create_delta_plan(table.snapshot_manager().get_latest_snapshot()).splits()
+    if table.options.native_plan_enabled():
+        scan.restore(table.snapshot_manager().get_latest_snapshot().id)
+        splits += scan.plan().splits()
+    else:
+        splits += 
scan._create_delta_plan(table.snapshot_manager().get_latest_snapshot()).splits()
     assert len(splits) == 2 and not splits[0].is_streaming and 
splits[1].is_streaming
     # Exercise the Rust quota directly: TableRead's outer truncation must not
     # conceal extra rows or payload reads in the core row-kind path.
diff --git a/paimon-python/pypaimon/tests/native_plan_incremental_test.py 
b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
index 31c54ea997..a72c8ba3e2 100644
--- a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
@@ -406,12 +406,9 @@ def 
test_incremental_reader_preserves_all_physical_row_kinds(catalog, native):
 
 
 @pytest.mark.native_plan
[email protected](
-    not native_method_available('ReadBuilder', 'with_nested_projection'),
-    reason='pypaimon_rust nested native reader API required')
 def test_stream_read_builder_combines_native_nested_projection_and_row_kind(
         catalog):
-    from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan
+    from pypaimon.read.streaming_table_scan import StreamTableScan
 
     schema = pa.schema([
         ('k', pa.int64()),
@@ -445,11 +442,12 @@ def 
test_stream_read_builder_combines_native_nested_projection_and_row_kind(
     builder = (native_table.new_stream_read_builder()
                .with_projection(['payload.score', 'k'])
                .with_include_row_kind())
-    # Use the same delta-plan primitive as the streaming loop without polling.
+    # Restore the public stream scan to consume this snapshot as a delta.
     scan = builder.new_streaming_scan()
-    assert isinstance(scan, AsyncStreamingTableScan)
+    assert isinstance(scan, StreamTableScan)
     snapshot = native_table.snapshot_manager().get_latest_snapshot()
-    plan = scan._create_delta_plan(snapshot)
+    scan.restore(snapshot.id)
+    plan = scan.plan()
     with patch(
             'pypaimon.read.table_read.TableRead._create_split_read',
             side_effect=AssertionError('streaming nested native read fell 
back')):
@@ -494,8 +492,8 @@ def 
test_streaming_changelog_frames_use_native_plan_and_read(catalog):
             if len(plans) == 2:
                 return plans
 
-    with patch.object(
-            scan, '_create_plan_from_manifests',
+    with patch(
+            'pypaimon.read.streaming_table_scan.ManifestFileManager',
             side_effect=AssertionError(
                 'streaming changelog native plan fell back to Python')):
         plans = asyncio.run(first_two_frames())
diff --git a/paimon-python/pypaimon/tests/native_stream_table_scan_test.py 
b/paimon-python/pypaimon/tests/native_stream_table_scan_test.py
new file mode 100644
index 0000000000..61b9d6f941
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_stream_table_scan_test.py
@@ -0,0 +1,405 @@
+# 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.
+
+"""Native StreamTableScan follows Java's initial and follow-up semantics."""
+
+import asyncio
+from contextlib import ExitStack
+import json
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+
+
+pytestmark = pytest.mark.native_plan
+SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.int32())])
+
+
+def _table(tmp_path, engine='deduplicate', producer='none', buckets=1, 
extra=None, schema=SCHEMA):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    options = {
+        'bucket': str(buckets), 'merge-engine': engine,
+        'changelog-producer': producer, 'write.native.enabled': 'false',
+        'source.split.target-size': '1 b', 'source.split.open-file-cost': '1 
b',
+    }
+    options.update(extra or {})
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(
+        schema, primary_keys=['id'], options=options), False)
+    return catalog.get_table('db.t')
+
+
+def _write(table, rows, overwrite=False, schema=SCHEMA):
+    builder = table.new_batch_write_builder()
+    if overwrite:
+        builder.overwrite()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table.snapshot_manager().get_latest_snapshot()
+
+
+def _frame(table, snapshot, mode, native, bucket_filter=None, projection=None, 
predicate=None):
+    if mode == 'delta':
+        table = table.copy({'changelog-producer': 'none'})
+    builder = table.copy({
+        'scan.native-plan.enabled': str(native).lower(),
+        'read.native.enabled': str(native).lower(),
+    }).new_stream_read_builder().with_include_row_kind()
+    if bucket_filter is not None:
+        builder.with_bucket_filter(bucket_filter)
+    if projection is not None:
+        builder.with_projection(projection)
+    if predicate is not None:
+        builder.with_filter(predicate)
+    scan = builder.new_streaming_scan()
+    try:
+        with ExitStack() as stack:
+            if native:
+                stack.enter_context(patch(
+                    'pypaimon.read.streaming_table_scan.FileScanner',
+                    side_effect=AssertionError('Python initial planning')))
+                stack.enter_context(patch(
+                    'pypaimon.read.streaming_table_scan.ManifestFileManager',
+                    side_effect=AssertionError('Python incremental planning')))
+            if native:
+                if mode != 'initial':
+                    scan.restore(snapshot.id)
+                plan = scan.plan()
+            else:
+                plan = getattr(scan, '_create_{}_plan'.format(mode))(snapshot)
+        if plan is None:
+            assert native and mode != 'initial'
+            assert scan.checkpoint() == snapshot.id + 1
+            return None, []
+        reader = builder.new_read()
+        with ExitStack() as stack:
+            if native:
+                stack.enter_context(patch.object(
+                    reader, '_create_split_read', 
side_effect=AssertionError('Python read')))
+            rows = reader.to_arrow(plan.splits()).to_pylist()
+        assert plan.snapshot_id == snapshot.id
+        if native:
+            assert all(getattr(split, '_native_split', None) is not None for 
split in plan.splits())
+        return plan, sorted(rows, key=lambda row: (row.get('id', 0), 
repr(row)))
+    finally:
+        if getattr(scan, '_prefetch_executor', None) is not None:
+            scan._prefetch_executor.shutdown(wait=True)
+
+
[email protected]('engine', ['deduplicate', 'first-row'])
+def 
test_initial_frame_includes_level_zero_and_merges_latest_snapshot(tmp_path, 
engine):
+    table = _table(tmp_path, engine)
+    _write(table, [{'id': 1, 'value': 10}, {'id': 2, 'value': 20}])
+    selected = _write(table, [{'id': 1, 'value': 11}, {'id': 3, 'value': 30}])
+    expected = [
+        {'_row_kind': '+I', 'id': 1, 'value': 10 if engine == 'first-row' else 
11},
+        {'_row_kind': '+I', 'id': 2, 'value': 20},
+        {'_row_kind': '+I', 'id': 3, 'value': 30},
+    ]
+    for native in (False, True):
+        plan, rows = _frame(table, selected, 'initial', native)
+        assert rows == expected
+        assert all(not split.is_streaming for split in plan.splits())
+
+
+def 
test_overwrite_changelog_is_a_stream_frame_not_an_incremental_range(tmp_path):
+    table = _table(tmp_path, producer='input')
+    first = _write(table, [{'id': 1, 'value': 10}])
+    selected = _write(table, [{'id': 2, 'value': 20}], overwrite=True)
+    # The local writer does not publish an overwrite changelog. A fixture with
+    # a real physical changelog list exercises producers which do; its rows
+    # differ from the overwrite's delta data to catch reading the wrong list.
+    path = table.snapshot_manager().get_snapshot_path(selected.id)
+    with open(path) as reader:
+        metadata = json.load(reader)
+    metadata['changelogManifestList'] = first.changelog_manifest_list
+    with open(path, 'w') as writer:
+        json.dump(metadata, writer)
+    selected = table.snapshot_manager().get_snapshot_by_id(selected.id)
+    _write(table, [{'id': 3, 'value': 30}])
+    assert selected.commit_kind == 'OVERWRITE'
+    for native in (False, True):
+        plan, rows = _frame(table, selected, 'changelog', native)
+        assert rows == [{'_row_kind': '+I', 'id': 1, 'value': 10}]
+        assert all(split.is_streaming for split in plan.splits())
+        assert all(file.file_name.startswith('changelog-')
+                   for split in plan.splits() for file in split.files)
+
+
[email protected]('mode', ['initial', 'delta', 'changelog'])
[email protected]('selected', [[], [0], [0, 2], [0, 1, 2, 3]])
+def test_bucket_filter_is_applied_to_every_streaming_frame(tmp_path, mode, 
selected):
+    table = _table(tmp_path, producer='input', buckets=4)
+    _write(table, [{'id': i, 'value': i + 100} for i in range(32)])
+    snapshot = _write(table, [{'id': i, 'value': i + 200} for i in range(32)])
+    predicate = 
table.new_read_builder().new_predicate_builder().greater_than('id', 7)
+    callback = lambda bucket: bucket in selected
+    python_plan, expected = _frame(table, snapshot, mode, False, callback, 
predicate=predicate)
+    native_plan, actual = _frame(table, snapshot, mode, True, callback, 
predicate=predicate)
+    assert actual == expected
+    splits = python_plan.splits() + (native_plan.splits() if native_plan is 
not None else [])
+    assert all(split.bucket in selected for split in splits)
+    if not selected:
+        assert not actual and (native_plan is None or not native_plan.splits())
+    elif len(selected) == 4:
+        assert len(actual) == 24
+
+
[email protected]('mode', ['initial', 'delta', 'changelog'])
+def test_empty_selection_retains_snapshot_and_reader_schema(tmp_path, mode):
+    table = _table(tmp_path, producer='input', buckets=4)
+    snapshot = _write(table, [{'id': 1, 'value': 10}])
+    for native in (False, True):
+        plan, rows = _frame(table, snapshot, mode, native,
+                            bucket_filter=lambda bucket: False, 
projection=['value'])
+        assert (plan is None and mode != 'initial') or plan.snapshot_id == 
snapshot.id
+        assert rows == []
+
+
[email protected]('mode', ['initial', 'delta', 'changelog'])
+def test_bucket_callback_failure_is_not_retried_or_hidden(tmp_path, mode):
+    table = _table(tmp_path, producer='input', buckets=4)
+    snapshot = _write(table, [{'id': i, 'value': i + 100} for i in range(32)])
+    error = LookupError('bucket callback failure')
+    calls = []
+
+    def callback(bucket):
+        calls.append(bucket)
+        if len(calls) == 2:
+            raise error
+        return True
+
+    with pytest.raises(LookupError) as raised:
+        _frame(table, snapshot, mode, True, bucket_filter=callback)
+    assert raised.value is error
+    assert len(calls) == 2
+
+
[email protected]('mode', ['initial', 'delta', 'changelog'])
+def test_projected_predicate_operand_stays_internal(tmp_path, mode):
+    table = _table(tmp_path, producer='input')
+    snapshot = _write(table, [{'id': 1, 'value': 10}, {'id': 2, 'value': 20}])
+    predicate = 
table.new_read_builder().new_predicate_builder().greater_than('id', 1)
+    for native in (False, True):
+        _, rows = _frame(table, snapshot, mode, native, projection=['value'], 
predicate=predicate)
+        assert rows == [{'_row_kind': '+I', 'value': 20}]
+
+
[email protected]('merge_on_read', [False, True])
+def test_initial_deletion_vector_table_keeps_uncompacted_level_zero(tmp_path, 
merge_on_read):
+    table = _table(tmp_path, extra={
+        'deletion-vectors.enabled': 'true',
+        'deletion-vectors.merge-on-read': str(merge_on_read).lower(),
+    })
+    snapshot = _write(table, [{'id': 1, 'value': 10}, {'id': 2, 'value': 20}])
+    snapshot = _write(table, [{'id': 1, 'value': 11}, {'id': 3, 'value': 30}])
+    for native in (False, True):
+        plan, rows = _frame(table, snapshot, 'initial', native)
+        assert rows == [{'_row_kind': '+I', 'id': 1, 'value': 11},
+                        {'_row_kind': '+I', 'id': 2, 'value': 20},
+                        {'_row_kind': '+I', 'id': 3, 'value': 30}]
+        assert all(file.level == 0 for split in plan.splits() for file in 
split.files)
+        predicate = 
table.new_read_builder().new_predicate_builder().equal('value', 10)
+        _, rows = _frame(table, snapshot, 'initial', native, 
predicate=predicate)
+        assert rows == []
+
+
[email protected]('producer,mode', [
+    ('none', 'initial'), ('none', 'delta'), ('input', 'initial'), ('input', 
'changelog')])
+def test_postpone_pending_visibility_matches_java_producer_rule(tmp_path, 
producer, mode):
+    table = _table(tmp_path, producer=producer, buckets=-2)
+    snapshot = _write(table, [{'id': 1, 'value': 10}])
+    expected = ([] if producer != 'none' or mode == 'changelog' else
+                [{'_row_kind': '+I', 'id': 1, 'value': 10}])
+    for native in (False, True):
+        plan, rows = _frame(table, snapshot, mode, native)
+        assert rows == expected
+        if rows:
+            assert all(split.bucket == -2 for split in plan.splits())
+
+
[email protected]('mode', ['initial', 'delta', 'changelog'])
+def 
test_snapshot_plan_retains_structured_read_type_for_nested_and_map_projection(tmp_path,
 mode):
+    schema = pa.schema([
+        ('id', pa.int32()),
+        ('payload', pa.struct([('x', pa.int32()), ('unused', pa.string())])),
+        ('attrs', pa.map_(pa.string(), pa.int32())),
+    ])
+    table = _table(tmp_path, producer='input', schema=schema)
+    snapshot = _write(table, [
+        {'id': 1, 'payload': {'x': 10, 'unused': 'drop'}, 'attrs': 
[('selected', 100), ('unused', 200)]},
+        {'id': 2, 'payload': None, 'attrs': []},
+    ], schema=schema)
+    projection = {'leaf': 'payload.x', 'lookup': 'attrs["selected"]'}
+    for native in (False, True):
+        _, rows = _frame(table, snapshot, mode, native, projection=projection)
+        assert rows == [{'_row_kind': '+I', 'leaf': 10, 'lookup': 100},
+                        {'_row_kind': '+I', 'leaf': None, 'lookup': None}]
+
+
[email protected]('mode', ['initial', 'delta', 'changelog'])
+def test_zero_column_projection_keeps_frame_row_count(tmp_path, mode):
+    table = _table(tmp_path, producer='input')
+    snapshot = _write(table, [{'id': 1, 'value': 10}, {'id': 2, 'value': 20}])
+    for native in (False, True):
+        _, rows = _frame(table, snapshot, mode, native, projection=[])
+        assert rows == [{'_row_kind': '+I'}, {'_row_kind': '+I'}]
+
+
[email protected]('native', [False, True])
[email protected]('initial', [False, True])
+def test_stream_callback_failure_does_not_advance_or_stage_consumer(tmp_path, 
native, initial):
+    from pypaimon.consumer.consumer_manager import ConsumerManager
+    from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan
+
+    table = _table(tmp_path)
+    _write(table, [{'id': 1, 'value': 10}])
+    table = table.copy({'scan.native-plan.enabled': str(native).lower(),
+                        'read.native.enabled': str(native).lower()})
+    failure = LookupError('retry selected snapshot')
+    calls = []
+
+    def callback(bucket):
+        calls.append(bucket)
+        if len(calls) == 1:
+            raise failure
+        return True
+
+    scan = (table.new_stream_read_builder().with_bucket_filter(callback)
+            .with_consumer_id('failed-frame').new_streaming_scan() if native 
else
+            AsyncStreamingTableScan(table, bucket_filter=callback, 
prefetch_enabled=False,
+                                    consumer_id='failed-frame'))
+    consumers = ConsumerManager(table.file_io, table.table_path)
+    if not initial:
+        scan.next_snapshot_id = 1
+
+    async def scenario():
+        failed_stream = scan.stream()
+        with pytest.raises(LookupError) as caught:
+            await failed_stream.__anext__()
+        assert caught.value is failure
+        assert scan.next_snapshot_id == (None if initial else 1)
+        assert scan._pending_consumer_snapshot is None
+        assert consumers.consumer('failed-frame') is None
+        _write(table, [{'id': 2, 'value': 20}])
+        retry = scan.stream()
+        try:
+            plan = await retry.__anext__()
+            expected_id = 2 if initial else 1
+            assert plan.snapshot_id == expected_id
+            builder = table.new_stream_read_builder().with_include_row_kind()
+            rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+            expected = [{'_row_kind': '+I', 'id': 1, 'value': 10}]
+            if initial:
+                expected.append({'_row_kind': '+I', 'id': 2, 'value': 20})
+            assert sorted(rows, key=lambda row: row['id']) == expected
+            assert scan.next_snapshot_id == expected_id + 1
+            assert scan._pending_consumer_snapshot == expected_id + 1
+            # Consumer progress waits for the caller to process the yielded 
plan.
+            assert consumers.consumer('failed-frame') is None
+        finally:
+            await retry.aclose()
+            await failed_stream.aclose()
+
+    asyncio.run(scenario())
+
+
[email protected]('producer', ['none', 'input'])
+def test_empty_follow_up_acknowledges_skipped_progress(tmp_path, producer):
+    from pypaimon.consumer.consumer_manager import ConsumerManager
+    table = _table(tmp_path, producer=producer)
+    for value in range(3):
+        _write(table, [{'id': 1, 'value': value}])
+    table = table.copy({'scan.native-plan.enabled': 'true'})
+    scan = (table.new_stream_read_builder().with_bucket_filter(lambda _: False)
+            .with_consumer_id('filtered').new_streaming_scan())
+    scan.restore(1)
+
+    async def stop_polling(_):
+        raise asyncio.CancelledError()
+
+    async def scenario():
+        iterator = scan.stream()
+        try:
+            with patch('pypaimon.read.streaming_table_scan.asyncio.sleep', 
new=stop_polling):
+                with pytest.raises(asyncio.CancelledError):
+                    await iterator.__anext__()
+        finally:
+            await iterator.aclose()
+    asyncio.run(scenario())
+    assert scan.checkpoint() == 4
+    consumers = ConsumerManager(table.file_io, table.table_path)
+    assert consumers.consumer('filtered').next_snapshot == 4
+
+
+def test_large_gap_keeps_all_native_changelog_frames(tmp_path):
+    table = _table(tmp_path, producer='input')
+    for value in range(12):
+        _write(table, [{'id': 1, 'value': value}])
+    builder = table.copy({'scan.native-plan.enabled': 'true', 
'read.native.enabled': 'true'})
+    builder = builder.new_stream_read_builder().with_include_row_kind()
+    scan = builder.new_streaming_scan()
+    scan.restore(1)
+    for value in range(12):
+        plan = scan.plan()
+        assert plan.snapshot_id == value + 1
+        assert builder.new_read().to_arrow(plan.splits()).to_pylist() == [
+            {'_row_kind': '+I', 'id': 1, 'value': value}]
+    assert scan.plan() is None
+    assert scan.checkpoint() == 13
+
+
+def test_failed_empty_poll_acknowledgement_is_retryable(tmp_path):
+    from pypaimon.consumer.consumer_manager import ConsumerManager
+    table = _table(tmp_path)
+    _write(table, [{'id': 1, 'value': 10}])
+    scan = (table.copy({'scan.native-plan.enabled': 
'true'}).new_stream_read_builder()
+            .with_bucket_filter(lambda _: 
False).with_consumer_id('retry-empty').new_streaming_scan())
+    scan.restore(1)
+    failure = IOError('retry consumer acknowledgement')
+
+    async def stop_polling(_):
+        raise asyncio.CancelledError()
+
+    async def scenario():
+        iterator = scan.stream()
+        try:
+            with patch.object(scan, 'notify_checkpoint_complete', 
side_effect=failure):
+                with pytest.raises(IOError) as caught:
+                    await iterator.__anext__()
+            assert caught.value is failure
+            assert scan._pending_consumer_snapshot == 2
+        finally:
+            await iterator.aclose()
+        iterator = scan.stream()
+        try:
+            with patch('pypaimon.read.streaming_table_scan.asyncio.sleep', 
new=stop_polling):
+                with pytest.raises(asyncio.CancelledError):
+                    await iterator.__anext__()
+        finally:
+            await iterator.aclose()
+    asyncio.run(scenario())
+    assert scan._pending_consumer_snapshot is None
+    assert ConsumerManager(table.file_io, 
table.table_path).consumer('retry-empty').next_snapshot == 2
diff --git a/paimon-python/pypaimon/tests/stream_read_builder_test.py 
b/paimon-python/pypaimon/tests/stream_read_builder_test.py
index a88bf4a69a..8e10db26a5 100644
--- a/paimon-python/pypaimon/tests/stream_read_builder_test.py
+++ b/paimon-python/pypaimon/tests/stream_read_builder_test.py
@@ -167,3 +167,26 @@ class TestConsumerIdPassthrough:
 
 if __name__ == '__main__':
     pytest.main([__file__, '-v'])
+
+
+class TestNativeStreamFactory:
+    def test_optional_native_initialization_falls_back(self, mock_scan_table):
+        mock_scan_table.options.native_plan_enabled.return_value = True
+        builder = StreamReadBuilder(mock_scan_table)
+        with patch('pypaimon.read.stream_read_builder.StreamTableScan',
+                   side_effect=ImportError('optional native runtime 
unavailable')):
+            assert isinstance(builder.new_streaming_scan(), 
AsyncStreamingTableScan)
+
+    def test_native_planning_failure_is_not_retried_with_python(self, 
mock_scan_table):
+        from pypaimon.read.streaming_table_scan import StreamTableScan
+        failure = LookupError('native planning failed')
+        native = MagicMock()
+        native.plan.side_effect = failure
+        with patch('pypaimon.read.native_plan.native_stream_scan', 
return_value=native):
+            scan = StreamTableScan(mock_scan_table)
+        with patch('pypaimon.read.streaming_table_scan.FileScanner') as 
python_planner:
+            with pytest.raises(LookupError) as caught:
+                scan.plan()
+        assert caught.value is failure
+        python_planner.assert_not_called()
+        native.plan.assert_called_once()
diff --git a/paimon-python/pypaimon/tests/streaming_table_scan_test.py 
b/paimon-python/pypaimon/tests/streaming_table_scan_test.py
index 41c1416a1c..2aea5287f1 100644
--- a/paimon-python/pypaimon/tests/streaming_table_scan_test.py
+++ b/paimon-python/pypaimon/tests/streaming_table_scan_test.py
@@ -18,10 +18,7 @@
 """Tests for AsyncStreamingTableScan."""
 
 import asyncio
-import sys
 import unittest
-from concurrent.futures import Future
-from types import ModuleType
 from unittest.mock import Mock, patch
 
 from pypaimon.common.options.core_options import ChangelogProducer
@@ -97,164 +94,6 @@ class AsyncStreamingTableScanTest(unittest.TestCase):
         self.assertIsInstance(plan, Plan)
         self.assertEqual(scan.next_snapshot_id, 6)
 
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan')
-    def test_delta_scan_uses_native_plan_with_projection(
-            self, native_plan, _manifest_files, _manifest_lists):
-        table, _ = _create_mock_table()
-        table.options.native_plan_enabled.return_value = True
-        native_plan.return_value = Plan([], snapshot_id=5)
-        scan = AsyncStreamingTableScan(table, predicate=Mock())
-        scan._read_type = [Mock(name='payload'), Mock(name='id')]
-        scan._read_type[0].name = 'payload'
-        scan._read_type[1].name = 'id'
-
-        plan = scan._create_delta_plan(_create_mock_snapshot(5))
-
-        self.assertIs(plan, native_plan.return_value)
-        self.assertEqual(native_plan.call_args.kwargs['incremental_range'], 
(4, 5))
-        self.assertEqual(
-            native_plan.call_args.kwargs['projection'], ['payload', 'id'])
-
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan')
-    def test_changelog_scan_uses_explicit_native_mode(
-            self, native_plan, _manifest_files, _manifest_lists):
-        table, _ = _create_mock_table()
-        table.options.native_plan_enabled.return_value = True
-        table.options.changelog_producer.return_value = ChangelogProducer.INPUT
-        native_plan.return_value = Plan([], snapshot_id=5)
-        scan = AsyncStreamingTableScan(table, predicate=Mock())
-        scan._read_type = [Mock(name='payload')]
-        scan._read_type[0].name = 'payload'
-
-        plan = scan._create_changelog_plan(_create_mock_snapshot(5))
-
-        self.assertIs(plan, native_plan.return_value)
-        self.assertEqual(
-            native_plan.call_args.kwargs['incremental_range'], (4, 5))
-        self.assertEqual(
-            native_plan.call_args.kwargs['incremental_mode'], 'changelog')
-        self.assertEqual(
-            native_plan.call_args.kwargs['projection'], ['payload'])
-
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan')
-    def test_streaming_plans_propagate_native_fork_safety_error(
-            self, native_plan, _manifest_files, manifest_lists):
-        class ForkSafetyError(RuntimeError):
-            pass
-
-        module = ModuleType('pypaimon_rust')
-        module.ForkSafetyError = ForkSafetyError
-        native_plan.side_effect = ForkSafetyError('cannot reuse Jindo after 
fork')
-
-        for mode in ('initial', 'delta', 'changelog'):
-            with self.subTest(mode=mode):
-                table, _ = _create_mock_table()
-                table.options.native_plan_enabled.return_value = True
-                table.options.batch_scan_skip_level0.return_value = False
-                scan = AsyncStreamingTableScan(table, prefetch_enabled=False)
-                snapshot = _create_mock_snapshot(5)
-                with patch.dict(sys.modules, {'pypaimon_rust': module}), 
patch.object(
-                        scan, '_AsyncStreamingTableScan__auth_query', 
return_value=None), patch.object(
-                        scan, 
'_AsyncStreamingTableScan__create_initial_plan_raw') as initial, patch.object(
-                        scan, '_create_plan_from_manifests') as from_manifests:
-                    with self.assertRaises(ForkSafetyError):
-                        getattr(scan, '_create_{}_plan'.format(mode))(snapshot)
-                initial.assert_not_called()
-                from_manifests.assert_not_called()
-                manifest_lists.return_value.read_all.assert_not_called()
-                manifest_lists.return_value.read_delta.assert_not_called()
-                manifest_lists.return_value.read_changelog.assert_not_called()
-
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan', 
side_effect=RuntimeError('unsupported'))
-    def test_delta_scan_falls_back_after_other_native_error(
-            self, _native_plan, _manifest_files, manifest_lists):
-        table, _ = _create_mock_table()
-        table.options.native_plan_enabled.return_value = True
-        manifest_lists.return_value.read_delta.return_value = []
-        scan = AsyncStreamingTableScan(table, prefetch_enabled=False)
-        snapshot = _create_mock_snapshot(5)
-
-        self.assertEqual(scan._create_delta_plan(snapshot).snapshot_id, 5)
-        
manifest_lists.return_value.read_delta.assert_called_once_with(snapshot)
-
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan')
-    def test_prefetch_propagates_native_fork_safety_error(
-            self, native_plan, _manifest_files, _manifest_lists):
-        class ForkSafetyError(RuntimeError):
-            pass
-
-        module = ModuleType('pypaimon_rust')
-        module.ForkSafetyError = ForkSafetyError
-        error = ForkSafetyError('cannot reuse Jindo after fork')
-        native_plan.side_effect = error
-        table, _ = _create_mock_table()
-        table.options.native_plan_enabled.return_value = True
-        scan = AsyncStreamingTableScan(table, prefetch_enabled=False)
-        scan._snapshot_manager.find_next_scannable.return_value = (
-            _create_mock_snapshot(5), 6, 0)
-
-        with patch.dict(sys.modules, {'pypaimon_rust': module}):
-            with self.assertRaises(ForkSafetyError):
-                scan._fetch_plan_with_lookahead(5)
-
-            future = Future()
-            future.set_exception(error)
-            scan._prefetch_future = future
-            scan.next_snapshot_id = 5
-            with patch.object(scan, '_should_use_diff_catch_up', 
return_value=False):
-                with self.assertRaises(ForkSafetyError):
-                    asyncio.run(scan.stream().__anext__())
-        scan._snapshot_manager.find_next_scannable.assert_called_once()
-
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan')
-    def test_overwrite_changelog_keeps_per_snapshot_python_plan(
-            self, native_plan, _manifest_files, manifest_lists):
-        table, _ = _create_mock_table()
-        table.options.native_plan_enabled.return_value = True
-        table.options.changelog_producer.return_value = ChangelogProducer.INPUT
-        snapshot = _create_mock_snapshot(5, 'OVERWRITE')
-        manifests = [Mock()]
-        manifest_lists.return_value.read_changelog.return_value = manifests
-        python_plan = Plan([], snapshot_id=5)
-        scan = AsyncStreamingTableScan(table)
-
-        with patch.object(
-                scan, '_create_plan_from_manifests',
-                return_value=python_plan) as create_plan:
-            plan = scan._create_changelog_plan(snapshot)
-
-        self.assertIs(plan, python_plan)
-        native_plan.assert_not_called()
-        
manifest_lists.return_value.read_changelog.assert_called_once_with(snapshot)
-        create_plan.assert_called_once_with(manifests, 5)
-
-    @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
-    @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
-    @patch('pypaimon.read.native_plan.native_plan')
-    def test_bucket_filtered_delta_scan_keeps_python_planner(
-            self, native_plan, _manifest_files, manifest_lists):
-        table, _ = _create_mock_table()
-        table.options.native_plan_enabled.return_value = True
-        manifest_lists.return_value.read_delta.return_value = []
-        scan = AsyncStreamingTableScan(table, bucket_filter=lambda bucket: 
True)
-
-        plan = scan._create_delta_plan(_create_mock_snapshot(5))
-
-        self.assertEqual(plan.snapshot_id, 5)
-        native_plan.assert_not_called()
-
     @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
     @patch('pypaimon.read.streaming_table_scan.FileScanner')
     def test_stream_skips_non_append_commits(self, MockStartingScanner, 
MockManifestListManager):

Reply via email to