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 5e1429c77c [python] Support distributed LeRobot import with Ray
(#10483)
5e1429c77c is described below
commit 5e1429c77c1bcee6e2e9d11c44f75a7257c7364e
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Oct 20 16:03:56 2026 +0800
[python] Support distributed LeRobot import with Ray (#10483)
---
docs/docs/pypaimon/lerobot.md | 15 +
paimon-python/pypaimon/multimodal/connection.py | 4 +-
paimon-python/pypaimon/multimodal/lerobot/api.py | 31 +-
.../pypaimon/multimodal/lerobot/loader.py | 90 +++-
.../pypaimon/multimodal/lerobot/ray_loader.py | 172 +++++++
.../pypaimon/multimodal/lerobot/source.py | 2 +
.../pypaimon/tests/multimodal_lerobot_ray_test.py | 547 +++++++++++++++++++++
7 files changed, 830 insertions(+), 31 deletions(-)
diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index 3499a703b7..53c32451a5 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -72,6 +72,21 @@ conn.load_from_lerobot(
)
```
+For distributed import, install `pypaimon[ray,lerobot]` on each worker:
+
+```python
+import ray
+
+ray.init(address="auto")
+conn.load_from_lerobot("robot_data", "oss://source-bucket/lerobot_dataset",
+ parallelism=8)
+```
+
+The default `parallelism=1` uses one process. Workers need source and target
+storage access; pass OSS `source_options` as above. Ray accepts FileIO URIs or
+shared directories, not Hub IDs. Keep the source unchanged during import.
+Failed imports may leave partial tables; retry with a new name.
+
Before training, finish any related data/metadata updates and pause writes to
this table group. Create a common named tag over the current component
snapshots:
diff --git a/paimon-python/pypaimon/multimodal/connection.py
b/paimon-python/pypaimon/multimodal/connection.py
index 4cea5786db..647f412e0c 100644
--- a/paimon-python/pypaimon/multimodal/connection.py
+++ b/paimon-python/pypaimon/multimodal/connection.py
@@ -142,7 +142,8 @@ class MultimodalConnection:
batch_size: int = 1024,
options=None,
source_options=None,
- tag_name=None) -> None:
+ tag_name=None,
+ parallelism=1) -> None:
"""Import LeRobot Dataset v3 into a new Paimon table group."""
from pypaimon.multimodal.lerobot import load_from_lerobot
load_from_lerobot(
@@ -153,6 +154,7 @@ class MultimodalConnection:
options=options,
source_options=source_options,
tag_name=tag_name,
+ parallelism=parallelism,
)
def create_lerobot_tag(self, table_name: str, tag_name: str):
diff --git a/paimon-python/pypaimon/multimodal/lerobot/api.py
b/paimon-python/pypaimon/multimodal/lerobot/api.py
index 20a054f90f..ce9f776eed 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/api.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/api.py
@@ -18,6 +18,7 @@
import numbers
import sys
+from functools import partial
from typing import Mapping, Optional
from pypaimon.catalog.catalog_exception import TableAlreadyExistException
@@ -68,6 +69,7 @@ def load_from_lerobot(
options: Optional[Mapping[str, object]] = None,
source_options: Optional[Mapping[str, object]] = None,
tag_name: Optional[str] = None,
+ parallelism: int = 1,
) -> None:
"""Import LeRobot Dataset v3 into a new Paimon table group.
@@ -75,7 +77,8 @@ def load_from_lerobot(
info/stats metadata are stored in companion Paimon tables. If provided,
``tag_name`` pins all components to their imported snapshots.
FileIO URI credentials come only from ``source_options`` and are not
- inherited from the target Catalog.
+ inherited from the target Catalog. ``parallelism > 1`` distributes Episode
+ groups on an initialized Ray cluster. All workers need source and target
access.
"""
if sys.version_info < (3, 10):
raise RuntimeError(
@@ -85,6 +88,14 @@ def load_from_lerobot(
or batch_size <= 0:
raise ValueError("batch_size must be a positive integer.")
+ if isinstance(parallelism, bool) or not isinstance(parallelism, int) \
+ or parallelism <= 0:
+ raise ValueError("parallelism must be a positive integer.")
+ if parallelism > 1:
+ from pypaimon.multimodal.lerobot.ray_loader import _require_ray,
_ray_source_uri
+ _require_ray()
+ source = _ray_source_uri(source)
+
if tag_name is not None:
_validate_tag_name(tag_name)
@@ -102,7 +113,8 @@ def load_from_lerobot(
_validated_counts(local_info, resolved_source.path)
_validate_v3_required_features(local_info)
video_fields = _video_feature_names(local_info)
- LeRobotDataset = _import_lerobot_dataset()
+ LeRobotDataset = (_import_lerobot_dataset()
+ if parallelism == 1 else None)
dataset = _open_resolved_dataset(
LeRobotDataset,
resolved_source,
@@ -131,6 +143,8 @@ def load_from_lerobot(
metadata,
tag_name,
video_fields,
+ parallelism,
+ validated_source_options,
)
finally:
close = getattr(dataset, "close", None)
@@ -149,7 +163,9 @@ def _import_dataset(
options,
metadata,
tag_name,
- video_fields):
+ video_fields,
+ parallelism=1,
+ source_options=None):
table = _create_target_table(
connection,
table_name,
@@ -166,7 +182,14 @@ def _import_dataset(
)
frames_snapshot_id = None
if int(info["total_frames"]) > 0:
- frames_snapshot_id = _write_dataset(
+ if parallelism > 1:
+ from pypaimon.multimodal.lerobot.ray_loader import
_write_dataset_ray
+ write_dataset = partial(
+ _write_dataset_ray, parallelism=parallelism,
+ source_options=source_options)
+ else:
+ write_dataset = _write_dataset
+ frames_snapshot_id = write_dataset(
table,
dataset,
info,
diff --git a/paimon-python/pypaimon/multimodal/lerobot/loader.py
b/paimon-python/pypaimon/multimodal/lerobot/loader.py
index c64ef56cfc..b664f5c4df 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/loader.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/loader.py
@@ -17,6 +17,7 @@
"""LeRobot frame conversion and batch writing."""
import io
+import logging
import math
import numbers
from array import array
@@ -54,6 +55,7 @@ _NUMERIC_DTYPES = {
}
_BOOLEAN_DTYPES = {"bool", "boolean"}
_VIDEO_TIMESTAMP_TOLERANCE = 1e-4
+_LOGGER = logging.getLogger(__name__)
def _strict_lerobot_table(data, target_schema, source, batch_index):
@@ -75,18 +77,50 @@ def _write_dataset(
batch_size,
metadata,
video_fields=()):
- target_schema = _target_schema(table.raw_table)
- write_builder = table.raw_table.new_batch_write_builder()
+ table_commit = table.raw_table.new_batch_write_builder().new_commit()
+ try:
+ messages = _prepare_dataset(
+ table.raw_table, dataset, info, source, source_schema, batch_size,
+ metadata, video_fields)
+ return _commit_messages(table_commit, messages)
+ finally:
+ table_commit.close()
+
+
+def _commit_messages(table_commit, messages):
+ snapshot_recorder = _SnapshotRecorder()
+ table_commit.add_commit_callback(snapshot_recorder)
+ table_commit.commit(messages)
+ if snapshot_recorder.snapshot_id is None:
+ raise RuntimeError(
+ "LeRobot append committed without reporting a snapshot id.")
+ return snapshot_recorder.snapshot_id
+
+
+def _abort_messages(table, messages):
+ if not messages:
+ return
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ commit.abort(messages)
+ finally:
+ commit.close()
+
+
+def _prepare_dataset(
+ table, dataset, info, source, source_schema, batch_size, metadata,
+ video_fields=(), episode_indices=None):
+ target_schema = _target_schema(table)
+ write_builder = table.new_batch_write_builder()
table_write = None
- table_commit = None
- commit_started = False
+ prepared = False
+ messages = None
batch_count = 0
row_count = 0
episodes = metadata["episodes"]
current_episode = None
expected_tasks = set()
observed_tasks = set()
- snapshot_recorder = _SnapshotRecorder()
video_sources = {}
try:
@@ -95,14 +129,13 @@ def _write_dataset(
dataset, "video_uri_reader_factory", None)
if video_fields and reader_factory is not None:
table_write.with_blob_uri_reader_factory(reader_factory)
- table_commit = write_builder.new_commit()
- table_commit.add_commit_callback(snapshot_recorder)
for source_episode, episode_index, episode_begin, task_indices, \
begin, end in _episode_batches(
info,
batch_size,
episodes,
video_fields,
+ episode_indices,
):
if episode_index != current_episode:
if current_episode is not None:
@@ -147,43 +180,48 @@ def _write_dataset(
_validate_episode_tasks(
current_episode, expected_tasks, observed_tasks)
- expected_rows = int(info.get("total_frames", len(dataset)))
+ expected_rows = (int(info["total_frames"]) if episode_indices is None
+ else sum(episodes[i]["length"] for i in
episode_indices))
if row_count != expected_rows:
raise ValueError(
"LeRobot metadata reports %d frames but import produced %d."
% (expected_rows, row_count))
messages = table_write.prepare_commit()
- commit_started = True
- table_commit.commit(messages)
- if snapshot_recorder.snapshot_id is None:
- raise RuntimeError(
- "LeRobot append committed without reporting a snapshot id.")
- return snapshot_recorder.snapshot_id
+ prepared = True
+ return messages
except BaseException:
- if table_write is not None and not commit_started:
+ if table_write is not None and not prepared:
table_write.abort()
raise
finally:
- try:
- if table_write is not None:
+ if table_write is not None:
+ try:
table_write.close()
- finally:
- if table_commit is not None:
- table_commit.close()
-
-
-def _episode_batches(info, batch_size, episodes, video_fields=()):
+ except BaseException:
+ # prepare_commit handed off these files; writer.abort() no
+ # longer owns them, and no caller has received the messages.
+ if messages is not None:
+ try:
+ _abort_messages(table, messages)
+ except Exception:
+ _LOGGER.exception("Failed to abort prepared LeRobot
files.")
+ raise
+
+
+def _episode_batches(
+ info, batch_size, episodes, video_fields=(), episode_indices=None):
episode_count = int(info.get("total_episodes", 0))
total_frames = int(info["total_frames"])
expected_begin = 0
- for ordinal in range(episode_count):
+ ordinals = range(episode_count) if episode_indices is None else
episode_indices
+ for ordinal in ordinals:
episode = episodes.iloc[ordinal] if hasattr(episodes, "iloc") \
else episodes[ordinal]
episode_index = int(_python_scalar(episode["episode_index"]))
begin = int(_python_scalar(episode["dataset_from_index"]))
end = int(_python_scalar(episode["dataset_to_index"]))
length = int(_python_scalar(episode["length"]))
- if episode_index != ordinal or begin != expected_begin \
+ if episode_index != ordinal or (episode_indices is None and begin !=
expected_begin) \
or end <= begin or length != end - begin:
raise ValueError(
"LeRobot episode %d has an invalid index, range, or length."
@@ -203,7 +241,7 @@ def _episode_batches(info, batch_size, episodes,
video_fields=()):
)
begin = batch_end
expected_begin = end
- if expected_begin != total_frames:
+ if episode_indices is None and expected_begin != total_frames:
raise ValueError(
"LeRobot episode ranges cover %d frames but metadata reports %d."
% (expected_begin, total_frames))
diff --git a/paimon-python/pypaimon/multimodal/lerobot/ray_loader.py
b/paimon-python/pypaimon/multimodal/lerobot/ray_loader.py
new file mode 100644
index 0000000000..22063bc396
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/ray_loader.py
@@ -0,0 +1,172 @@
+# 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.
+
+"""Ray workers prepare LeRobot files; only the driver commits snapshots."""
+
+import logging
+from pathlib import Path
+
+from pypaimon.multimodal.lerobot.loader import (
+ _abort_messages, _commit_messages, _prepare_dataset,
+)
+from pypaimon.multimodal.lerobot.source import (
+ _RemoteLeRobotDataset, _resolved_source,
+)
+
+
+_CANCEL_DRAIN_SECONDS = 1.0
+
+
+def _require_ray():
+ try:
+ import ray
+ except ImportError as error:
+ raise ImportError("Parallel import requires 'pypaimon[ray,lerobot]'.")
from error
+ if not ray.is_initialized():
+ raise RuntimeError("Call ray.init() before using parallelism > 1.")
+ return ray
+
+
+def _ray_source_uri(source):
+ if isinstance(source, str):
+ source = source.strip()
+ if "://" in source:
+ return source
+ if source and Path(source).expanduser().is_dir():
+ return Path(source).expanduser().resolve().as_uri()
+ elif isinstance(source, Path):
+ return source.expanduser().resolve().as_uri()
+ raise ValueError(
+ "Parallel import requires a FileIO URI or shared directory, not a Hub
repo_id.")
+
+
+def _episode_groups(info, episodes, video_fields):
+ # Keep Episodes sharing a data or video file in the same task.
+ parents = list(range(len(episodes)))
+
+ def root(i):
+ while parents[i] != i:
+ parents[i] = parents[parents[i]]
+ i = parents[i]
+ return i
+
+ owners = {}
+ for i in range(len(episodes)):
+ episode = episodes[i]
+ data_path = _RemoteLeRobotDataset._data_path(episode, info)
+ previous = owners.setdefault(("data", data_path), i)
+ parents[root(i)] = root(previous)
+ for field in video_fields:
+ prefix = "videos/%s/" % field
+ path = info["video_path"].format(
+ video_key=field, chunk_index=episode[prefix + "chunk_index"],
+ file_index=episode[prefix + "file_index"])
+ previous = owners.setdefault(("video", path), i)
+ parents[root(i)] = root(previous)
+ groups = {}
+ for i in range(len(episodes)):
+ groups.setdefault(root(i), []).append(i)
+ return list(groups.values())
+
+
+def _task_groups(info, episodes, video_fields, parallelism):
+ groups = _episode_groups(info, episodes, video_fields)
+ tasks = [[] for _ in range(min(parallelism, len(groups)))]
+ for i, group in enumerate(groups):
+ tasks[i % len(tasks)].extend(group)
+ return [sorted(task) for task in tasks]
+
+
+def _prepare_group(table, source_path, source_options, info, schema,
+ batch_size, metadata, video_fields, episode_indices):
+ messages = None
+ try:
+ with _resolved_source(source_path, source_options) as (source,
current_info):
+ if current_info != info:
+ raise ValueError("LeRobot source metadata changed during
import.")
+ dataset = _RemoteLeRobotDataset(source, info)
+ try:
+ messages = _prepare_dataset(
+ table, dataset, info, source, schema, batch_size,
+ metadata, video_fields, episode_indices)
+ finally:
+ dataset.close()
+ return messages
+ except BaseException:
+ # Retain ownership until both the dataset and source have closed.
+ if messages is not None:
+ try:
+ _abort_messages(table, messages)
+ except Exception:
+ logging.exception("Failed to abort prepared LeRobot files.")
+ raise
+
+
+def _write_dataset_ray(table, dataset, info, source, source_schema, batch_size,
+ metadata, video_fields=(), *, parallelism,
+ source_options=None):
+ ray = _require_ray()
+ table_commit = table.raw_table.new_batch_write_builder().new_commit()
+ try:
+ groups = _task_groups(info, dataset._episodes, video_fields,
parallelism)
+ # Share only validated metadata, never the driver's dataset or open
streams.
+ table_ref = ray.put(table.raw_table)
+ info_ref = ray.put(info)
+ metadata_ref = ray.put({
+ "episodes": metadata["episodes"],
+ "subtask_indices": metadata["subtask_indices"],
+ })
+ prepare = ray.remote(num_cpus=1, max_retries=0)(_prepare_group)
+ pending = []
+ messages = []
+ try:
+ for group in groups:
+ pending.append(prepare.remote(
+ table_ref, source.path, source_options, info_ref,
+ source_schema, batch_size, metadata_ref, video_fields,
group))
+ while pending:
+ ready, _ = ray.wait(pending, num_returns=1)
+ messages.extend(ray.get(ready[0]))
+ pending.remove(ready[0])
+ except BaseException:
+ # Soft cancellation raises KeyboardInterrupt in running Ray tasks,
+ # allowing their writer cleanup to run. Never wait without a bound.
+ for task in pending:
+ try:
+ ray.cancel(task)
+ except Exception:
+ logging.exception("Failed to cancel LeRobot import task.")
+ if pending:
+ try:
+ ready, _ = ray.wait(
+ pending, num_returns=len(pending),
+ timeout=_CANCEL_DRAIN_SECONDS)
+ for task in ready:
+ try:
+ messages.extend(ray.get(task, timeout=0))
+ except Exception:
+ pass
+ except Exception:
+ logging.exception("Failed to drain cancelled LeRobot
tasks.")
+ try:
+ _abort_messages(table.raw_table, messages)
+ except Exception:
+ logging.exception("Failed to abort prepared LeRobot files.")
+ raise
+
+ return _commit_messages(table_commit, messages)
+ finally:
+ table_commit.close()
diff --git a/paimon-python/pypaimon/multimodal/lerobot/source.py
b/paimon-python/pypaimon/multimodal/lerobot/source.py
index 995c14a438..7eb8265c73 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/source.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/source.py
@@ -543,6 +543,8 @@ def _remote_source_path(root, path, name,
source_file_io=None):
% (name, path))
source_path = posixpath.normpath(
_fully_unquote(path_uri.path) or "/")
+ elif root_uri.scheme.lower() == "file" and Path(path).is_absolute():
+ source_path = str(Path(path).resolve())
else:
relative_path = _relative_dataset_path(path, name)
source_path = posixpath.normpath(posixpath.join(
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
new file mode 100644
index 0000000000..cba6f43a8f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
@@ -0,0 +1,547 @@
+# 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 importlib.util
+import io
+import json
+import os
+import tempfile
+import time
+import unittest
+from collections import Counter
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+from urllib.parse import urlparse
+
+import numpy as np
+import pandas as pd
+import pyarrow as pa
+import pyarrow.parquet as pq
+from PIL import Image
+
+import pypaimon.multimodal as pm
+from pypaimon.multimodal.lerobot.ray_loader import (
+ _episode_groups, _prepare_group, _ray_source_uri, _task_groups,
+)
+
+
+def _counted_prepare_group(trace_path, *args):
+ from pypaimon.multimodal.lerobot import source as source_module
+ from pypaimon.multimodal.lerobot.ray_loader import _prepare_group
+
+ original = source_module._read_remote_parquet
+
+ def counted(file_io, path, *read_args, **read_kwargs):
+ with open(trace_path, "a") as trace:
+ trace.write(path + "\n")
+ return original(file_io, path, *read_args, **read_kwargs)
+
+ source_module._read_remote_parquet = counted
+ try:
+ return _prepare_group(*args)
+ finally:
+ source_module._read_remote_parquet = original
+
+
+def _bucket_files(table):
+ bucket = Path(urlparse(table.table_path).path).resolve() / "bucket-0"
+ return sorted(path for path in bucket.rglob("*") if path.is_file())
+
+
+class LeRobotRayPlanningTest(unittest.TestCase):
+ def test_groups_shared_payloads_across_cameras(self):
+ info = {
+ "data_path": "data/{chunk_index}/{file_index}.parquet",
+ "video_path": "{video_key}/{chunk_index}/{file_index}.mp4",
+ }
+ episodes = []
+ for a, b in ((0, 0), (0, 1), (2, 1), (3, 3)):
+ episodes.append({
+ "data/chunk_index": 0, "data/file_index": len(episodes),
+ "videos/a/chunk_index": 0, "videos/a/file_index": a,
+ "videos/b/chunk_index": 0, "videos/b/file_index": b,
+ })
+ self.assertEqual([[0, 1, 2], [3]],
+ _episode_groups(info, episodes, ("a", "b")))
+ self.assertEqual([[0], [1], [2], [3]],
+ _episode_groups(info, episodes, ()))
+ episodes[1]["data/file_index"] = 0
+ self.assertEqual([[0, 1], [2], [3]],
+ _episode_groups(info, episodes, ()))
+ self.assertEqual([[0, 1, 3], [2]],
+ _task_groups(info, episodes, (), 2))
+
+ def test_reject_hub_id(self):
+ with self.assertRaisesRegex(ValueError, "Hub repo_id"):
+ _ray_source_uri("lerobot/example")
+
+ def test_cancel_after_prepare_aborts_worker_files(self):
+ table = object()
+ messages = [object()]
+ dataset = MagicMock()
+ dataset.close.side_effect = KeyboardInterrupt
+ with patch("pypaimon.multimodal.lerobot.ray_loader._resolved_source")
as resolved, \
+
patch("pypaimon.multimodal.lerobot.ray_loader._RemoteLeRobotDataset",
+ return_value=dataset), \
+
patch("pypaimon.multimodal.lerobot.ray_loader._prepare_dataset",
+ return_value=messages), \
+
patch("pypaimon.multimodal.lerobot.ray_loader._abort_messages") as abort:
+ resolved.return_value.__enter__.return_value = (object(), {})
+ with self.assertRaises(KeyboardInterrupt):
+ _prepare_group(table, "ignored", None, {}, None, 1, {}, (),
[0])
+ abort.assert_called_once_with(table, messages)
+ dataset.close.assert_called_once()
+
+ def test_validate_options_before_creating_tables(self):
+ with tempfile.TemporaryDirectory() as directory:
+ conn = pm.connect(options={"warehouse": Path(directory).as_uri()})
+ for options in ({"parallelism": 0}, {"parallelism": -1},
+ {"parallelism": True}, {"parallelism": 1.5},
+ {"parallelism": "2"}):
+ with self.subTest(options=options):
+ with self.assertRaises(ValueError):
+ conn.load_from_lerobot("invalid", "missing", **options)
+ self.assertEqual([], conn.catalog.list_databases())
+
+
[email protected](importlib.util.find_spec("ray"), "Ray is not installed")
+class LeRobotRayImportTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import ray
+ cls.owns_ray = not ray.is_initialized()
+ if cls.owns_ray:
+ ray.init(num_cpus=2, include_dashboard=False,
+ object_store_memory=100 * 1024 * 1024)
+
+ @classmethod
+ def tearDownClass(cls):
+ if cls.owns_ray:
+ import ray
+ ray.shutdown()
+
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.root = Path(self.directory.name).resolve()
+ self.source = self.root / "source"
+ self.conn = pm.connect(options={"warehouse": (self.root /
"lake").as_uri()})
+
+ def tearDown(self):
+ self.directory.cleanup()
+
+ def _source(self, video=False, data_files=1, path_backed=False):
+ root = self.source
+ (root / "meta/episodes/chunk-000").mkdir(parents=True, exist_ok=True)
+ (root / "data/chunk-000").mkdir(parents=True, exist_ok=True)
+ features = {key: {"dtype": "int64", "shape": [1]} for key in
+ ("index", "episode_index", "frame_index", "task_index")}
+ features.update({
+ "timestamp": {"dtype": "float32", "shape": [1]},
+ "observation.state": {"dtype": "float32", "shape": [2]},
+ "camera": {"dtype": "video" if video else "image", "shape": [16,
16, 3]},
+ })
+ info = {
+ "codebase_version": "v3.0", "fps": 10, "total_frames": 8,
+ "total_episodes": 4, "total_tasks": 1, "features": features,
+ "data_path":
"data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
+ "video_path":
"videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4",
+ }
+ (root / "meta/info.json").write_text(json.dumps(info))
+ pq.write_table(pa.Table.from_pandas(pd.DataFrame(
+ {"task_index": [0]}, index=pd.Index(["pick"], name="task"))),
+ root / "meta/tasks.parquet")
+ episodes = []
+ for e in range(4):
+ row = {"episode_index": e, "dataset_from_index": e * 2,
+ "dataset_to_index": e * 2 + 2, "length": 2, "tasks":
["pick"],
+ "data/chunk_index": 0,
+ "data/file_index": e // (4 // data_files)}
+ if video:
+ row.update({"videos/camera/chunk_index": 0,
+ "videos/camera/file_index": e // 2,
+ "videos/camera/from_timestamp": (e % 2) * 0.2,
+ "videos/camera/to_timestamp": (e % 2 + 1) * 0.2})
+ episodes.append(row)
+ pq.write_table(pa.Table.from_pylist(episodes),
+ root / "meta/episodes/chunk-000/file-000.parquet")
+ rows = pa.table({
+ "index": pa.array(range(8), type=pa.int64()),
+ "episode_index": pa.array([i // 2 for i in range(8)],
type=pa.int64()),
+ "frame_index": pa.array([i % 2 for i in range(8)],
type=pa.int64()),
+ "task_index": pa.array([0] * 8, type=pa.int64()),
+ "timestamp": pa.array([i % 2 / 10 for i in range(8)],
type=pa.float32()),
+ "observation.state": pa.array([[i, -i] for i in range(8)],
type=pa.list_(pa.float32(), 2)),
+ })
+ if video:
+ import av
+ for v in range(2):
+ path = root / ("videos/camera/chunk-000/file-%03d.mp4" % v)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with av.open(str(path), "w") as container:
+ stream = container.add_stream("mpeg4", rate=10)
+ stream.width = stream.height = 16
+ stream.pix_fmt = "yuv420p"
+ for i in range(4):
+ frame = av.VideoFrame.from_ndarray(
+ np.full((16, 16, 3), (v * 4 + i) * 20,
dtype=np.uint8), format="rgb24")
+ for packet in stream.encode(frame):
+ container.mux(packet)
+ for packet in stream.encode():
+ container.mux(packet)
+ else:
+ images = []
+ for i in range(8):
+ buffer = io.BytesIO()
+ Image.new("RGB", (16, 16), (i * 20, 0, 0)).save(buffer,
format="PNG")
+ if path_backed:
+ path = root / ("image-%d.png" % i)
+ path.write_bytes(buffer.getvalue())
+ images.append({"bytes": None, "path": str(path.resolve())})
+ else:
+ images.append({"bytes": buffer.getvalue(), "path": None})
+ rows = rows.append_column("camera", pa.array(images))
+ for file_index in range(data_files):
+ episode_count = 4 // data_files
+ pq.write_table(
+ rows.slice(file_index * episode_count * 2, episode_count * 2),
+ root / ("data/chunk-000/file-%03d.parquet" % file_index))
+ return rows
+
+ def test_source_files_are_read_once_per_worker(self):
+ trace_path = self.root / "reads.txt"
+
+ def counted_prepare(*args):
+ return _counted_prepare_group(str(trace_path), *args)
+
+ with patch(
+ "pypaimon.multimodal.lerobot.ray_loader._prepare_group",
+ counted_prepare):
+ for data_files in (1, 4):
+ with self.subTest(data_files=data_files):
+ self._source(data_files=data_files)
+ if trace_path.exists():
+ trace_path.unlink()
+ self.conn.load_from_lerobot(
+ "counted_%d" % data_files, self.source,
+ parallelism=2, batch_size=1)
+ reads = Counter(
+ str(Path(urlparse(path).path).resolve().relative_to(
+ self.source.resolve()))
+ for path in trace_path.read_text().splitlines())
+ expected_workers = min(data_files, 2)
+ self.assertEqual(expected_workers,
reads["meta/tasks.parquet"])
+ self.assertEqual(
+ expected_workers,
+ reads["meta/episodes/chunk-000/file-000.parquet"])
+ for file_index in range(data_files):
+ self.assertEqual(
+ 1, reads["data/chunk-000/file-%03d.parquet" %
file_index])
+
+ def _import_serial(self):
+ # FileIO sources use the native Parquet reader, not the LeRobot class.
+ with patch("pypaimon.multimodal.lerobot.api._import_lerobot_dataset",
return_value=None):
+ self.conn.load_from_lerobot("serial", self.source.as_uri(),
+ batch_size=1, parallelism=1)
+
+ def test_images_match_serial_import(self):
+ self._check_image_import(False)
+
+ def test_absolute_image_paths_match_serial_import(self):
+ self._check_image_import(True)
+
+ def test_home_string_path_matches_serial_import(self):
+ with patch.dict(os.environ, {"HOME": str(self.root)}):
+ self._check_image_import(True, " ~/source ")
+
+ def _check_image_import(self, path_backed, source=None):
+ self._source(path_backed=path_backed, data_files=4)
+ self._import_serial()
+ self.conn.load_from_lerobot("parallel", source or self.source,
parallelism=2,
+ batch_size=1, tag_name="imported")
+ for suffix in ("", "__episodes", "__tasks", "__info"):
+ def read(name, tag=None):
+ table = self.conn.catalog.get_table("default." + name)
+ if tag:
+ table = table.copy({"scan.tag-name": tag})
+ builder = table.new_read_builder()
+ return
builder.new_read().to_arrow(builder.new_scan().plan().splits())
+
+ left = read("serial" + suffix)
+ right = read("parallel" + suffix, "imported")
+ if not suffix:
+ from pypaimon.table.row.blob import Blob
+ left, right = left.sort_by("index"), right.sort_by("index")
+ file_io = self.conn.get_table("parallel").raw_table.file_io
+ for original, imported in zip(left["camera"], right["camera"]):
+ self.assertEqual(
+ Blob.from_descriptor_bytes(original.as_py(),
file_io=file_io).to_data(),
+ Blob.from_descriptor_bytes(imported.as_py(),
file_io=file_io).to_data())
+ left, right = left.drop(["camera"]), right.drop(["camera"])
+ else:
+ left = left.sort_by(left.column_names[0])
+ right = right.sort_by(right.column_names[0])
+ self.assertTrue(left.equals(right), suffix)
+ # One data snapshot plus the initial BTree snapshot, regardless of
task count.
+ table = self.conn.get_table("parallel").raw_table
+ self.assertEqual(2, table.snapshot_manager().get_latest_snapshot().id)
+
+ @unittest.skipUnless(
+ importlib.util.find_spec("av")
+ and importlib.util.find_spec("torch")
+ and importlib.util.find_spec("datasets"),
+ "PyAV, PyTorch, and datasets are required",
+ )
+ def test_real_videos_are_readable_after_parallel_import(self):
+ self._source(video=True)
+ self.conn.load_from_lerobot("videos", self.source, parallelism=2,
batch_size=1)
+ self._import_serial()
+ from pypaimon.multimodal.lerobot import PaimonLeRobotDataset
+ first = PaimonLeRobotDataset(self.conn.get_table("videos"),
video_backend="pyav", return_uint8=True)
+ second = PaimonLeRobotDataset(self.conn.get_table("serial"),
video_backend="pyav", return_uint8=True)
+ try:
+ for i in (7, 0, 3, 4, 1, 6, 2, 5):
+ self.assertTrue(np.array_equal(first[i]["camera"],
second[i]["camera"]))
+ finally:
+ first.close()
+ second.close()
+
+ def test_worker_failure_does_not_publish_frames_or_tags(self):
+ rows = self._source(data_files=4)
+ # Corruption in the last Episode is detected by its worker, after
planning.
+ rows = rows.set_column(rows.schema.get_field_index("frame_index"),
"frame_index",
+ pa.array([0, 1, 0, 1, 0, 1, 0, 99],
type=pa.int64()))
+ pq.write_table(rows.slice(6, 2),
+ self.source / "data/chunk-000/file-003.parquet")
+ import ray
+ from pypaimon.multimodal.lerobot.ray_loader import _abort_messages
+ received = self.root / "success_received"
+ files_before_failure = []
+ connection = self.conn
+
+ class MarkingRay:
+ def __getattr__(self, name):
+ return getattr(ray, name)
+
+ def get(self, ref, **kwargs):
+ result = ray.get(ref, **kwargs)
+ if isinstance(result, list) and result and hasattr(result[0],
"new_files"):
+ table = connection.get_table("broken").raw_table
+ files_before_failure.extend(_bucket_files(table))
+ received.touch()
+ return result
+
+ def prepare(*args):
+ if 3 in args[-1]:
+ deadline = time.monotonic() + 30
+ while not received.exists():
+ if time.monotonic() > deadline:
+ raise TimeoutError("Successful task result was not
received.")
+ time.sleep(0.01)
+ return _prepare_group(*args)
+
+ with patch("pypaimon.multimodal.lerobot.ray_loader._require_ray",
+ return_value=MarkingRay()), \
+ patch("pypaimon.multimodal.lerobot.ray_loader._prepare_group",
+ prepare), \
+ patch("pypaimon.multimodal.lerobot.ray_loader._abort_messages",
+ wraps=_abort_messages) as abort:
+ with self.assertRaisesRegex(Exception, "frame_index"):
+ self.conn.load_from_lerobot("broken", self.source,
parallelism=2,
+ tag_name="imported")
+ self.assertTrue(files_before_failure)
+ abort.assert_called_once()
+ self.assertTrue(abort.call_args[0][1])
+ table = self.conn.get_table("broken").raw_table
+ self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+ self.assertFalse(table.tag_manager().tag_exists("imported"))
+ self.assertEqual([], _bucket_files(table))
+ self.assertTrue(all(not path.exists() for path in
files_before_failure))
+
+ def test_writer_close_failure_aborts_prepared_files(self):
+ self._source()
+ for error_type in (RuntimeError, KeyboardInterrupt):
+ with self.subTest(error_type=error_type):
+ table_name = "close_" + error_type.__name__.lower()
+ trace = self.root / (table_name + ".json")
+
+ def prepare(*args):
+ from pypaimon.multimodal.lerobot.ray_loader import
_prepare_group
+ from pypaimon.write.table_write import BatchTableWrite
+
+ original_close = BatchTableWrite.close
+
+ def fail_close(write):
+ original_close(write)
+ trace.write_text(json.dumps(
+ [path.name for path in _bucket_files(args[0])]))
+ raise error_type("injected writer close")
+
+ BatchTableWrite.close = fail_close
+ try:
+ return _prepare_group(*args)
+ finally:
+ BatchTableWrite.close = original_close
+
+ with
patch("pypaimon.multimodal.lerobot.ray_loader._prepare_group",
+ prepare):
+ with self.assertRaisesRegex(BaseException, "injected
writer close"):
+ self.conn.load_from_lerobot(
+ table_name, self.source, parallelism=2)
+ before = json.loads(trace.read_text())
+ self.assertTrue(any(name.endswith(".parquet") for name in
before), before)
+ self.assertTrue(any(".vector." in name for name in before),
before)
+ self.assertTrue(any(name.endswith(".blob") for name in
before), before)
+ table = self.conn.get_table(table_name).raw_table
+
self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+ self.assertEqual([], _bucket_files(table))
+
+ def test_interrupt_during_result_delivery_aborts_ready_task(self):
+ import ray
+ self._source()
+ delivered = []
+
+ class InterruptingRay:
+ def __getattr__(self, name):
+ return getattr(ray, name)
+
+ def get(self, task, **kwargs):
+ result = ray.get(task, **kwargs)
+ if not delivered:
+ delivered.extend(result)
+ raise KeyboardInterrupt("interrupted result delivery")
+ return result
+
+ with patch("pypaimon.multimodal.lerobot.ray_loader._require_ray",
+ return_value=InterruptingRay()):
+ with self.assertRaisesRegex(KeyboardInterrupt, "interrupted result
delivery"):
+ self.conn.load_from_lerobot("interrupted", self.source,
parallelism=2)
+ self.assertTrue(delivered)
+ table = self.conn.get_table("interrupted").raw_table
+ self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+ self.assertEqual([], _bucket_files(table))
+
+ def test_source_close_interrupt_aborts_prepared_files(self):
+ self._source()
+ trace = self.root / "source-close.json"
+
+ def prepare(*args):
+ from contextlib import contextmanager
+ from pypaimon.multimodal.lerobot import ray_loader
+ original = ray_loader._resolved_source
+
+ @contextmanager
+ def interrupted_source(*source_args):
+ with original(*source_args) as source:
+ yield source
+ trace.write_text(json.dumps(
+ [path.name for path in _bucket_files(args[0])]))
+ raise KeyboardInterrupt("interrupted source close")
+
+ with patch.object(ray_loader, "_resolved_source",
interrupted_source):
+ return ray_loader._prepare_group(*args)
+
+ with patch("pypaimon.multimodal.lerobot.ray_loader._prepare_group",
prepare):
+ with self.assertRaisesRegex(BaseException, "interrupted source
close"):
+ self.conn.load_from_lerobot("source_close", self.source,
parallelism=2)
+ self.assertTrue(json.loads(trace.read_text()))
+ table = self.conn.get_table("source_close").raw_table
+ self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+ self.assertEqual([], _bucket_files(table))
+
+ def test_invalid_commit_options_fail_before_preparing(self):
+ self._source()
+ for parallelism in (1, 2):
+ with self.subTest(parallelism=parallelism):
+ name = "invalid_commit_%d" % parallelism
+ with
patch("pypaimon.multimodal.lerobot.api._import_lerobot_dataset",
+ return_value=None), \
+
patch("pypaimon.multimodal.lerobot.loader._prepare_dataset") as prepare, \
+
patch("pypaimon.multimodal.lerobot.ray_loader._prepare_group") as group:
+ with self.assertRaises(ValueError):
+ self.conn.load_from_lerobot(
+ name, self.source.as_uri(),
parallelism=parallelism,
+ options={"commit.max-retries": "typo"})
+ prepare.assert_not_called()
+ group.assert_not_called()
+ table = self.conn.get_table(name).raw_table
+
self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+ self.assertEqual([], _bucket_files(table))
+
+ def test_worker_failure_does_not_wait_for_blocked_task(self):
+ import ray
+ if ray.cluster_resources().get("CPU", 0) < 2:
+ self.skipTest("Two Ray CPUs are required to run both tasks")
+ self._source(data_files=2)
+ started = self.root / "blocked_task_started"
+ triggered = self.root / "failure_triggered"
+ release = self.root / "release_blocked_task"
+ stopped = self.root / "blocked_task_stopped"
+ refs = set()
+
+ class TrackingRay:
+ def __getattr__(self, name):
+ return getattr(ray, name)
+
+ def wait(self, tasks, **kwargs):
+ refs.update(tasks)
+ return ray.wait(tasks, **kwargs)
+
+ def prepare(*args):
+ if args[-1][0] == 0:
+ deadline = time.monotonic() + 30
+ while not started.exists():
+ if time.monotonic() > deadline:
+ raise TimeoutError("Blocked worker did not start.")
+ time.sleep(0.01)
+ triggered.write_text(str(time.monotonic()))
+ raise ValueError("planned worker failure")
+ started.touch()
+ try:
+ deadline = time.monotonic() + 15
+ while not release.exists():
+ if time.monotonic() > deadline:
+ raise TimeoutError("Blocked task was not released.")
+ time.sleep(0.05)
+ return []
+ finally:
+ stopped.touch()
+
+ from pypaimon.multimodal.lerobot.ray_loader import _abort_messages
+ try:
+ with patch("pypaimon.multimodal.lerobot.ray_loader._require_ray",
+ return_value=TrackingRay()), \
+
patch("pypaimon.multimodal.lerobot.ray_loader._prepare_group",
+ prepare), \
+
patch("pypaimon.multimodal.lerobot.ray_loader._abort_messages",
+ wraps=_abort_messages) as abort:
+ with self.assertRaisesRegex(Exception, "planned worker
failure"):
+ self.conn.load_from_lerobot(
+ "blocked", self.source, parallelism=2)
+ elapsed = time.monotonic() - float(triggered.read_text())
+ abort.assert_called_once()
+ self.assertEqual([], abort.call_args[0][1])
+ self.assertLess(elapsed, 6)
+ finally:
+ release.touch()
+ deadline = time.monotonic() + 5
+ while not stopped.exists() and time.monotonic() < deadline:
+ time.sleep(0.01)
+ if not stopped.exists():
+ for ref in refs:
+ ray.cancel(ref, force=True)
+ if refs:
+ ray.wait(list(refs), num_returns=len(refs), timeout=2)
+ self.assertTrue(stopped.exists())