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 9c7deebbde [python] Add distributed RoboMIND action backfill (#9496)
9c7deebbde is described below

commit 9c7deebbdeae9fde1945977163b6989e73172e77
Author: Yann Byron <[email protected]>
AuthorDate: Tue Sep 1 10:42:03 2026 +0800

    [python] Add distributed RoboMIND action backfill (#9496)
---
 docs/docs/pypaimon/robomind-agilex.md              |  28 ++--
 paimon-python/pypaimon/sample/robomind_agilex.py   | 183 +++++++++++++++++----
 .../tests/robomind_agilex_pipeline_test.py         | 124 +++++++++++++-
 3 files changed, 286 insertions(+), 49 deletions(-)

diff --git a/docs/docs/pypaimon/robomind-agilex.md 
b/docs/docs/pypaimon/robomind-agilex.md
index e8aa3308d6..ee8aa65a10 100644
--- a/docs/docs/pypaimon/robomind-agilex.md
+++ b/docs/docs/pypaimon/robomind-agilex.md
@@ -84,9 +84,11 @@ pytest -q pypaimon/tests/robomind_agilex_pipeline_test.py \
 ```python
 from pypaimon.sample.robomind_agilex import (
     backfill_canonical_action,
+    backfill_canonical_action_ray,
     ingest_local,
     ingest_ray,
     run_local_pipeline,
+    run_ray_pipeline,
 )
 
 # Run local ingestion and canonical-action backfill together.
@@ -95,25 +97,31 @@ pipeline = run_local_pipeline(
     "/data/warehouse",
 )
 
-# Or compose the lower-level operations explicitly. Ray chooses distributed
-# task placement; concurrency is only an optional upper bound.
-ingest = ingest_ray(
+# Run distributed ingestion and backfill on one managed Ray cluster.
+pipeline = run_ray_pipeline(
     "/data/RoboMIND/h5_agilex_3rgb",
     "/data/warehouse",
     concurrency=8,
-)
-
-backfill = backfill_canonical_action(
-    "/data/warehouse",
     statistics_version="robomind-agilex-joint-position@1",
+    num_partitions=8,
+    ray_address="ray://cluster:10001",
 )
 ```
 
 Episode and frame ingestion commit separately and use the generic
 `pypaimon.ray.load_from_hdf5` API in Ray mode. Canonical action materialization
-and statistics refresh also commit separately. If statistics need to be
-regenerated, call `refresh_action_statistics` without repeating ingestion or
-the row-id update.
+and statistics refresh also commit separately. The Ray backfill uses the
+optimized self-merge path: each target file group is processed by one task, so
+updates originating from multiple input batches cannot produce competing delta
+files for the same target file. The driver coordinates one commit after all
+file groups finish. If statistics need to be regenerated, call
+`refresh_action_statistics` without repeating ingestion or the row-id update.
+
+Use `backfill_canonical_action` for the local iterable path and
+`run_ray_pipeline` for managed distributed ingestion and backfill. The Ray
+pipeline requires Ray 2.50 or newer. The lower-level `ingest_ray` and
+`backfill_canonical_action_ray` stages assume that Ray has already been
+initialized, which allows either stage to be retried independently.
 
 The canonical `action` is `float32(concat(master/joint_position_left,
 master/joint_position_right))`. The backfill materializes only this consumed
diff --git a/paimon-python/pypaimon/sample/robomind_agilex.py 
b/paimon-python/pypaimon/sample/robomind_agilex.py
index aa56133e25..5a4b83a170 100644
--- a/paimon-python/pypaimon/sample/robomind_agilex.py
+++ b/paimon-python/pypaimon/sample/robomind_agilex.py
@@ -114,6 +114,14 @@ class LocalPipelineResult:
     backfill: BackfillResult
 
 
+@dataclass(frozen=True)
+class RayPipelineResult:
+    """Result of the complete Ray ingestion and backfill pipeline."""
+
+    ingest: IngestResult
+    backfill: BackfillResult
+
+
 def episode_schema():
     """Return the shared AgileX episode business schema."""
     return pa.schema([
@@ -341,25 +349,37 @@ def run_local_pipeline(
     return LocalPipelineResult(ingest=ingest, backfill=backfill)
 
 
-def ingest_ray(
+def _require_ray_250(ray):
+    from packaging.version import parse
+
+    if parse(ray.__version__) < parse("2.50.0"):
+        raise RuntimeError(
+            "RoboMIND Ray backfill requires ray>=2.50; installed ray is %s."
+            % ray.__version__)
+
+
+def run_ray_pipeline(
         input_root,
         warehouse,
         *,
         database=DEFAULT_DATABASE,
         batch_size=64,
         concurrency=None,
+        statistics_version=DEFAULT_STATISTICS_VERSION,
+        num_partitions=None,
         ray_address=None):
-    """Ingest AgileX through the public distributed HDF5 loader."""
-    if concurrency is not None:
-        concurrency = _positive_int(concurrency, "concurrency")
-    episodes = discover_episodes(input_root)
-    _create_tables(warehouse, database)
+    """Run Ray ingestion and backfill on one managed Ray cluster."""
+    if num_partitions is not None:
+        num_partitions = _positive_int(num_partitions, "num_partitions")
+    if not isinstance(statistics_version, str) or not statistics_version:
+        raise ValueError("statistics_version must be a non-empty string.")
 
     try:
         import ray
     except ImportError:
         raise ImportError(
-            "Ray ingestion requires ray; install pypaimon[ray,hdf5].")
+            "Ray pipeline requires ray; install pypaimon[ray,hdf5].")
+    _require_ray_250(ray)
 
     initialized_here = not ray.is_initialized()
     if initialized_here:
@@ -377,33 +397,62 @@ def ingest_ray(
             "ray_address cannot be set after Ray has already been 
initialized.")
 
     try:
-        from pypaimon.ray import load_from_hdf5
-        catalog_options = {
-            "warehouse": str(Path(warehouse).expanduser().resolve())}
-        paths = [episode.path for episode in episodes]
-        episode_result = load_from_hdf5(
-            "%s.%s" % (database, EPISODES_TABLE), paths, catalog_options,
-            transform=RoboMindAgileXEpisodeTransform(episodes),
+        ingest = ingest_ray(
+            input_root,
+            warehouse,
+            database=database,
+            batch_size=batch_size,
             concurrency=concurrency,
         )
-        frame_result = load_from_hdf5(
-            "%s.%s" % (database, FRAMES_TABLE), paths, catalog_options,
-            transform=RoboMindAgileXFrameTransform(
-                episodes, batch_size=batch_size),
-            concurrency=concurrency,
-        )
-        return IngestResult(
-            mode="ray",
-            episode_count=episode_result.row_count,
-            frame_count=frame_result.row_count,
-            episodes_snapshot_id=episode_result.snapshot_id,
-            frames_snapshot_id=frame_result.snapshot_id,
+        backfill = backfill_canonical_action_ray(
+            warehouse,
+            database=database,
+            statistics_version=statistics_version,
+            num_partitions=num_partitions,
         )
+        return RayPipelineResult(ingest=ingest, backfill=backfill)
     finally:
         if initialized_here:
             ray.shutdown()
 
 
+def ingest_ray(
+        input_root,
+        warehouse,
+        *,
+        database=DEFAULT_DATABASE,
+        batch_size=64,
+        concurrency=None):
+    """Ingest AgileX through an already initialized Ray cluster."""
+    if concurrency is not None:
+        concurrency = _positive_int(concurrency, "concurrency")
+    episodes = discover_episodes(input_root)
+    _create_tables(warehouse, database)
+
+    from pypaimon.ray import load_from_hdf5
+    catalog_options = {
+        "warehouse": str(Path(warehouse).expanduser().resolve())}
+    paths = [episode.path for episode in episodes]
+    episode_result = load_from_hdf5(
+        "%s.%s" % (database, EPISODES_TABLE), paths, catalog_options,
+        transform=RoboMindAgileXEpisodeTransform(episodes),
+        concurrency=concurrency,
+    )
+    frame_result = load_from_hdf5(
+        "%s.%s" % (database, FRAMES_TABLE), paths, catalog_options,
+        transform=RoboMindAgileXFrameTransform(
+            episodes, batch_size=batch_size),
+        concurrency=concurrency,
+    )
+    return IngestResult(
+        mode="ray",
+        episode_count=episode_result.row_count,
+        frame_count=frame_result.row_count,
+        episodes_snapshot_id=episode_result.snapshot_id,
+        frames_snapshot_id=frame_result.snapshot_id,
+    )
+
+
 def build_canonical_action_backfill(source):
     """Build canonical actions keyed by the physical row ID."""
     required = [_ACTION_LEFT, _ACTION_RIGHT, "_ROW_ID"]
@@ -453,8 +502,85 @@ def backfill_canonical_action(
     )
 
 
+def backfill_canonical_action_ray(
+        warehouse,
+        *,
+        database=DEFAULT_DATABASE,
+        statistics_version=DEFAULT_STATISTICS_VERSION,
+        num_partitions=None):
+    """Run distributed backfill on an already initialized Ray cluster."""
+    try:
+        import ray
+    except ImportError:
+        raise ImportError(
+            "Ray backfill requires ray; install pypaimon[ray].")
+    _require_ray_250(ray)
+
+    row_count, frames_snapshot_id = _materialize_canonical_action_ray(
+        warehouse,
+        database=database,
+        num_partitions=num_partitions,
+    )
+    statistics_snapshot_id = refresh_action_statistics(
+        warehouse,
+        database=database,
+        statistics_version=statistics_version,
+    )
+    return BackfillResult(
+        row_count=row_count,
+        frames_snapshot_id=frames_snapshot_id,
+        statistics_snapshot_id=statistics_snapshot_id,
+        statistics_version=statistics_version,
+    )
+
+
 def materialize_canonical_action(warehouse, *, database=DEFAULT_DATABASE):
     """Stage one: add and populate canonical action, then commit it."""
+    connection, frames_table = _prepare_canonical_action_table(
+        warehouse, database)
+    row_count = _update_canonical_action_batches(frames_table.raw_table)
+    frames_table = connection.get_table(FRAMES_TABLE)
+    frames_snapshot_id = _snapshot_id(frames_table)
+    return row_count, frames_snapshot_id
+
+
+def _materialize_canonical_action_ray(
+        warehouse,
+        *,
+        database=DEFAULT_DATABASE,
+        num_partitions=None):
+    """Stage one: materialize canonical action with Ray self-merge."""
+    if num_partitions is not None:
+        num_partitions = _positive_int(num_partitions, "num_partitions")
+
+    from pypaimon.ray import WhenMatched, merge_into
+
+    connection, _ = _prepare_canonical_action_table(
+        warehouse, database)
+    catalog_options = {
+        "warehouse": str(Path(warehouse).expanduser().resolve())}
+
+    def canonical_action(rows):
+        return build_canonical_action_backfill(rows)[_ACTION_COLUMN]
+
+    target = "%s.%s" % (database, FRAMES_TABLE)
+    result = merge_into(
+        target,
+        target,
+        catalog_options,
+        on=["_ROW_ID"],
+        when_matched=[WhenMatched.update({
+            _ACTION_COLUMN: canonical_action,
+        })],
+        read_columns=[_ACTION_LEFT, _ACTION_RIGHT],
+        num_partitions=num_partitions,
+    )
+
+    frames_table = connection.get_table(FRAMES_TABLE)
+    return result["num_matched"], _snapshot_id(frames_table)
+
+
+def _prepare_canonical_action_table(warehouse, database):
     connection = pmm.connect(
         database=database,
         options={"warehouse": str(Path(warehouse).expanduser().resolve())},
@@ -478,10 +604,7 @@ def materialize_canonical_action(warehouse, *, 
database=DEFAULT_DATABASE):
             False,
         )
         frames_table = connection.get_table(FRAMES_TABLE)
-    row_count = _update_canonical_action_batches(frames_table.raw_table)
-    frames_table = connection.get_table(FRAMES_TABLE)
-    frames_snapshot_id = _snapshot_id(frames_table)
-    return row_count, frames_snapshot_id
+    return connection, frames_table
 
 
 def refresh_action_statistics(
diff --git a/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py 
b/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
index 92013a9017..f77e146515 100644
--- a/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
+++ b/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
@@ -15,6 +15,7 @@
 # limitations under the License.
 
 import importlib.util
+import inspect
 import json
 import subprocess
 import sys
@@ -398,17 +399,18 @@ def 
test_ray_ingest_matches_local_schema_rows_and_backfill(
     local_backfill = agilex.backfill_canonical_action(
         local_warehouse, statistics_version="synthetic-actions@1")
 
-    ray.init(num_cpus=2, include_dashboard=False)
-    try:
-        ray_result = agilex.ingest_ray(
-            root, ray_warehouse, batch_size=2, concurrency=2)
-    finally:
-        ray.shutdown()
+    ray_pipeline = agilex.run_ray_pipeline(
+        root,
+        ray_warehouse,
+        batch_size=2,
+        concurrency=2,
+        statistics_version="synthetic-actions@1",
+    )
+    ray_result = ray_pipeline.ingest
+    ray_backfill = ray_pipeline.backfill
+    assert not ray.is_initialized()
     assert ray_result.episodes_snapshot_id == 1
     assert ray_result.frames_snapshot_id == 1
-    ray_backfill = agilex.backfill_canonical_action(
-        ray_warehouse, statistics_version="synthetic-actions@1")
-
     assert ray_result.mode == "ray"
     assert ray_result.episode_count == len(paths)
     assert ray_result.frame_count == 12
@@ -426,6 +428,110 @@ def 
test_ray_ingest_matches_local_schema_rows_and_backfill(
             _logical_rows(ray_warehouse, table_name, schema))
 
 
+def test_run_ray_pipeline_owns_cluster_lifecycle(monkeypatch):
+    ray = pytest.importorskip("ray")
+    ray_init = MagicMock()
+    ray_shutdown = MagicMock()
+    ingest = MagicMock(return_value=MagicMock())
+    backfill = MagicMock(return_value=MagicMock())
+    monkeypatch.setattr(ray, "__version__", "2.50.0")
+    monkeypatch.setattr(ray, "is_initialized", lambda: False)
+    monkeypatch.setattr(ray, "init", ray_init)
+    monkeypatch.setattr(ray, "shutdown", ray_shutdown)
+    monkeypatch.setattr(agilex, "ingest_ray", ingest)
+    monkeypatch.setattr(agilex, "backfill_canonical_action_ray", backfill)
+
+    result = agilex.run_ray_pipeline(
+        "input", "warehouse", ray_address="ray://cluster")
+
+    ray_init.assert_called_once_with(
+        include_dashboard=False,
+        ignore_reinit_error=True,
+        address="ray://cluster",
+    )
+    ingest.assert_called_once_with(
+        "input",
+        "warehouse",
+        database=agilex.DEFAULT_DATABASE,
+        batch_size=64,
+        concurrency=None,
+    )
+    backfill.assert_called_once_with(
+        "warehouse",
+        database=agilex.DEFAULT_DATABASE,
+        statistics_version=agilex.DEFAULT_STATISTICS_VERSION,
+        num_partitions=None,
+    )
+    ray_shutdown.assert_called_once_with()
+    assert result.ingest is ingest.return_value
+    assert result.backfill is backfill.return_value
+
+
+def test_run_ray_pipeline_rejects_old_ray_before_ingest(monkeypatch):
+    ray = pytest.importorskip("ray")
+    ingest = MagicMock()
+    monkeypatch.setattr(ray, "__version__", "2.49.0")
+    monkeypatch.setattr(ray, "is_initialized", lambda: True)
+    monkeypatch.setattr(agilex, "ingest_ray", ingest)
+
+    with pytest.raises(RuntimeError, match="requires ray>=2.50"):
+        agilex.run_ray_pipeline("input", "warehouse")
+
+    ingest.assert_not_called()
+
+
+def test_ray_backfill_rejects_old_ray_before_preparing_table(monkeypatch):
+    ray = pytest.importorskip("ray")
+    prepare = MagicMock(return_value=(MagicMock(), MagicMock()))
+    monkeypatch.setattr(ray, "__version__", "2.49.0")
+    monkeypatch.setattr(agilex, "_prepare_canonical_action_table", prepare)
+
+    with pytest.raises(RuntimeError, match="requires ray>=2.50"):
+        agilex.backfill_canonical_action_ray("warehouse")
+
+    prepare.assert_not_called()
+
+
+def test_run_ray_pipeline_rejects_num_partitions_before_ingest(monkeypatch):
+    ray = pytest.importorskip("ray")
+    ingest = MagicMock()
+    monkeypatch.setattr(ray, "__version__", "2.50.0")
+    monkeypatch.setattr(ray, "is_initialized", lambda: True)
+    monkeypatch.setattr(agilex, "ingest_ray", ingest)
+
+    with pytest.raises(
+            ValueError, match="num_partitions must be a positive int"):
+        agilex.run_ray_pipeline(
+            "input", "warehouse", num_partitions=0)
+
+    ingest.assert_not_called()
+
+
+def test_run_ray_pipeline_rejects_statistics_version_before_ingest(
+        monkeypatch):
+    ray = pytest.importorskip("ray")
+    ingest = MagicMock()
+    monkeypatch.setattr(ray, "__version__", "2.50.0")
+    monkeypatch.setattr(ray, "is_initialized", lambda: True)
+    monkeypatch.setattr(agilex, "ingest_ray", ingest)
+    monkeypatch.setattr(
+        agilex, "_materialize_canonical_action_ray",
+        MagicMock(return_value=(12, 2)))
+
+    with pytest.raises(
+            ValueError, match="statistics_version must be a non-empty string"):
+        agilex.run_ray_pipeline(
+            "input", "warehouse", statistics_version="")
+
+    ingest.assert_not_called()
+
+
+def test_ray_stage_apis_do_not_accept_cluster_addresses():
+    assert "ray_address" not in inspect.signature(agilex.ingest_ray).parameters
+    assert "ray_address" not in inspect.signature(
+        agilex.backfill_canonical_action_ray).parameters
+
+
 def 
test_stream_action_statistics_are_stable_and_order_independent(monkeypatch):
     frame_count = 257
     row = np.arange(frame_count, dtype=np.float64).reshape(-1, 1)

Reply via email to