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 179277d014 [python][ray] Fix read-task limits and close batch readers 
(#10698)
179277d014 is described below

commit 179277d014821a1d303c5025044e76a206b260e2
Author: Zaynt <[email protected]>
AuthorDate: Mon Oct 26 12:15:10 2026 +0800

    [python][ray] Fix read-task limits and close batch readers (#10698)
---
 .../pypaimon/read/datasource/ray_datasource.py     |   4 +
 .../pypaimon/tests/ray_datasource_test.py          | 132 +++++++++++++++++++++
 .../pypaimon/tests/ray_integration_test.py         |  26 ++--
 3 files changed, 144 insertions(+), 18 deletions(-)

diff --git a/paimon-python/pypaimon/read/datasource/ray_datasource.py 
b/paimon-python/pypaimon/read/datasource/ray_datasource.py
index a8cb978e03..b37292f75c 100644
--- a/paimon-python/pypaimon/read/datasource/ray_datasource.py
+++ b/paimon-python/pypaimon/read/datasource/ray_datasource.py
@@ -128,6 +128,8 @@ class RayDatasource(Datasource):
         output_projection = self._split_provider.output_projection()
         splits = self._split_provider.splits()
         limit = self._split_provider.limit()
+        if per_task_row_limit is not None:
+            limit = per_task_row_limit if limit is None else min(limit, 
per_task_row_limit)
         include_row_kind = self._split_provider.include_row_kind()
         if not splits:
             return []
@@ -239,6 +241,8 @@ class RayDatasource(Datasource):
                 num_rows = None  # Can't estimate with predicate filtering
             else:
                 num_rows = total_rows
+            if num_rows is not None and limit is not None:
+                num_rows = min(num_rows, limit)
             size_bytes = total_size if total_size > 0 else None
 
             metadata_kwargs = {
diff --git a/paimon-python/pypaimon/tests/ray_datasource_test.py 
b/paimon-python/pypaimon/tests/ray_datasource_test.py
new file mode 100644
index 0000000000..c127fe271a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_datasource_test.py
@@ -0,0 +1,132 @@
+# 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.
+
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+import ray.cloudpickle
+from ray.data import DataContext
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.datasource.ray_datasource import RayDatasource
+from pypaimon.read.datasource.split_provider import CatalogSplitProvider
+from pypaimon.read.table_read import TableRead
+
+
[email protected]
+def catalog_options(tmp_path):
+    options = {"warehouse": str(tmp_path)}
+    catalog = CatalogFactory.create(options)
+    catalog.create_database("default", True)
+    data = pa.table({"id": list(range(5))})
+    catalog.create_table("default.data", 
Schema.from_pyarrow_schema(data.schema), False)
+    table = catalog.get_table("default.data")
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    commit = builder.new_commit()
+    try:
+        writer.write_arrow(data)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return options
+
+
[email protected]("limit,task_limit,expected", [
+    (None, None, 5), (2, None, 2), (None, 2, 2),
+    (4, 2, 2), (2, 4, 2), (0, None, 0), (None, 0, 0),
+    (10, None, 5),
+])
+def test_task_metadata_matches_limited_rows(catalog_options, limit, 
task_limit, expected):
+    source = RayDatasource(CatalogSplitProvider(
+        "default.data", catalog_options, limit=limit))
+    tasks = source.get_read_tasks(
+        1, per_task_row_limit=task_limit, 
data_context=DataContext.get_current())
+    # The native planner can return no splits for a zero scan limit.
+    rows = 0
+    for task in tasks:
+        task = ray.cloudpickle.loads(ray.cloudpickle.dumps(task))
+        task_rows = sum(block.num_rows for block in task())
+        assert task.metadata.num_rows == task_rows
+        rows += task_rows
+
+    assert rows == expected
+
+
+def test_filter_keeps_unknown_row_count_with_limit(catalog_options):
+    table = CatalogFactory.create(catalog_options).get_table("default.data")
+    predicate = 
table.new_read_builder().new_predicate_builder().greater_than("id", 1)
+    source = RayDatasource(CatalogSplitProvider(
+        "default.data", catalog_options, predicate=predicate, limit=2))
+    task = source.get_read_tasks(1, per_task_row_limit=1)[0]
+
+    assert task.metadata.num_rows is None
+    assert sum(block.num_rows for block in task()) == 1
+
+
+def test_ray_limit_is_applied_inside_worker_reader(catalog_options):
+    source = RayDatasource(CatalogSplitProvider("default.data", 
catalog_options, limit=4))
+    task = source.get_read_tasks(1, per_task_row_limit=2)[0]
+    original = TableRead.to_arrow_batch_reader
+    limits = []
+
+    def track_reader(read, splits):
+        limits.append(read.limit)
+        return original(read, splits)
+
+    with patch.object(TableRead, "to_arrow_batch_reader", track_reader):
+        assert sum(block.num_rows for block in task()) == 2
+    assert limits == [2]
+
+
[email protected]("finish", ["exhausted", "cancelled", "failed", 
"empty"])
[email protected]("fail_close", [False, True])
+def test_worker_closes_batch_reader(catalog_options, finish, fail_close):
+    source = RayDatasource(CatalogSplitProvider("default.data", 
catalog_options))
+    task = source.get_read_tasks(1)[0]
+    batch = pa.record_batch([pa.array([0, 1])], names=["id"])
+    read_error = RuntimeError("read failed")
+    close_error = OSError("close failed") if fail_close else None
+    batches = [batch, StopIteration()]
+    if finish == "failed":
+        batches = [batch, read_error]
+    elif finish == "empty":
+        batches = [StopIteration()]
+    reader = Mock()
+    reader.read_next_batch.side_effect = batches
+    reader.close.side_effect = close_error
+
+    with patch.object(TableRead, "to_arrow_batch_reader", return_value=reader):
+        blocks = task.read_fn()
+        if finish == "cancelled":
+            assert next(blocks).num_rows == 2
+            consume = blocks.close
+        else:
+            consume = lambda: list(blocks)
+
+        expected_error = read_error if finish == "failed" else close_error
+        if expected_error is not None:
+            with pytest.raises(type(expected_error)) as exc_info:
+                consume()
+            assert exc_info.value is expected_error
+        else:
+            result = consume()
+            if finish != "cancelled":
+                assert sum(block.num_rows for block in result) == (0 if finish 
== "empty" else 2)
+        reader.close.assert_called_once_with()
diff --git a/paimon-python/pypaimon/tests/ray_integration_test.py 
b/paimon-python/pypaimon/tests/ray_integration_test.py
index d95d3855a9..0096dd4c95 100644
--- a/paimon-python/pypaimon/tests/ray_integration_test.py
+++ b/paimon-python/pypaimon/tests/ray_integration_test.py
@@ -358,18 +358,7 @@ class RayIntegrationTest(unittest.TestCase):
         self.assertEqual(set(df['category'].tolist()), {'A'})
 
     def test_read_paimon_with_limit(self):
-        """``read_paimon(limit=N)`` propagates the limit into the scan plan.
-
-        Writes 10 rows across two partitions (5 + 5) so the scan produces two
-        raw-convertible splits. ``limit=3`` causes ``FileScanner`` to drop the
-        second split once the first already covers the limit, so the Ray
-        Dataset contains strictly fewer than the full 10 rows.
-
-        We assert ``< 10`` (not ``== N``) because Paimon's scan-time limit is
-        a per-split cap — whole-split granularity at this layer — not a
-        row-exact hard limit. Row-exact short-circuiting in the reader is a
-        separate follow-up.
-        """
+        """Limits remain exact before and after materialization across 
tasks."""
         from pypaimon.ray import read_paimon
 
         pa_schema = pa.schema([
@@ -391,12 +380,13 @@ class RayIntegrationTest(unittest.TestCase):
         ds_full = read_paimon(identifier, self.catalog_options)
         self.assertEqual(ds_full.count(), 10)
 
-        # With limit=3, the scan plan drops the second partition's split
-        # once the first split's row count already covers the limit.
-        ds = read_paimon(identifier, self.catalog_options, limit=3)
-        limited_count = ds.count()
-        self.assertGreater(limited_count, 0)
-        self.assertLess(limited_count, 10)
+        for limit in (0, 3, 7, 20):
+            with self.subTest(limit=limit):
+                ds = read_paimon(
+                    identifier, self.catalog_options, limit=limit,
+                    override_num_blocks=2)
+                self.assertEqual(ds.count(), min(limit, 10))
+                self.assertEqual(ds.materialize().count(), min(limit, 10))
 
     def test_read_paimon_empty_table(self):
         """read_paimon() on an empty table preserves the table schema."""

Reply via email to