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 eeb765d405 [Python] Delegate Native split execution to Rust Core 
(#10691)
eeb765d405 is described below

commit eeb765d405963b619cc0194ecf79e2378df3b77e
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 17:18:49 2026 +0800

    [Python] Delegate Native split execution to Rust Core (#10691)
---
 paimon-python/README.md                            |  18 +-
 paimon-python/pypaimon/read/native_plan.py         |  10 +-
 paimon-python/pypaimon/read/table_read.py          | 342 +-----------
 .../pypaimon/tests/native_iterator_test.py         |  98 ++--
 .../tests/native_parquet_vector_update_test.py     |  20 +-
 paimon-python/pypaimon/tests/native_read_test.py   | 604 ++++++---------------
 6 files changed, 292 insertions(+), 800 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index 5f0e4c81d5..d72c46d2c0 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -419,11 +419,19 @@ Python-planned or serialized authorization rules retain 
the Python reader.
 For both materialized
 `to_arrow()` and streaming `to_arrow_batch_reader()` reads, the effective split
 parallelism (the method argument, `read.parallelism`, or the automatic default)
-runs independent Rust readers. Splits stay in input order but contiguous groups
-are balanced by physical file bytes to reduce worker skew. An unfiltered row
-limit also caps reader fan-out to avoid speculative work.
-Streaming buffers at most one batch per reader and closing the batch reader
-interrupts native reads that are still in flight. An uninstalled extension,
+is executed by Rust Core. Core owns byte-balanced split groups, bounded
+prefetch, the shared row limit and cancellation. The Rust reader's `ordered`
+argument defaults to `False`. PyPaimon requests `ordered=True` for materialized
+reads, emitting groups in supplied split order; streaming reads use
+`ordered=False` and emit batches as groups become ready. Both modes retain each
+split reader's row order.
+Ordered parallel reads disable background Parquet row-group prefetch to prevent
+later groups from holding scheduling permits needed by earlier groups; split
+workers still read concurrently.
+An unfiltered row limit also caps reader fan-out to avoid speculative work.
+The split scheduler buffers at most one batch per worker, and closing the batch
+reader interrupts native reads that are still in flight. Limited BLOB reads 
visit
+splits lazily with the remaining quota, before resolving output payloads. An 
uninstalled extension,
 unsupported route, or native-reader construction failure falls back to Python;
 I/O and data errors raised after streaming starts surface to the caller.
 
diff --git a/paimon-python/pypaimon/read/native_plan.py 
b/paimon-python/pypaimon/read/native_plan.py
index fc8a638f76..336c807dfb 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -500,7 +500,7 @@ def _prepare_native_read(table, predicate: 
Optional[Predicate] = None,
                          blob_parallelism: Optional[int] = None,
                          read_type: Optional[List[DataField]] = None,
                          include_row_kind: bool = False):
-    """Create one Rust reader reusable across split groups."""
+    """Create the configured Core reader entrypoint."""
     if not native_runtime_available():
         raise RuntimeError(
             "read.native.enabled needs the pypaimon-rust extension")
@@ -527,8 +527,10 @@ def native_read(table, splits, predicate: 
Optional[Predicate] = None,
                 projection: Optional[List[str]] = None,
                 blob_parallelism: Optional[int] = None,
                 read_type: Optional[List[DataField]] = None,
-                include_row_kind: bool = False):
-    """Read Rust ``Split`` objects into PyArrow ``RecordBatch`` objects."""
+                include_row_kind: bool = False,
+                parallelism: int = 1,
+                ordered: bool = False):
+    """Run one Core read; ``ordered`` retains the supplied split order."""
     read_splits = _prepare_native_read(
         table,
         predicate=predicate,
@@ -537,7 +539,7 @@ def native_read(table, splits, predicate: 
Optional[Predicate] = None,
         blob_parallelism=blob_parallelism,
         read_type=read_type,
         include_row_kind=include_row_kind)
-    return read_splits(splits)
+    return read_splits(splits, parallelism=parallelism, ordered=ordered)
 
 
 def native_plan(
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 6e6b9326d7..6159f17ced 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -17,7 +17,6 @@
 
 import logging
 import os
-import queue
 import threading
 from concurrent.futures import ThreadPoolExecutor, as_completed
 from typing import Any, Callable, Dict, Iterator, List, Optional
@@ -138,10 +137,6 @@ class _RemainingRows:
             return self._remaining <= 0
 
 
-class _NativeReadSetupError(Exception):
-    """Native reader construction failed before a batch was exposed."""
-
-
 class TableRead:
     """Implementation of TableRead for native Python reading."""
 
@@ -244,8 +239,7 @@ class TableRead:
             read._row_kind_column = 'rowkind'
             schema = 
read._add_row_kind_to_schema(read._output_arrow_schema(splits), 
read._row_kind_column)
             fields = output_fields(self.read_type, self.output_projection)
-            batches = read._try_native_batches(splits, schema, parallelism=1, 
streaming=True,
-                                               preserve_split_order=True)
+            batches = read._try_native_batches(splits, schema, parallelism=1, 
ordered=True)
             if batches is not None:
                 try:
                     yield from native_rows(batches, self.table, fields)
@@ -316,8 +310,8 @@ class TableRead:
         """Return a closeable batch reader supporting context management.
 
         PyArrow readers do not close the Python iterator passed to
-        ``from_batches``. Retain it explicitly for a parallel read so native
-        split workers are stopped when a caller ends the read early.
+        ``from_batches``. Retain it explicitly so native reads release their
+        resources when a caller ends the read early, including serial reads.
         """
         # Keep the explicit iterator cleanup chain even when LIMIT caps the
         # native worker count inside _new_arrow_batch_reader to one.
@@ -339,22 +333,23 @@ class TableRead:
         schema = self._output_arrow_schema(splits)
         if self.include_row_kind:
             schema = self._add_row_kind_to_schema(schema)
-        effective = self._effective_parallelism(parallelism, len(splits))
+        effective = self._resolve_parallelism(parallelism, len(splits))
         effective_bp = self._resolve_blob_parallelism(blob_parallelism)
-        if self._should_run_parallel(splits, effective):
-            effective_bp = self._cap_blob_parallelism(
-                min(effective, len(splits)), effective_bp)
         native_batches = self._try_native_batches(
             splits,
             schema,
             parallelism=effective,
             blob_parallelism=effective_bp,
-            streaming=True,
+            ordered=False,
         )
         if native_batches is not None:
             batch_iterator = iter(native_batches)
         else:
             self._check_python_merge_supported()
+            effective = self._effective_parallelism(parallelism, len(splits))
+            if self._should_run_parallel(splits, effective):
+                effective_bp = self._cap_blob_parallelism(
+                    min(effective, len(splits)), effective_bp)
             batch_iterator = self._arrow_batch_generator(
                 splits, schema, effective_bp)
         reader_type = pyarrow.ipc.RecordBatchReader
@@ -436,26 +431,23 @@ class TableRead:
                 shrunk to stay within it.
         """
         effective_bp = self._resolve_blob_parallelism(blob_parallelism)
-        effective = self._effective_parallelism(parallelism, len(splits))
+        effective = self._resolve_parallelism(parallelism, len(splits))
         schema = self._output_arrow_schema(splits)
         if self.include_row_kind:
             schema = self._add_row_kind_to_schema(schema)
 
-        native_parallel = self._should_run_parallel(splits, effective)
-        native_bp = effective_bp
-        if native_parallel:
-            native_bp = self._cap_blob_parallelism(
-                min(effective, len(splits)), effective_bp)
         native_batches = self._try_native_batches(
             splits,
             schema,
             parallelism=effective,
-            blob_parallelism=native_bp,
+            blob_parallelism=effective_bp,
+            ordered=True,
         )
         if native_batches is not None:
             return self._batches_to_arrow(native_batches, schema)
 
         self._check_python_merge_supported()
+        effective = self._effective_parallelism(parallelism, len(splits))
         if self._should_run_parallel(splits, effective):
             return self._to_arrow_parallel(splits, schema, effective, 
effective_bp)
 
@@ -597,8 +589,7 @@ class TableRead:
             schema: pyarrow.Schema,
             parallelism: Optional[int] = None,
             blob_parallelism: Optional[int] = None,
-            streaming: bool = False,
-            preserve_split_order: bool = False):
+            ordered: bool = False):
         """Return Rust-read batches, or ``None`` when this read must fall 
back."""
         if not self.table.options.native_read_enabled():
             return self._native_fallback("read.native.enabled is false")
@@ -612,12 +603,10 @@ class TableRead:
                 "the table uses an unsupported BLOB view")
         try:
             from pypaimon.read.native_plan import (
-                _prepare_native_read, _raise_if_native_fork_safety_error,
-                native_read, native_split_from_python)
+                _raise_if_native_fork_safety_error, native_read, 
native_split_from_python)
         except Exception as e:
             return self._native_fallback(str(e), e, log=True)
         rust_splits = []
-        split_weights = []
         for split in splits:
             if not self._native_split_files_supported(split):
                 return self._native_fallback(
@@ -633,86 +622,20 @@ class TableRead:
                         e,
                         log=True)
             rust_splits.append(rust_split)
-            split_weights.append(self._native_split_weight(split))
-        if preserve_split_order:
-            read_kwargs = self._native_read_kwargs(blob_parallelism)
-            try:
-                read_splits = _prepare_native_read(self.table, **read_kwargs)
-            except Exception as e:
-                _raise_if_native_fork_safety_error(e)
-                return self._native_fallback(str(e), e, log=True)
-            return self._convert_native_batches(
-                self._native_batches_ordered(read_splits, rust_splits, 
read_kwargs), schema)
-        if (parallelism is not None
-                and self._should_run_parallel(splits, parallelism)):
-            read_kwargs = self._native_read_kwargs(blob_parallelism)
-            try:
-                read_splits = _prepare_native_read(self.table, **read_kwargs)
-            except Exception as e:
-                _raise_if_native_fork_safety_error(e)
-                return self._native_fallback(str(e), e, log=True)
-            if streaming:
-                groups = self._native_split_groups(
-                    rust_splits, parallelism, split_weights)
-                readers = []
-                try:
-                    for group in groups:
-                        readers.append(read_splits(group))
-                except Exception as e:
-                    for reader in readers:
-                        close = getattr(reader, 'close', None)
-                        if close is not None:
-                            try:
-                                close()
-                            except Exception:
-                                logger.debug(
-                                    "Failed to close a native reader after "
-                                    "another reader failed to start",
-                                    exc_info=True,
-                                )
-                    _raise_if_native_fork_safety_error(e)
-                    return self._native_fallback(str(e), e, log=True)
-                batches = self._native_batches_parallel_streaming(readers)
-                return self._convert_native_batches(batches, schema)
-            try:
-                return self._native_batches_parallel(
-                    read_splits, rust_splits, schema, parallelism,
-                    split_weights)
-            except _NativeReadSetupError as e:
-                _raise_if_native_fork_safety_error(e)
-                return self._native_fallback(str(e), e, log=True)
+        # Python adapts the public configuration; Core owns split grouping,
+        # bounded concurrency, the row quota and cancellation of every stream.
+        effective = 1 if parallelism is None else parallelism
         try:
-            read_kwargs = self._native_read_kwargs(blob_parallelism)
-            batches = native_read(self.table, rust_splits, **read_kwargs)
-        except Exception as e:
-            _raise_if_native_fork_safety_error(e)
-            return self._native_fallback(str(e), e, log=True)
+            batches = native_read(
+                self.table, rust_splits,
+                parallelism=effective,
+                ordered=ordered,
+                **self._native_read_kwargs(blob_parallelism))
+        except Exception as error:
+            _raise_if_native_fork_safety_error(error)
+            return self._native_fallback(str(error), error, log=True)
         return self._convert_native_batches(batches, schema)
 
-    def _native_batches_ordered(self, read_splits, splits, read_kwargs):
-        from pypaimon.read.native_plan import _prepare_native_read
-
-        remaining = self.limit
-        for index, split in enumerate(splits):
-            if remaining is not None and remaining <= 0:
-                break
-            if index and remaining is not None:
-                # BLOB readers must see the remaining quota before opening any
-                # payload, including when earlier splits already spent it.
-                read_splits = _prepare_native_read(self.table, 
**dict(read_kwargs, limit=remaining))
-            batches = read_splits([split])
-            try:
-                for batch in batches:
-                    yield batch
-                    if remaining is not None:
-                        remaining -= batch.num_rows
-                        if remaining <= 0:
-                            break
-            finally:
-                close = getattr(batches, 'close', None)
-                if close is not None:
-                    close()
-
     def _native_read_kwargs(self, blob_parallelism=None):
         kwargs = {
             'predicate': self.predicate,
@@ -725,207 +648,6 @@ class TableRead:
             kwargs['include_row_kind'] = True
         return kwargs
 
-    @staticmethod
-    def _native_split_weight(split):
-        """Estimate native split work from its physical data-file bytes."""
-        try:
-            sizes = [data_file.file_size for data_file in split.files]
-        except (AttributeError, TypeError):
-            return 1
-        if any(not isinstance(size, int) for size in sizes):
-            return 1
-        return max(1, sum(max(0, size) for size in sizes))
-
-    @staticmethod
-    def _native_split_groups(rust_splits, effective, weights=None):
-        workers = min(effective, len(rust_splits))
-        if weights is None:
-            weights = [1] * len(rust_splits)
-        if len(weights) != len(rust_splits):
-            raise ValueError("split weights must match splits")
-        weights = [max(1, int(weight)) for weight in weights]
-
-        # Find the smallest possible maximum group weight for a contiguous
-        # partition. Keeping groups contiguous preserves split order when the
-        # non-streaming path flattens worker results.
-        low, high = max(weights), sum(weights)
-        while low < high:
-            capacity = (low + high) // 2
-            groups_needed = 1
-            current = 0
-            for weight in weights:
-                if current + weight > capacity:
-                    groups_needed += 1
-                    current = weight
-                else:
-                    current += weight
-            if groups_needed <= workers:
-                high = capacity
-            else:
-                low = capacity + 1
-
-        capacity = low
-        groups = []
-        offset = 0
-        for index in range(workers):
-            remaining_groups = workers - index
-            if remaining_groups == 1:
-                groups.append(rust_splits[offset:])
-                break
-            end = offset
-            group_weight = 0
-            max_end = len(rust_splits) - (remaining_groups - 1)
-            while end < max_end:
-                weight = weights[end]
-                if end > offset and group_weight + weight > capacity:
-                    break
-                group_weight += weight
-                end += 1
-            groups.append(rust_splits[offset:end])
-            offset = end
-        return groups
-
-    def _native_batches_parallel(
-            self, read_splits, rust_splits, schema, effective,
-            split_weights=None):
-        """Read contiguous split groups with independent Rust streams."""
-        groups = self._native_split_groups(
-            rust_splits, effective, split_weights)
-        workers = len(groups)
-
-        remaining_state = _RemainingRows(self.limit)
-        results = [None] * len(groups)
-        with ThreadPoolExecutor(
-                max_workers=workers,
-                thread_name_prefix="pypaimon-native-read") as executor:
-            futures = {
-                executor.submit(
-                    self._read_native_split_group,
-                    read_splits,
-                    group,
-                    schema,
-                    remaining_state,
-                ): index
-                for index, group in enumerate(groups)
-            }
-            for future in as_completed(futures):
-                results[futures[future]] = future.result()
-
-        return [batch for group_batches in results for batch in group_batches]
-
-    def _native_batches_parallel_streaming(self, readers):
-        """Stream ready batches with one buffered batch per Rust reader."""
-        stop = threading.Event()
-        results = queue.Queue(maxsize=len(readers))
-        capacities = [threading.Semaphore(1) for _ in readers]
-        executor = ThreadPoolExecutor(
-            max_workers=len(readers),
-            thread_name_prefix="pypaimon-native-read",
-        )
-
-        def put(index, item):
-            while not stop.is_set():
-                try:
-                    results.put((index, item), timeout=0.1)
-                    return True
-                except queue.Full:
-                    pass
-            return False
-
-        def read_group(index, batches):
-            try:
-                iterator = iter(batches)
-                while not stop.is_set():
-                    if not capacities[index].acquire(timeout=0.1):
-                        continue
-                    if stop.is_set():
-                        capacities[index].release()
-                        return
-                    try:
-                        batch = next(iterator)
-                    except StopIteration:
-                        put(index, ('done', None))
-                        return
-                    if not put(index, ('batch', batch)):
-                        return
-            except Exception as error:
-                put(index, ('error', error))
-            finally:
-                close = getattr(batches, 'close', None)
-                if close is not None:
-                    close()
-
-        futures = [
-            executor.submit(read_group, index, reader)
-            for index, reader in enumerate(readers)
-        ]
-        try:
-            completed = 0
-            while completed < len(readers):
-                index, (kind, value) = results.get()
-                if kind == 'batch':
-                    try:
-                        yield value
-                    except BaseException:
-                        # Stop workers before releasing this reader's permit.
-                        # Otherwise an early close can let it enter another
-                        # blocking ``next`` before the outer finally runs.
-                        stop.set()
-                        raise
-                    finally:
-                        capacities[index].release()
-                elif kind == 'done':
-                    capacities[index].release()
-                    completed += 1
-                else:
-                    capacities[index].release()
-                    raise value
-        finally:
-            stop.set()
-            # A worker may be blocked inside its first native next(), before
-            # it has produced a batch or observed stop. Close every reader to
-            # interrupt that in-flight Rust operation before waiting for the
-            # executor. Native close is idempotent because workers also close
-            # their own readers in read_group's finally block.
-            for reader in readers:
-                close = getattr(reader, 'close', None)
-                if close is not None:
-                    try:
-                        close()
-                    except Exception:
-                        logger.debug(
-                            "Failed to close a native reader while stopping "
-                            "parallel read",
-                            exc_info=True,
-                        )
-            for future in futures:
-                future.cancel()
-            executor.shutdown(wait=True)
-
-    def _read_native_split_group(
-            self, read_splits, rust_splits, schema, remaining_state):
-        if remaining_state.exhausted():
-            return []
-        try:
-            batches = read_splits(rust_splits)
-        except Exception as e:
-            raise _NativeReadSetupError(str(e)) from e
-        result = []
-        for batch in batches:
-            if batch.num_rows == 0:
-                continue
-            allowed = remaining_state.try_consume(batch.num_rows)
-            if allowed == 0:
-                break
-            if allowed < batch.num_rows:
-                batch = batch.slice(0, allowed)
-            batch = self._prepare_native_batch(batch, schema)
-            batch = self._project_batch_to_output(batch)
-            result.append(self._try_to_pad_batch_by_schema(batch, schema))
-            if remaining_state.exhausted():
-                break
-        return result
-
     @staticmethod
     def _native_split_files_supported(split):
         for data_file in split.files:
@@ -936,20 +658,13 @@ class TableRead:
         return True
 
     def _convert_native_batches(self, batches, schema):
-        remaining = self.limit
         try:
             for batch in batches:
                 if batch.num_rows == 0:
                     continue
                 batch = self._prepare_native_batch(batch, schema)
-                if remaining is not None and batch.num_rows > remaining:
-                    batch = batch.slice(0, remaining)
                 batch = self._project_batch_to_output(batch)
                 yield self._try_to_pad_batch_by_schema(batch, schema)
-                if remaining is not None:
-                    remaining -= batch.num_rows
-                    if remaining <= 0:
-                        break
         finally:
             close = getattr(batches, 'close', None)
             if close is not None:
@@ -1124,7 +839,10 @@ class TableRead:
                 and not self._limit_covers_all_splits(splits))
 
     def _requires_batch_iterator_cleanup(self, splits: List[Split], effective: 
int) -> bool:
-        return (self._should_run_parallel(splits, effective)
+        # A serial Core stream owns resources too. PyArrow's reader does not
+        # close the Python iterator supplied to from_batches on an early close.
+        return (self.table.options.native_read_enabled()
+                or self._should_run_parallel(splits, effective)
                 or self._deferred_blob_limit_may_prune(splits))
 
     def _native_blob_view_supported(self) -> bool:
diff --git a/paimon-python/pypaimon/tests/native_iterator_test.py 
b/paimon-python/pypaimon/tests/native_iterator_test.py
index 4f20936b75..76eb987950 100644
--- a/paimon-python/pypaimon/tests/native_iterator_test.py
+++ b/paimon-python/pypaimon/tests/native_iterator_test.py
@@ -107,6 +107,46 @@ def test_iterator_uses_native_read(tmp_path, mode, 
projection, limit):
     assert all(kind == RowKind.INSERT for _, kind in actual)
 
 
[email protected]('method_name', [
+    'to_arrow', 'to_arrow_batch_reader', '_to_managed_arrow_batch_reader',
+])
+def 
test_native_arrow_modes_share_limit_and_materialized_keeps_split_prefix(tmp_path,
 method_name):
+    table, _ = _table(tmp_path)
+    predicate = 
table.new_read_builder().new_predicate_builder().greater_than('v', 10)
+    builder = 
table.new_read_builder().with_projection(['id']).with_filter(predicate)
+    splits = list(reversed(builder.new_scan().plan().splits()))
+    assert len(splits) >= 2
+    python = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    python_read = 
python.new_read_builder().with_projection(['id']).with_filter(predicate).new_read()
+    expected = []
+    for split in splits:
+        expected.extend(python_read.to_arrow([split], 
parallelism=1).column('id').to_pylist())
+    assert len(expected) == 2
+    for limit in [None, 1]:
+        for parallelism in [1, 4]:
+            limited = 
table.new_read_builder().with_projection(['id']).with_filter(predicate)
+            if limit is not None:
+                limited.with_limit(limit)
+            read = limited.new_read()
+            with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python read fallback')):
+                result = getattr(read, method_name)(splits, 
parallelism=parallelism)
+                if method_name != 'to_arrow':
+                    try:
+                        result_table = result.read_all()
+                    finally:
+                        result.close()
+                else:
+                    result_table = result
+            values = result_table.column('id').to_pylist()
+            count = len(expected) if limit is None else limit
+            assert len(values) == count
+            if method_name == 'to_arrow' or parallelism == 1:
+                assert values == expected[:count]
+            else:
+                assert len(set(values)) == len(values)
+                assert set(values).issubset(expected)
+
+
 def test_append_iterator_row_kind_is_insert(tmp_path):
     table, _ = _table(tmp_path)
     table = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
@@ -143,14 +183,19 @@ def _planned_read(tmp_path, limit=None):
     builder = table.new_read_builder().with_projection(['id'])
     if limit is not None:
         builder.with_limit(limit)
-    return builder.new_read(), builder.new_scan().plan().splits()
+    splits = builder.new_scan().plan().splits()
+    # Transport tests mock native_read itself; opaque retained split tokens
+    # keep their lifetime assertions independent of Native split construction.
+    for split in splits:
+        split._native_split = object()
+    return builder.new_read(), splits
 
 
 @pytest.mark.parametrize('consume', [False, True])
 def test_iterator_is_lazy_and_close_stops_native_input(tmp_path, consume):
     read, splits = _planned_read(tmp_path)
     batches = _Batches([_batch([1, 2]), RuntimeError('must not fetch next 
batch')])
-    with patch('pypaimon.read.native_plan._prepare_native_read', 
return_value=lambda _: batches) as prepare, \
+    with patch('pypaimon.read.native_plan.native_read', return_value=batches) 
as prepare, \
             patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python fallback')):
         iterator = read.to_iterator(splits)
         prepare.assert_not_called()
@@ -171,7 +216,7 @@ def 
test_iterator_closes_native_input_on_failure_without_replaying_python(tmp_pa
            pa.record_batch([pa.array(['+I']), pa.array(['wrong type'])], 
names=['rowkind', 'id'])
            if failure == 'schema' else _batch([2], ['invalid']))
     batches = _Batches([_batch([1]), bad])
-    with patch('pypaimon.read.native_plan._prepare_native_read', 
return_value=lambda _: batches), \
+    with patch('pypaimon.read.native_plan.native_read', return_value=batches), 
\
             patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python replay')):
         iterator = read.to_iterator(splits)
         assert next(iterator).get_field(0) == 1
@@ -180,32 +225,23 @@ def 
test_iterator_closes_native_input_on_failure_without_replaying_python(tmp_pa
     assert batches.closed
 
 
-def test_iterator_spends_limit_before_opening_next_split(tmp_path):
+def test_iterator_delegates_ordered_global_limit_in_one_native_read(tmp_path):
     read, splits = _planned_read(tmp_path, limit=3)
     assert len(splits) >= 2
-    prepared = []
-    opened = []
-    readers = []
-
-    def prepare(table, **kwargs):
-        prepared.append(kwargs['limit'])
-
-        def open_split(group):
-            assert len(group) == 1
-            assert not readers or readers[-1].closed
-            opened.append(group[0])
-            reader = _Batches([_batch(list(range(min(2, kwargs['limit']))))])
-            readers.append(reader)
-            return reader
-        return open_split
-
-    with patch('pypaimon.read.native_plan._prepare_native_read', 
side_effect=prepare), \
+    batches = _Batches([_batch([1, 2]), _batch([3])])
+    with patch('pypaimon.read.native_plan.native_read', return_value=batches) 
as native, \
+            patch('pypaimon.read.native_plan.native_split_from_python', 
side_effect=lambda split, **kwargs: split), \
             patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python fallback')):
         result = _rows(read, splits + splits)
-    assert len(result) == 3
-    assert prepared == [3, 1]
-    assert opened == [splits[0]._native_split, splits[1]._native_split]
-    assert all(reader.closed for reader in readers)
+    assert result == [((value,), RowKind.INSERT) for value in [1, 2, 3]]
+    native.assert_called_once()
+    transported = [getattr(split, '_native_split', split) for split in splits 
+ splits]
+    assert native.call_args.args == (read.table, transported)
+    assert native.call_args.kwargs['limit'] == 3
+    assert native.call_args.kwargs['parallelism'] == 1
+    assert native.call_args.kwargs['ordered'] is True
+    assert native.call_args.kwargs['include_row_kind'] is True
+    assert batches.closed
     assert read.limit == 3
 
 
@@ -213,14 +249,14 @@ def 
test_iterator_setup_failure_falls_back_before_native_rows(tmp_path):
     read, splits = _planned_read(tmp_path)
     expected = _rows(read.table.copy({'read.native.enabled': 
'false'}).new_read_builder()
                      .with_projection(['id']).new_read(), splits)
-    with patch('pypaimon.read.native_plan._prepare_native_read', 
side_effect=RuntimeError('setup failed')):
+    with patch('pypaimon.read.native_plan.native_read', 
side_effect=RuntimeError('setup failed')):
         assert _rows(read, splits) == expected
 
 
 def test_iterator_qualifies_every_split_before_starting_native(tmp_path):
     read, splits = _planned_read(tmp_path)
     with patch.object(read, '_native_split_files_supported', 
side_effect=[True, False]), \
-            patch('pypaimon.read.native_plan._prepare_native_read') as prepare:
+            patch('pypaimon.read.native_plan.native_read') as prepare:
         assert len(_rows(read, splits)) == 3
     prepare.assert_not_called()
 
@@ -229,10 +265,10 @@ def 
test_iterator_qualifies_every_split_before_starting_native(tmp_path):
 def test_iterator_preserves_all_row_kinds_and_hides_transport_column(tmp_path, 
limit):
     read, splits = _planned_read(tmp_path, limit)
     kinds = ['+I', '-U', '+U', '-D']
-    batches = _Batches([_batch([1, 2, 3, 4], kinds)])
-    with patch('pypaimon.read.native_plan._prepare_native_read', 
return_value=lambda _: batches) as prepare:
-        rows = _rows(read, splits[:1])
     count = 4 if limit is None else limit
+    batches = _Batches([_batch([1, 2, 3, 4][:count], kinds[:count])])
+    with patch('pypaimon.read.native_plan.native_read', return_value=batches) 
as prepare:
+        rows = _rows(read, splits[:1])
     assert rows == [((i + 1,), RowKind.from_string(kind)) for i, kind in 
enumerate(kinds[:count])]
     assert prepare.call_count == int(limit != 0)
     assert batches.closed == (limit != 0)
@@ -439,7 +475,7 @@ def 
test_iterator_keeps_plan_authorization_across_splits_and_fallback(rest_catal
         if mode == 'wire-fallback':
             splits = [deserialize_split_v1(serialize_split_v1(split), []) for 
split in splits]
             failure = AssertionError('Serialized rules must not mint a native 
grant')
-        context = patch('pypaimon.read.native_plan._prepare_native_read', 
side_effect=failure)
+        context = patch('pypaimon.read.native_plan.native_read', 
side_effect=failure)
     with context:
         assert sorted(_rows(read, splits), key=lambda row: row[0]) == [
             ((2,), RowKind.INSERT), ((3,), RowKind.INSERT)]
diff --git a/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py 
b/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py
index 90a6c1b4f8..57a0688bb9 100644
--- a/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py
+++ b/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py
@@ -25,7 +25,7 @@ import pyarrow.compute as pc
 import pytest
 
 from pypaimon import CatalogFactory, Schema
-from pypaimon.read.table_read import TableRead
+from pypaimon.read.native_plan import native_read as read_native_splits
 from pypaimon.schema.data_types import PyarrowFieldParser
 from pypaimon.table.data_evolution_merge_into import WhenMatched, 
WhenNotMatched, source_col
 from pypaimon.write.table_update import TableUpdate
@@ -172,17 +172,9 @@ def 
test_shard_normal_updates_preserve_parquet_vector_and_blob_sidecars(tmp_path
     update = builder.new_update().with_update_type(['score'])
     update.with_read_projection(['id', 'score'])
 
-    def reversed_groups(self, readers):
-        # A valid completion order for parallel readers must not change which
-        # row receives an update. Read actual files, but finish the last group 
first.
-        for reader in reversed(readers):
-            try:
-                yield from reader
-            finally:
-                reader.close()
-
     with ExitStack() as stack:
-        stack.enter_context(patch.object(TableRead, 
'_native_batches_parallel_streaming', reversed_groups))
+        native_calls = stack.enter_context(patch(
+            'pypaimon.read.native_plan.native_read', wraps=read_native_splits))
         if native:
             
stack.enter_context(patch('pypaimon.write.table_update.ShardTableUpdator.__init__',
                                       side_effect=AssertionError('Python shard 
fallback')))
@@ -195,6 +187,12 @@ def 
test_shard_normal_updates_preserve_parquet_vector_and_blob_sidecars(tmp_path
         finally:
             reader.close()
         messages = shard.prepare_commit()
+        if native_read and not native:
+            # Pure shard writers consume their ranges in split order. Their
+            # Native read delegates every split to one serial Core stream.
+            native_calls.assert_called_once()
+            assert native_calls.call_args.kwargs['parallelism'] == 1
+            assert native_calls.call_args.kwargs['ordered'] is False
     _commit(builder, messages, stream)
     for rows in _rows(table):
         assert rows == [dict(row, score=row['score'] + 10) for row in before]
diff --git a/paimon-python/pypaimon/tests/native_read_test.py 
b/paimon-python/pypaimon/tests/native_read_test.py
index e82e996a81..20966ef9fc 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -200,12 +200,12 @@ def test_floating_sequence_uses_native_read(type_):
         native.assert_called_once()
 
 
-def test_native_read_consumes_retained_rust_splits_and_enforces_limit():
+def test_native_read_consumes_retained_rust_splits_and_transports_limit():
     read = _table_read(limit=2)
     first, second = _Split(), _Split()
     first._native_split = object()
     second._native_split = object()
-    batches = [_id_batch([1, 2, 3])]
+    batches = [_id_batch([1, 2])]
 
     with patch(
             'pypaimon.read.native_plan.native_read',
@@ -220,6 +220,8 @@ def 
test_native_read_consumes_retained_rust_splits_and_enforces_limit():
         limit=2,
         read_type=read.read_type,
         blob_parallelism=1,
+        parallelism=1,
+        ordered=True,
     )
 
 
@@ -339,6 +341,8 @@ def test_native_read_bridges_python_planned_split():
         predicate=None,
         limit=None,
         read_type=read.read_type,
+        parallelism=1,
+        ordered=False,
     )
 
 
@@ -366,95 +370,68 @@ def 
test_native_query_auth_transports_rules_for_core_authorization():
     assert native.call_args.args[1] == [converted]
 
 
-def test_native_read_uses_effective_parallelism_from_table_option():
[email protected]('method_name, ordered', [
+    ('to_arrow', True), ('to_arrow_batch_reader', False),
+    ('_to_managed_arrow_batch_reader', False),
+])
[email protected]('option, runtime, expected', [
+    (2, None, 2), (4, 2, 2), (1, 4, 4), (4, 1, 1), (4, 16, 16),
+])
+def test_native_read_delegates_all_splits_and_parallelism_once(method_name, 
ordered, option, runtime, expected):
     read = _table_read()
-    read._read_parallelism = 2
-    splits = [_Split() for _ in range(4)]
+    read._read_parallelism = option
+    splits = [_Split(file_size=size) for size in [8, 8, 1, 1]]
     for index, split in enumerate(splits):
         split._native_split = index
-
-    barrier = threading.Barrier(2)
-    worker_names = set()
-    worker_names_lock = threading.Lock()
-
-    groups = []
-
-    def read_group(rust_splits):
-        groups.append(rust_splits)
-
-        def batches():
-            with worker_names_lock:
-                worker_names.add(threading.current_thread().name)
-            barrier.wait(timeout=5)
-            yield _id_batch(rust_splits)
-        return batches()
-
-    with patch('pypaimon.read.native_plan._prepare_native_read',
-               return_value=read_group) as prepare:
-        result = read.to_arrow(splits)
-
+    with patch('pypaimon.read.native_plan.native_read', 
return_value=[_id_batch(range(4))]) as native, \
+            patch('pypaimon.read.table_read.ThreadPoolExecutor', 
side_effect=AssertionError('Python executor')):
+        result = getattr(read, method_name)(splits, parallelism=runtime)
+        if method_name != 'to_arrow':
+            result = result.read_all()
     assert result.to_pydict() == {'id': [0, 1, 2, 3]}
-    prepare.assert_called_once()
-    assert sorted(groups) == [[0, 1], [2, 3]]
-    assert len(worker_names) == 2
-
-
-def test_parallel_native_read_prepares_rust_reader_once():
-    read = _table_read()
-    splits = [_Split() for _ in range(4)]
-    for index, split in enumerate(splits):
-        split._native_split = index
-
-    groups = []
-
-    def read_group(rust_splits):
-        groups.append(rust_splits)
-        return [_id_batch(rust_splits)]
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            create=True,
-            return_value=read_group) as prepare, patch(
-                'pypaimon.read.native_plan.native_read',
-                side_effect=AssertionError('rebuilt native reader')):
-        result = read.to_arrow(splits, parallelism=2)
-
-    assert result.to_pydict() == {'id': [0, 1, 2, 3]}
-    prepare.assert_called_once()
-    assert sorted(groups) == [[0, 1], [2, 3]]
+    native.assert_called_once()
+    assert native.call_args.args == (read.table, list(range(4)))
+    assert native.call_args.kwargs['parallelism'] == expected
+    assert native.call_args.kwargs['ordered'] == ordered
 
 
-def test_native_batch_reader_uses_effective_parallelism():
[email protected]('ordered', [False, True])
+def test_native_read_transports_explicit_order_without_opening_groups(ordered):
     read = _table_read()
-    read._read_parallelism = 2
-    splits = [_Split() for _ in range(4)]
+    splits = [_Split(), _Split()]
     for index, split in enumerate(splits):
         split._native_split = index
+    with patch('pypaimon.read.native_plan.native_read', 
return_value=[_id_batch([0, 1])]) as native:
+        batches = list(read._try_native_batches(
+            splits, pa.schema([('id', pa.int32())]), parallelism=2, 
ordered=ordered))
+    assert batches[0].column('id').to_pylist() == [0, 1]
+    native.assert_called_once()
+    assert native.call_args.args[1] == [0, 1]
+    assert native.call_args.kwargs['ordered'] == ordered
+    assert native.call_args.kwargs['parallelism'] == 2
 
-    barrier = threading.Barrier(2)
-    worker_names = set()
-    worker_names_lock = threading.Lock()
-
-    groups = []
 
-    def read_group(rust_splits):
-        groups.append(rust_splits)
[email protected]('ordered', [None, False, True])
+def test_native_read_bridge_transports_current_ordered_api(ordered):
+    from pypaimon.read.native_plan import native_read
 
-        def batches():
-            with worker_names_lock:
-                worker_names.add(threading.current_thread().name)
-            barrier.wait(timeout=5)
-            yield _id_batch(rust_splits)
-        return batches()
+    table = object()
+    splits = [object(), object()]
+    batches = [_id_batch([2]), _id_batch([1])]
+    calls = []
 
-    with patch('pypaimon.read.native_plan._prepare_native_read',
-               return_value=read_group) as prepare:
-        result = read.to_arrow_batch_reader(splits).read_all()
+    def read_splits(values, *, parallelism, ordered=False):
+        calls.append((values, parallelism, ordered))
+        return batches
 
-    assert sorted(result.column('id').to_pylist()) == [0, 1, 2, 3]
-    prepare.assert_called_once()
-    assert sorted(groups) == [[0, 1], [2, 3]]
-    assert len(worker_names) == 2
+    kwargs = {} if ordered is None else {'ordered': ordered}
+    with patch('pypaimon.read.native_plan._prepare_native_read', 
return_value=read_splits) as prepare:
+        result = native_read(table, splits, parallelism=2, limit=2, **kwargs)
+    assert result is batches
+    assert calls == [(splits, 2, False if ordered is None else ordered)]
+    prepare.assert_called_once_with(
+        table, predicate=None, limit=2, projection=None, blob_parallelism=None,
+        read_type=None, include_row_kind=False)
 
 
 def test_native_batch_reader_close_closes_batch_iterator():
@@ -482,433 +459,187 @@ def 
test_native_batch_reader_close_closes_batch_iterator():
             self.closed = True
 
     reader = Reader()
-    with patch.object(
-            read,
-            '_new_arrow_batch_reader',
-            return_value=(reader, batch_iterator)):
-        batch_reader = read.to_arrow_batch_reader(
-            [_Split(), _Split()])
+    with patch.object(read, '_new_arrow_batch_reader', return_value=(reader, 
batch_iterator)):
+        batch_reader = read.to_arrow_batch_reader([_Split(), _Split()])
         assert batch_reader.read_next_batch().column('id').to_pylist() == [1]
         batch_reader.close()
-
     assert reader.closed
     assert iterator_closed.wait(timeout=1)
 
 
-def test_native_batch_reader_caps_blob_parallelism_across_rust_readers():
-    read = _table_read()
-    splits = [_Split() for _ in range(16)]
-    for index, split in enumerate(splits):
-        split._native_split = index
-
-    groups = []
-
-    def read_group(group):
-        groups.append(group)
-        return [_id_batch(group)]
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=read_group) as prepare:
-        result = read.to_arrow_batch_reader(
-            splits, blob_parallelism=16, parallelism=16).read_all()
-
-    assert result.num_rows == 16
-    prepare.assert_called_once()
-    assert prepare.call_args.kwargs['blob_parallelism'] == 4
-    assert len(groups) == 16
-
-
-def test_parallel_native_stream_bounds_prefetch_per_reader():
-    read = _table_read()
-    all_started = threading.Barrier(3)
-    produced = [0, 0, 0]
-    produced_lock = threading.Lock()
-
-    def reader_batches(index):
-        for value in range(3):
-            with produced_lock:
-                produced[index] += 1
-            if value == 0:
-                all_started.wait(timeout=2)
-            yield _id_batch([index * 10 + value])
-
-    batches = read._native_batches_parallel_streaming([
-        reader_batches(0),
-        reader_batches(1),
-        reader_batches(2),
-    ])
-    try:
-        next(batches)
-        with produced_lock:
-            assert sum(produced) <= 4
-            assert all(value <= 2 for value in produced)
-    finally:
-        batches.close()
-
-
-def test_parallel_native_stream_close_does_not_start_another_read():
-    read = _table_read()
-
-    class BlockingReader:
-        def __init__(self):
-            self._first = True
-            self._release = threading.Event()
-            self.second_read_started = threading.Event()
-            self.closed = threading.Event()
-
-        def __iter__(self):
-            return self
-
-        def __next__(self):
-            if self._first:
-                self._first = False
-                return _id_batch([1])
-            self.second_read_started.set()
-            self._release.wait(timeout=5)
-            raise StopIteration
-
-        def close(self):
-            self.closed.set()
-            self._release.set()
-
-    reader = BlockingReader()
-    batches = read._native_batches_parallel_streaming([reader])
-    assert next(batches).column('id').to_pylist() == [1]
-    reader.second_read_started.wait(timeout=1)
-
-    close_finished = threading.Event()
-
-    def close_batches():
-        batches.close()
-        close_finished.set()
-
-    close_thread = threading.Thread(target=close_batches, daemon=True)
-    close_thread.start()
-    closed_without_unblocking = close_finished.wait(timeout=1)
-    try:
-        assert closed_without_unblocking
-    finally:
-        reader.close()
-        close_thread.join(timeout=5)
-
-    assert reader.closed.is_set()
-    assert not reader.second_read_started.is_set()
-
-
-def test_parallel_native_stream_close_interrupts_other_in_flight_reader():
-    read = _table_read()
-    both_reading = threading.Barrier(2)
-
-    class FirstReader:
-        def __init__(self):
-            self.closed = threading.Event()
-
-        def __iter__(self):
-            return self
-
-        def __next__(self):
-            both_reading.wait(timeout=5)
-            return _id_batch([1])
-
-        def close(self):
-            self.closed.set()
-
-    class BlockingReader:
-        def __init__(self):
-            self.closed = threading.Event()
-
-        def __iter__(self):
-            return self
-
-        def __next__(self):
-            both_reading.wait(timeout=5)
-            self.closed.wait(timeout=5)
-            raise StopIteration
-
-        def close(self):
-            self.closed.set()
-
-    first = FirstReader()
-    blocked = BlockingReader()
-    batches = read._native_batches_parallel_streaming([first, blocked])
-    assert next(batches).column('id').to_pylist() == [1]
-
-    close_finished = threading.Event()
-    close_thread = threading.Thread(
-        target=lambda: (batches.close(), close_finished.set()), daemon=True)
-    close_thread.start()
-    closed_in_flight_reader = close_finished.wait(timeout=1)
-    try:
-        assert closed_in_flight_reader
-    finally:
-        blocked.close()
-        close_thread.join(timeout=5)
-
-    assert first.closed.is_set()
-    assert blocked.closed.is_set()
-
-
-def test_native_read_limit_caps_split_reader_fanout():
-    read = _table_read(limit=1)
-    splits = [_Split() for _ in range(16)]
-    for index, split in enumerate(splits):
-        split._native_split = index
-
-    with patch(
-            'pypaimon.read.native_plan.native_read',
-            side_effect=lambda table, group, **kwargs: [
-                _id_batch(group)]) as native:
-        result = read.to_arrow_batch_reader(
-            splits, parallelism=16).read_all()
-
-    assert result.num_rows == 1
-    native.assert_called_once()
-    assert native.call_args.args[1] == list(range(16))
-
-
-def test_native_read_limit_close_reaches_capped_native_reader():
-    read = _table_read(limit=1)
[email protected]('method_name', ['to_arrow_batch_reader', 
'_to_managed_arrow_batch_reader'])
[email protected]('blob_limit', [False, True])
+def test_native_batch_reader_close_reaches_core_input(method_name, blob_limit):
+    read = _blob_table_read(limit=2) if blob_limit else _table_read()
+    read.table.is_primary_key_table = blob_limit
     splits = [_Split(), _Split()]
     for index, split in enumerate(splits):
         split._native_split = index
+        split.merged_row_count = Mock(return_value=None)
+    expected = b'payload' if blob_limit else 1
+    batch = (pa.record_batch([pa.array([expected], type=pa.large_binary())], 
names=['payload'])
+             if blob_limit else _id_batch([expected]))
 
     class CloseTrackingReader:
-        def __init__(self):
-            self._batch = _id_batch([0, 1])
-            self.closed = False
+        closed = False
 
         def __iter__(self):
             return self
 
         def __next__(self):
-            if self._batch is None:
-                raise StopIteration
-            batch, self._batch = self._batch, None
             return batch
 
         def close(self):
             self.closed = True
 
-    native_reader = CloseTrackingReader()
-    with patch(
-            'pypaimon.read.native_plan.native_read',
-            return_value=native_reader) as native:
-        batch_reader = read.to_arrow_batch_reader(
-            splits, parallelism=2)
-        assert batch_reader.read_next_batch().column('id').to_pylist() == [0]
-        assert not native_reader.closed
-        batch_reader.close()
-
-    native.assert_called_once()
-    assert native_reader.closed
+    core_reader = CloseTrackingReader()
+    with patch('pypaimon.read.native_plan.native_read', 
return_value=core_reader):
+        reader = getattr(read, method_name)(splits, parallelism=4)
+        assert reader.read_next_batch().column(0).to_pylist() == [expected]
+        assert not core_reader.closed
+        reader.close()
+        reader.close()
+    assert core_reader.closed
 
 
[email protected]('managed_reader', [False, True])
-def 
test_pruning_pk_blob_limit_close_reaches_serial_native_reader(managed_reader):
-    read = _blob_table_read(limit=2)
-    read.table.is_primary_key_table = True
-    splits = [_Split(), _Split()]
[email protected]('method_name', ['to_arrow_batch_reader', 
'_to_managed_arrow_batch_reader'])
[email protected]('split_count, parallelism', [(1, 1), (3, 1)])
+def 
test_serial_native_batch_reader_close_releases_suspended_core_stream(method_name,
 split_count, parallelism):
+    read = _table_read()
+    splits = [_Split() for _ in range(split_count)]
     for index, split in enumerate(splits):
         split._native_split = index
-        split.merged_row_count = Mock(return_value=None)
 
-    class CloseTrackingReader:
-        closed = False
+    class CoreReader:
+        def __init__(self):
+            self.reads = 0
+            self.closes = 0
 
         def __iter__(self):
             return self
 
         def __next__(self):
-            return pa.record_batch([pa.array([b'payload'], 
type=pa.large_binary())], names=['payload'])
+            self.reads += 1
+            return _id_batch([self.reads])
 
         def close(self):
-            self.closed = True
+            self.closes += 1
 
-    native_reader = CloseTrackingReader()
-    method = read._to_managed_arrow_batch_reader if managed_reader else 
read.to_arrow_batch_reader
-    with patch('pypaimon.read.native_plan.native_read', 
return_value=native_reader):
-        reader = method(splits, parallelism=4)
-        assert reader.read_next_batch().column('payload').to_pylist() == 
[b'payload']
-        assert not native_reader.closed
+    core_reader = CoreReader()
+    with patch('pypaimon.read.native_plan.native_read', 
return_value=core_reader):
+        reader = getattr(read, method_name)(splits, parallelism=parallelism)
+        assert reader.read_next_batch().column('id').to_pylist() == [1]
+        assert core_reader.closes == 0
+        reader.close()
         reader.close()
-    assert native_reader.closed
+    assert core_reader.closes == 1
+    assert core_reader.reads == 1
 
 
-def test_filtered_native_read_limit_keeps_split_parallelism():
-    read = _table_read(limit=1)
[email protected]('method_name', ['to_arrow', 'to_arrow_batch_reader'])
[email protected]('limit', [0, 1, 3, None])
+def test_native_read_forwards_one_global_limit(method_name, limit):
+    read = _table_read(limit=limit)
     read.predicate = Mock()
-
-    assert read._effective_parallelism(16, 16) == 16
-
-
-def test_native_split_groups_balance_contiguous_file_bytes():
-    groups = TableRead._native_split_groups(
-        list(range(4)), 2, weights=[8, 8, 1, 1])
-
-    assert groups == [[0], [1, 2, 3]]
-    assert [sum([8, 8, 1, 1][value] for value in group)
-            for group in groups] == [8, 10]
-
-
-def test_native_split_groups_keep_equal_splits_evenly_distributed():
-    assert TableRead._native_split_groups(
-        list(range(5)), 2, weights=[1] * 5) == [[0, 1, 2], [3, 4]]
-
-
-def test_native_read_groups_splits_by_file_bytes():
-    read = _table_read()
-    splits = [_Split(file_size=size) for size in [8, 8, 1, 1]]
-    for index, split in enumerate(splits):
-        split._native_split = index
-
-    groups = []
-
-    def read_group(group):
-        groups.append(group)
-        return [_id_batch(group)]
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=read_group):
-        result = read.to_arrow(splits, parallelism=2)
-
-    assert result.num_rows == 4
-    assert sorted(groups) == [[0], [1, 2, 3]]
-
-
-def test_native_read_runtime_parallelism_overrides_table_option():
-    read = _table_read()
-    read._read_parallelism = 4
+    read._read_parallelism = 2
     splits = [_Split() for _ in range(4)]
     for index, split in enumerate(splits):
         split._native_split = index
-
-    groups = []
-
-    def read_group(group):
-        groups.append(group)
-        return [_id_batch(group)]
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=read_group) as prepare:
-        result = read.to_arrow(splits, parallelism=2)
-
-    assert result.num_rows == 4
-    prepare.assert_called_once()
-    assert len(groups) == 2
+    # Core owns quota consumption and returns the already limited batches.
+    values = [0, 1, 2] if limit is None else [0, 1, 2][:limit]
+    with patch('pypaimon.read.native_plan.native_read',
+               return_value=[_id_batch([]), _id_batch(values)]) as native:
+        result = getattr(read, method_name)(splits)
+        if method_name != 'to_arrow':
+            result = result.read_all()
+    assert result.to_pydict() == {'id': values}
+    native.assert_called_once()
+    assert native.call_args.args[1] == list(range(4))
+    assert native.call_args.kwargs['limit'] == limit
+    assert native.call_args.kwargs['predicate'] is read.predicate
+    assert native.call_args.kwargs['parallelism'] == 2
 
 
-def test_native_read_caps_blob_parallelism_across_split_readers():
[email protected]('method_name', ['to_arrow', 'to_arrow_batch_reader'])
+def 
test_native_read_transports_blob_parallelism_for_one_core_read(method_name):
     read = _table_read()
     splits = [_Split() for _ in range(16)]
     for index, split in enumerate(splits):
         split._native_split = index
-
-    groups = []
-
-    def read_group(group):
-        groups.append(group)
-        return [_id_batch(group)]
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=read_group) as prepare:
-        result = read.to_arrow(
-            splits, parallelism=16, blob_parallelism=16)
-
+    with patch('pypaimon.read.native_plan.native_read', 
return_value=[_id_batch(range(16))]) as native:
+        result = getattr(read, method_name)(splits, parallelism=16, 
blob_parallelism=16)
+        if method_name != 'to_arrow':
+            result = result.read_all()
     assert result.num_rows == 16
-    prepare.assert_called_once()
-    assert prepare.call_args.kwargs['blob_parallelism'] == 4
-    assert len(groups) == 16
-
-
-def test_parallel_native_read_shares_limit_across_readers():
-    read = _table_read(limit=3)
-    read._read_parallelism = 2
-    splits = [_Split() for _ in range(4)]
-    for index, split in enumerate(splits):
-        split._native_split = index
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=lambda group: [_id_batch(group)]):
-        result = read.to_arrow(splits)
-
-    assert result.num_rows == 3
+    native.assert_called_once()
+    assert native.call_args.args[1] == list(range(16))
+    assert native.call_args.kwargs['parallelism'] == 16
+    assert native.call_args.kwargs['blob_parallelism'] == 16
 
 
-def test_parallel_native_batch_reader_shares_limit_across_readers():
-    read = _table_read(limit=3)
-    read._read_parallelism = 2
-    splits = [_Split() for _ in range(4)]
+def test_native_batch_reader_setup_fallback_preserves_python_blob_worker_cap():
+    read = _table_read()
+    splits = [_Split() for _ in range(16)]
     for index, split in enumerate(splits):
         split._native_split = index
-
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=lambda group: [_id_batch(group)]):
-        result = read.to_arrow_batch_reader(splits).read_all()
-
-    assert result.num_rows == 3
+    schema = pa.schema([('id', pa.int32())])
+    with patch('pypaimon.read.native_plan.native_read', 
side_effect=RuntimeError('setup failed')) as native, \
+            patch.object(read, '_arrow_batch_generator', 
return_value=iter([_id_batch([1])])) as python_read:
+        result = read.to_arrow_batch_reader(splits, parallelism=16, 
blob_parallelism=16).read_all()
+    assert result.to_pydict() == {'id': [1]}
+    assert native.call_args.kwargs['parallelism'] == 16
+    assert native.call_args.kwargs['blob_parallelism'] == 16
+    python_read.assert_called_once_with(splits, schema, 4)
 
 
[email protected]('streaming', [False, True])
-def test_parallel_native_reader_setup_failure_falls_back(streaming):
[email protected]('ordered', [False, True])
+def test_native_reader_setup_failure_falls_back_before_batches(ordered):
     read = _table_read()
-    splits = [_Split() for _ in range(2)]
+    splits = [_Split(), _Split()]
     for split in splits:
         split._native_split = object()
-
-    with patch('pypaimon.read.native_plan._prepare_native_read',
-               side_effect=RuntimeError('setup failed')):
+    with patch('pypaimon.read.native_plan.native_read', 
side_effect=RuntimeError('setup failed')) as native:
         assert read._try_native_batches(
-            splits,
-            pa.schema([('id', pa.int32())]),
-            parallelism=2,
-            streaming=streaming,
-        ) is None
+            splits, pa.schema([('id', pa.int32())]), parallelism=2, 
ordered=ordered) is None
+    native.assert_called_once()
 
 
-def test_parallel_native_stream_setup_failure_closes_started_readers():
+def test_native_reader_qualifies_all_splits_before_starting_core():
     read = _table_read()
-    splits = [_Split() for _ in range(2)]
+    splits = [_Split(), _Split('unsupported.vortex')]
     for split in splits:
         split._native_split = object()
-    started = Mock()
-
-    read_splits = Mock(side_effect=[started, RuntimeError('setup failed')])
-    with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=read_splits):
+    with patch('pypaimon.read.native_plan.native_read') as native:
         assert read._try_native_batches(
-            splits,
-            pa.schema([('id', pa.int32())]),
-            parallelism=2,
-            streaming=True,
-        ) is None
-
-    started.close.assert_called_once_with()
+            splits, pa.schema([('id', pa.int32())]), parallelism=2) is None
+    native.assert_not_called()
 
 
-def test_parallel_native_stream_error_propagates():
[email protected]('method_name', ['to_arrow', 'to_arrow_batch_reader'])
+def 
test_native_stream_error_closes_input_without_replaying_python(method_name):
     read = _table_read()
-    splits = [_Split() for _ in range(2)]
+    read._read_parallelism = 2
+    splits = [_Split(), _Split()]
     for split in splits:
         split._native_split = object()
+    closed = []
 
-    def broken_stream(group):
-        def batches():
+    def batches():
+        try:
+            yield _id_batch([1])
             raise RuntimeError('stream failed')
-            yield
-        return batches()
-
-    with patch('pypaimon.read.native_plan._prepare_native_read',
-               return_value=broken_stream), \
-            pytest.raises(RuntimeError, match='stream failed'):
-        read._try_native_batches(
-            splits, pa.schema([('id', pa.int32())]), parallelism=2)
+        finally:
+            closed.append(True)
+
+    with patch('pypaimon.read.native_plan.native_read', 
return_value=batches()) as native, \
+            patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python replay')):
+        with pytest.raises(RuntimeError, match='stream failed'):
+            result = getattr(read, method_name)(splits)
+            if method_name != 'to_arrow':
+                assert result.read_next_batch().column('id').to_pylist() == [1]
+                result.read_next_batch()
+    assert closed == [True]
+    native.assert_called_once()
 
 
 def test_native_read_failure_falls_back():
@@ -1014,9 +745,9 @@ def 
test_native_read_preserves_parallel_large_binary_blob_schema():
         split._native_split = index
 
     with patch(
-            'pypaimon.read.native_plan._prepare_native_read',
-            return_value=lambda group: [pa.record_batch(
-                [pa.array([bytes(group)], type=pa.large_binary())],
+            'pypaimon.read.native_plan.native_read',
+            return_value=[pa.record_batch(
+                [pa.array([b'payload'], type=pa.large_binary())],
                 names=['payload'])]):
         result = read.to_arrow(splits, parallelism=2)
 
@@ -1040,7 +771,6 @@ def 
test_native_primary_key_blob_limit_passes_quota_to_rust(descriptor):
             blob_parallelism=1)) == [batch]
     native.assert_called_once()
     assert native.call_args.kwargs['limit'] == 1
-    assert not read._should_run_parallel([split, split], 2)
 
 
 @pytest.mark.parametrize('descriptor', [False, True])

Reply via email to