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 79bdc93798 [python] Use native planning for LeRobot frame lookups 
(#10701)
79bdc93798 is described below

commit 79bdc9379885c2cba19f2284c94a26fac37b876c
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Oct 21 14:15:28 2026 +0800

    [python] Use native planning for LeRobot frame lookups (#10701)
---
 docs/docs/pypaimon/lerobot.md                      |   5 +
 .../pypaimon/multimodal/lerobot/reader.py          |  25 +-
 .../pypaimon/tests/lerobot_native_reader_test.py   | 258 +++++++++++++++++++++
 3 files changed, 287 insertions(+), 1 deletion(-)

diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index 53c32451a5..45120edb2f 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -248,6 +248,11 @@ on `index`; payloads remain lazy. Indexed videos prefetch 
metadata and target GO
 uncached PyAV reads on demand. Unindexed videos use the TorchCodec/PyAV scan 
path. Set
 `video_backend` to force either decoder.
 
+With `pypaimon-rust` installed, LeRobot defaults to Rust frame lookup and 
table reads
+unless native read/planning options are explicitly configured. Set
+`table = table.copy({"read.native.enabled": "false"})` to use Python.
+Reads stay on the selected snapshot; unavailable or unsupported native paths 
use Python.
+
 Set `table = table.copy({"read.video.max-open-decoders": "8"})` before
 constructing the dataset to limit cached decoders per camera per worker 
(default: 16).
 Each open decoder uses memory and a file handle, so usage grows with cameras 
and workers.
diff --git a/paimon-python/pypaimon/multimodal/lerobot/reader.py 
b/paimon-python/pypaimon/multimodal/lerobot/reader.py
index a63945fd86..fe53da6e46 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/reader.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/reader.py
@@ -30,15 +30,38 @@ class _PaimonTableFrameReader:
     def __init__(self, frames_table, *, columns):
         self._table, self.snapshot_id, splits = _indexed_read_table(
             frames_table, columns)
+        options = self._table.options.options
+        if not any(options.contains(option) for option in (
+                CoreOptions.READ_NATIVE_ENABLED, 
CoreOptions.SCAN_NATIVE_PLAN_ENABLED)):
+            from pypaimon.execution.native.runtime import 
native_runtime_available
+            if native_runtime_available():
+                self._table = self._table.copy_without_time_travel({
+                    CoreOptions.READ_NATIVE_ENABLED.key(): "true"})
         snapshot = self._table.snapshot_manager().get_snapshot_by_id(
             self.snapshot_id)
+        self._table = self._table._copy_with_snapshot(snapshot)
         self.num_rows = snapshot.next_row_id
         self.file_io = self._table.file_io
         self._locator = _FrameLocator(self._table, snapshot, splits)
 
     def read_indices(self, indices, columns):
-        splits, needs_filter = self._locator.locate(indices)
         builder = self._table.new_read_builder().with_projection(columns)
+        if (self._table.options.native_plan_enabled()
+                or self._table.options.native_read_enabled()):
+            from pypaimon.execution.native.runtime import 
native_runtime_available
+            if native_runtime_available():
+                # Keep lookup predicates in the native plan and residual read.
+                builder.with_filter(_index_predicate(self._table, indices))
+                scan = builder.new_scan()
+                if scan._native_plan_supported():
+                    # Authorized scans must retain the complete planning path.
+                    if scan._query_auth_fn is not None or 
self._table.options.query_auth_enabled:
+                        return 
builder.new_read().to_arrow(scan.plan().splits())
+                    plan = scan._try_native_plan()
+                    if plan is not None:
+                        return builder.new_read().to_arrow(plan.splits())
+                builder = 
self._table.new_read_builder().with_projection(columns)
+        splits, needs_filter = self._locator.locate(indices)
         if needs_filter:
             builder = builder.with_filter(
                 _index_predicate(self._table, indices))
diff --git a/paimon-python/pypaimon/tests/lerobot_native_reader_test.py 
b/paimon-python/pypaimon/tests/lerobot_native_reader_test.py
new file mode 100644
index 0000000000..ef413f2a64
--- /dev/null
+++ b/paimon-python/pypaimon/tests/lerobot_native_reader_test.py
@@ -0,0 +1,258 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pickle
+import multiprocessing
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+import pypaimon.multimodal as pm
+from pypaimon.multimodal.lerobot.reader import _PaimonTableFrameReader
+from pypaimon.execution.native.runtime import native_runtime_available
+from pypaimon.execution.native.read.plan import native_plan
+
+pytest.importorskip("torch")
+
+
+def _read_in_worker(reader, output):
+    try:
+        assert reader._table.options.native_read_enabled()
+        with patch.object(reader._locator, "locate", 
side_effect=AssertionError("Python frame lookup")):
+            output.put(reader.read_indices([10, 40], 
["index"])["index"].to_pylist())
+    finally:
+        reader.close()
+
+
[email protected]
+def frames(tmp_path):
+    conn = pm.connect(options={"warehouse": str(tmp_path)})
+    table = conn.create_table("frames", data=pa.table({
+        "index": [10, 20, 30], "value": [1, 2, 3],
+    }), options={
+        "row-tracking.enabled": "true",
+        "data-evolution.enabled": "true",
+        "global-index.enabled": "true",
+        "bucket": "-1",
+    })
+    table.raw_table.create_global_index("index", index_type="btree")
+    return table
+
+
+def reader(table, **options):
+    return _PaimonTableFrameReader(
+        table.raw_table.copy(options), columns=["index", "value"])
+
+
[email protected](not native_runtime_available(), reason="native reader 
required")
[email protected]("option", [None, "read.native.enabled", 
"scan.native-plan.enabled"])
+def test_native_frame_lookup_keeps_residual_and_snapshot(frames, option):
+    # The index covers the original rows, not this append.
+    frames.add(pa.table({"index": [40, 50], "value": [4, 5]}))
+    current = reader(frames, **({option: "true"} if option else {}))
+    if option is None:
+        assert current._table.options.native_read_enabled()
+    frames.add(pa.table({"index": [60], "value": [6]}))
+    native_plans = []
+
+    def checked_plan(*args, **kwargs):
+        plan = native_plan(*args, **kwargs)
+        native_plans.append(plan)
+        return plan
+
+    try:
+        with patch.object(current._locator, "locate",
+                          side_effect=AssertionError("Python frame lookup")), \
+                patch("pypaimon.execution.native.read.plan.native_plan", 
side_effect=checked_plan):
+            for indices, expected in [([40, 20, 40], [20, 40]),
+                                      ([60, 99], []), ([], [])]:
+                result = current.read_indices(indices, ["value", "index"])
+                assert result.column_names == ["value", "index"]
+                assert sorted(result["index"].to_pylist()) == expected
+            assert len(native_plans) == 3
+            assert all(plan.snapshot_id == current.snapshot_id for plan in 
native_plans)
+    finally:
+        current.close()
+
+
[email protected](not native_runtime_available(), reason="native reader 
required")
+def test_native_frame_lookup_preserves_tag_after_pickle(frames):
+    frames.raw_table.create_tag("training")
+    current = reader(frames, **{
+        "scan.tag-name": "training",
+    })
+    restored = pickle.loads(pickle.dumps(current))
+    frames.add(pa.table({"index": [40], "value": [4]}))
+    frames.raw_table.replace_tag("training")
+    try:
+        assert current.snapshot_id == restored.snapshot_id
+        assert restored.read_indices([10, 40], ["index"])["index"].to_pylist() 
== [10]
+    finally:
+        current.close()
+        restored.close()
+
+
[email protected](not native_runtime_available(), reason="native reader 
required")
+def test_native_frame_lookup_in_spawn_worker(frames):
+    current = reader(frames)
+    frames.add(pa.table({"index": [40], "value": [4]}))
+    context = multiprocessing.get_context("spawn")
+    output = context.Queue()
+    process = context.Process(target=_read_in_worker, args=(current, output))
+    try:
+        process.start()
+        assert output.get(timeout=30) == [10]
+        process.join(timeout=30)
+        assert process.exitcode == 0
+    finally:
+        if process.is_alive():
+            process.terminate()
+            process.join()
+        output.close()
+        current.close()
+
+
+def test_missing_native_runtime_keeps_cached_python_lookup(frames):
+    current = reader(frames, **{"read.native.enabled": "true"})
+    try:
+        with 
patch("pypaimon.execution.native.runtime.native_runtime_available", 
return_value=False), \
+                patch.object(current._locator, "locate", 
wraps=current._locator.locate) as locate:
+            assert current.read_indices([30], ["value"])["value"].to_pylist() 
== [3]
+            locate.assert_called_once_with([30])
+    finally:
+        current.close()
+
+
[email protected](not native_runtime_available(), reason="native reader 
required")
+def test_native_plan_failure_reuses_scanner_and_keeps_predicate(frames):
+    from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
+    frames.add(pa.table({"index": [40, 50], "value": [4, 5]}))
+    current = reader(frames, **{
+        "scan.native-plan.enabled": "true", "read.native.enabled": "false",
+    })
+    try:
+        with patch("pypaimon.execution.native.read.plan.native_plan",
+                   side_effect=RuntimeError("unavailable")), \
+                patch.object(current._locator, "locate", 
wraps=current._locator.locate) as locate, \
+                patch.object(DataEvolutionGlobalIndexScanner, "create",
+                             wraps=DataEvolutionGlobalIndexScanner.create) as 
create:
+            for indices, expected in [([20], [2]), ([40], [4]), ([10, 99], 
[1])]:
+                assert current.read_indices(indices, 
["value"])["value"].to_pylist() == expected
+            assert locate.call_count == 3
+            assert create.call_count == 1
+            scanner = current._locator._scanner
+            with patch.object(scanner, "close", wraps=scanner.close) as close:
+                current.close()
+                close.assert_called_once()
+    finally:
+        current.close()
+
+
[email protected]_plan
[email protected]_read
[email protected]("options", [
+    {"read.native.enabled": "false"},
+    {"scan.native-plan.enabled": "false"},
+])
+def test_explicit_python_lookup_is_preserved(frames, options):
+    current = reader(frames, **options)
+    try:
+        assert not current._table.options.native_read_enabled()
+        with patch.object(current._locator, "locate", 
wraps=current._locator.locate) as locate:
+            assert current.read_indices([20], ["value"])["value"].to_pylist() 
== [2]
+            locate.assert_called_once_with([20])
+    finally:
+        current.close()
+
+
[email protected]_plan
[email protected]_read
[email protected]("available", [False, True])
+def test_auto_native_uses_private_table_copy(frames, available):
+    original = dict(frames.raw_table.options.options.to_map())
+    with patch("pypaimon.execution.native.runtime.native_runtime_available", 
return_value=available):
+        current = reader(frames)
+    try:
+        assert current._table.options.native_read_enabled() == available
+        assert frames.raw_table.options.options.to_map() == original
+        if not available:
+            with patch.object(current._locator, "locate", 
wraps=current._locator.locate) as locate:
+                assert current.read_indices([30], 
["value"])["value"].to_pylist() == [3]
+                locate.assert_called_once_with([30])
+    finally:
+        current.close()
+
+
+def test_unsupported_native_plan_reuses_python_index_scanner(frames):
+    from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
+    frames.add(pa.table({"index": [40], "value": [4]}))
+    with patch("pypaimon.execution.native.runtime.native_runtime_available", 
return_value=True):
+        current = reader(frames)
+    try:
+        assert current._table.options.native_read_enabled()
+        with 
patch("pypaimon.execution.native.runtime.native_runtime_available", 
return_value=True), \
+                
patch("pypaimon.execution.native.read.plan.NativeTableScanMixin._native_plan_supported",
+                      return_value=False), \
+                patch("pypaimon.execution.native.read.plan.native_plan",
+                      side_effect=AssertionError("unsupported native plan")), \
+                patch.object(DataEvolutionGlobalIndexScanner, "create",
+                             wraps=DataEvolutionGlobalIndexScanner.create) as 
create:
+            for indices, expected in [([20], [2]), ([40], [4]), ([10, 99], 
[1])]:
+                result = current.read_indices(indices, ["value"])
+                assert result["value"].to_pylist() == expected
+            assert create.call_count == 1
+            scanner = current._locator._scanner
+            with patch.object(scanner, "close", wraps=scanner.close) as close:
+                current.close()
+                close.assert_called_once()
+    finally:
+        current.close()
+
+
[email protected](not native_runtime_available(), reason="native reader 
required")
[email protected]("denied", [False, True])
+def test_native_lookup_preserves_query_authorization(frames, denied):
+    from pypaimon.read.table_scan import TableScan
+    current = reader(frames)
+    calls = []
+
+    def authorize(*args, **kwargs):
+        calls.append(True)
+        if denied:
+            raise PermissionError("denied")
+        return None
+
+    original_init = TableScan.__init__
+
+    def init_with_auth(scan, *args, **kwargs):
+        original_init(scan, *args, **kwargs)
+        scan._query_auth_fn = authorize
+
+    try:
+        with patch.object(TableScan, "__init__", init_with_auth), \
+                patch.object(current._locator, "locate", 
side_effect=AssertionError("authorization bypass")), \
+                patch("pypaimon.execution.native.read.plan.native_plan",
+                      side_effect=RuntimeError("unavailable")):
+            if denied:
+                with pytest.raises(PermissionError, match="denied"):
+                    current.read_indices([20], ["value"])
+            else:
+                assert current.read_indices([20], 
["value"])["value"].to_pylist() == [2]
+            assert calls
+    finally:
+        current.close()

Reply via email to