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 d53103a442 [python] Add RoboMIND AgileX HDF5 pipeline (#9445)
d53103a442 is described below
commit d53103a44286aabcb4e3be162cd5f16d43c66bdd
Author: Yann Byron <[email protected]>
AuthorDate: Sat Aug 29 21:04:09 2026 +0800
[python] Add RoboMIND AgileX HDF5 pipeline (#9445)
---
.github/workflows/paimon-python-checks.yml | 1 +
docs/docs/pypaimon/robomind-agilex.md | 134 ++++
paimon-python/conftest.py | 7 +
paimon-python/pypaimon/multimodal/hdf5.py | 75 +-
paimon-python/pypaimon/ray/__init__.py | 2 +
paimon-python/pypaimon/ray/hdf5.py | 146 ++++
paimon-python/pypaimon/ray/ray_paimon.py | 9 +-
paimon-python/pypaimon/sample/robomind_agilex.py | 797 +++++++++++++++++++++
paimon-python/pypaimon/tests/ray_hdf5_test.py | 78 ++
.../tests/robomind_agilex_pipeline_test.py | 520 ++++++++++++++
paimon-python/pypaimon/tests/table_update_test.py | 34 +
paimon-python/pypaimon/write/ray_datasink.py | 81 ++-
paimon-python/pypaimon/write/table_update.py | 29 +-
13 files changed, 1865 insertions(+), 48 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index 5b35fff4f8..a7bdd6aa29 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -141,6 +141,7 @@ jobs:
python -c "import h5py; print('h5py', h5py.__version__)"
if [[ "${{ matrix.python-version }}" == "3.11" ]]; then
+ # Run the RoboMIND pipeline tests with synthetic local HDF5 data.
# Exercise the 0.4 API in one lane until its wheel is published.
python -m pip install
"git+https://github.com/apache/paimon-rust.git@${PYPAIMON_RUST_REV}#subdirectory=bindings/python"
python -m pip install "./paimon-python[sql]"
diff --git a/docs/docs/pypaimon/robomind-agilex.md
b/docs/docs/pypaimon/robomind-agilex.md
new file mode 100644
index 0000000000..e8aa3308d6
--- /dev/null
+++ b/docs/docs/pypaimon/robomind-agilex.md
@@ -0,0 +1,134 @@
+---
+title: "RoboMIND AgileX"
+sidebar_position: 7
+---
+
+<!--
+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.
+-->
+
+# RoboMIND AgileX
+
+The RoboMIND AgileX sample turns a downloaded HDF5 directory into three
+Paimon tables:
+
+- `episodes_agilex` stores episode metadata derived from the dataset layout;
+- `frames_agilex` stores ordered robot state, raw action, RGB, and depth rows;
+- `feature_stats_agilex` versions the train-split statistics consumed by
+ policy training.
+
+The local and Ray paths use the same `RoboMindAgileXEpisodeTransform` and
+`RoboMindAgileXFrameTransform` contracts and table schemas. Ray assigns each
+complete HDF5 file to one transform task, while the Paimon sink performs one
+coordinated commit. Discovery does not open or hash HDF5 contents; validation
+and frame counting happen after a transform task has opened the file.
+
+`split` is RoboMIND dataset metadata derived from the `train` or `val`
+directory component. It is not a required field of every Paimon multimodal
+table. This sample uses successful train episodes to select frame rows for
+normalization statistics.
+
+## Run the local pipeline
+
+After downloading RoboMIND, use Python 3.11 or later, install the HDF5 and
+Vortex extras, and provide the source and warehouse directories to one command:
+
+```bash
+pip install 'pypaimon[hdf5,vortex]'
+python -m pypaimon.sample.robomind_agilex \
+ --input /data/RoboMIND/h5_agilex_3rgb \
+ --warehouse /data/warehouse
+```
+
+The command discovers and validates every `**/data/trajectory.hdf5`, ingests
+the episode and frame tables locally, materializes the canonical action, and
+writes versioned train-split normalization statistics. It prints a JSON result
+with row counts and committed snapshot IDs. The input must already be present
+locally; the command does not download RoboMIND or contact Hugging Face.
+
+Use a new warehouse for each ingestion run. Ingestion is append-only, so
+repeating the same input against existing tables would create duplicate rows.
+Canonical-action backfill is independently retryable after schema creation.
+
+The current Hugging Face example data includes `language_raw` and
+`language_distilbert`, but these datasets are not part of the published AgileX
+HDF5 schema. The episode transform therefore validates and stores them when
+present, and writes null instruction metadata when they are absent.
+
+Pytest generates several small HDF5 episodes with the real AgileX field names,
+shapes, dtypes, split layout, and success layout, so the default test needs no
+download. To exercise a downloaded customer dataset explicitly, run:
+
+```bash
+pytest -q pypaimon/tests/robomind_agilex_pipeline_test.py \
+ --robomind-agilex-input /data/RoboMIND/h5_agilex_3rgb
+```
+
+## Python API
+
+```python
+from pypaimon.sample.robomind_agilex import (
+ backfill_canonical_action,
+ ingest_local,
+ ingest_ray,
+ run_local_pipeline,
+)
+
+# Run local ingestion and canonical-action backfill together.
+pipeline = run_local_pipeline(
+ "/data/RoboMIND/h5_agilex_3rgb",
+ "/data/warehouse",
+)
+
+# Or compose the lower-level operations explicitly. Ray chooses distributed
+# task placement; concurrency is only an optional upper bound.
+ingest = ingest_ray(
+ "/data/RoboMIND/h5_agilex_3rgb",
+ "/data/warehouse",
+ concurrency=8,
+)
+
+backfill = backfill_canonical_action(
+ "/data/warehouse",
+ statistics_version="robomind-agilex-joint-position@1",
+)
+```
+
+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.
+
+The canonical `action` is `float32(concat(master/joint_position_left,
+master/joint_position_right))`. The backfill materializes only this consumed
+14-dimensional column. It does not materialize normalized actions. Instead,
+the stats table stores the train-only population mean and standard deviation,
+the `1e-2` standard-deviation floor, the train split manifest digest, and the
+source `frames_agilex` snapshot. A training reader normalizes `action` at read
+time with that versioned row.
+
+The tables are non-primary-key append tables. Repeating ingestion therefore
+appends duplicate rows by design; it does not mean row-level update/delete is
+disabled. The sample keeps deletion vectors enabled, stores vectors with
+Vortex, and sets `blob-as-descriptor=false` because its transforms emit raw
+image/depth bytes rather than external BLOB descriptors. Parquet data format,
+dynamic bucket mode, and global-index search mode are inherited defaults and
+are not repeated in the sample options.
+
+Run local and Ray modes against separate new warehouses when comparing them.
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index f6adcd31ca..4ea23aa02e 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -24,6 +24,13 @@ _native_plan_count = 0
_force_native_for_test = False
+def pytest_addoption(parser):
+ parser.addoption(
+ "--robomind-agilex-input",
+ help="Downloaded RoboMIND AgileX directory for the optional sample
test.",
+ )
+
+
def _native_plan_enabled():
return os.environ.get(_NATIVE_PLAN_ENV) == "1"
diff --git a/paimon-python/pypaimon/multimodal/hdf5.py
b/paimon-python/pypaimon/multimodal/hdf5.py
index 68389cf4e2..acd85fb5d6 100644
--- a/paimon-python/pypaimon/multimodal/hdf5.py
+++ b/paimon-python/pypaimon/multimodal/hdf5.py
@@ -72,10 +72,14 @@ class Hdf5File:
@dataclass(frozen=True)
class Hdf5LoadResult:
- """Counts and optional snapshot for one ``load_from_hdf5`` call."""
+ """Counts and optional snapshot for one ``load_from_hdf5`` call.
+
+ ``batch_count`` is unavailable for Ray loads because counting lazy output
+ batches would execute the transform a second time.
+ """
file_count: int
- batch_count: int
+ batch_count: Optional[int]
row_count: int
snapshot_id: Optional[int]
@@ -188,33 +192,17 @@ def _load_hdf5_files(table, files, transform,
source_file_io, h5py):
table_commit.add_commit_callback(snapshot_recorder)
for source in files:
- source_row_count = 0
- with closing(source_file_io.new_input_stream(source.path)) as
stream:
- _require_seekable(stream, source)
- with h5py.File(stream, "r") as h5:
- transformed = transform(h5, source)
- batches = None
- try:
- batches = _arrow_batches(transformed)
- for value in batches:
- arrow_table = _strict_arrow_table(
- value,
- target_schema,
- source,
- batch_count,
- )
- batch_count += 1
- row_count += arrow_table.num_rows
- source_row_count += arrow_table.num_rows
- if arrow_table.num_rows:
- table_write.write_arrow(arrow_table)
- finally:
- _close_transform_iterator(
- batches if batches is not None else transformed)
-
- if source_row_count == 0:
- raise ValueError(
- "HDF5 source %s produced no rows." % source.path)
+ for arrow_table in _transform_hdf5_file(
+ source,
+ transform,
+ source_file_io,
+ h5py,
+ target_schema,
+ batch_index=batch_count):
+ batch_count += 1
+ row_count += arrow_table.num_rows
+ if arrow_table.num_rows:
+ table_write.write_arrow(arrow_table)
commit_messages = table_write.prepare_commit()
commit_started = True
@@ -241,6 +229,35 @@ def _load_hdf5_files(table, files, transform,
source_file_io, h5py):
table_commit.close()
+def _transform_hdf5_file(
+ source,
+ transform,
+ source_file_io,
+ h5py,
+ target_schema,
+ *,
+ batch_index=0):
+ """Yield validated Arrow tables for one HDF5 source."""
+ produced_rows = 0
+ with closing(source_file_io.new_input_stream(source.path)) as stream:
+ _require_seekable(stream, source)
+ with h5py.File(stream, "r") as h5:
+ transformed = transform(h5, source)
+ batches = None
+ try:
+ batches = _arrow_batches(transformed)
+ for index, value in enumerate(batches, start=batch_index):
+ arrow_table = _strict_arrow_table(
+ value, target_schema, source, index)
+ produced_rows += arrow_table.num_rows
+ yield arrow_table
+ finally:
+ _close_transform_iterator(
+ batches if batches is not None else transformed)
+ if produced_rows == 0:
+ raise ValueError("HDF5 source %s produced no rows." % source.path)
+
+
def _discover_hdf5_files(paths, source_file_io):
values = _path_values(paths)
normalized = {}
diff --git a/paimon-python/pypaimon/ray/__init__.py
b/paimon-python/pypaimon/ray/__init__.py
index 63141ecd41..e29d845061 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -31,6 +31,7 @@ from pypaimon.ray.data_evolution_merge_transform import (
from pypaimon.ray.update_by_row_id import update_by_row_id
from pypaimon.ray.read_by_row_id import read_by_row_id
from pypaimon.ray.process_row_id_ranges import process_row_id_ranges
+from pypaimon.ray.hdf5 import load_from_hdf5
__all__ = [
"read_paimon",
@@ -42,6 +43,7 @@ __all__ = [
"update_by_row_id",
"read_by_row_id",
"process_row_id_ranges",
+ "load_from_hdf5",
"WhenMatched",
"WhenNotMatched",
"source_col",
diff --git a/paimon-python/pypaimon/ray/hdf5.py
b/paimon-python/pypaimon/ray/hdf5.py
new file mode 100644
index 0000000000..2871c3b885
--- /dev/null
+++ b/paimon-python/pypaimon/ray/hdf5.py
@@ -0,0 +1,146 @@
+# 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.
+
+"""Distributed HDF5 ingestion using the multimodal transform contract."""
+
+from typing import Any, Dict, Mapping, Optional
+
+
+def load_from_hdf5(
+ table_identifier: str,
+ paths,
+ catalog_options: Dict[str, str],
+ *,
+ transform,
+ source_options: Optional[Mapping[str, object]] = None,
+ concurrency: Optional[int] = None,
+ ray_remote_args: Optional[Dict[str, Any]] = None):
+ """Transform complete HDF5 files on Ray and append them in one commit.
+
+ The transform has the same ``(h5py.File, Hdf5File)`` contract as
+ :meth:`MultimodalConnection.load_from_hdf5`. Discovery runs on the driver;
+ workers open and transform complete files, and the Paimon Ray sink commits
+ all worker messages once. The result reports logical output rows and the
+ exact committed snapshot; its batch count is ``None`` because Ray does not
+ expose that count without re-executing the lazy transform.
+ """
+ if not callable(transform):
+ raise ValueError("transform must be callable.")
+
+ from pypaimon.catalog.catalog_factory import CatalogFactory
+ from pypaimon.common.options import Options
+ from pypaimon.multimodal.hdf5 import (
+ Hdf5LoadResult,
+ _Hdf5SourceFileIO,
+ _discover_hdf5_files,
+ _path_values,
+ _validate_source_kerberos,
+ _validated_source_options,
+ )
+ from pypaimon.multimodal.table import _target_schema
+ from pypaimon.ray.ray_paimon import _require_ray_data, write_paimon
+
+ ray_data = _require_ray_data()
+ validated_options = _validated_source_options(source_options)
+ path_values = _path_values(paths)
+ _validate_source_kerberos(path_values, validated_options)
+ source_file_io = _Hdf5SourceFileIO(Options(validated_options))
+ try:
+ files = _discover_hdf5_files(path_values, source_file_io)
+ finally:
+ source_file_io.close()
+ if not files:
+ return Hdf5LoadResult(
+ file_count=0,
+ batch_count=None,
+ row_count=0,
+ snapshot_id=None,
+ )
+
+ table = CatalogFactory.create(catalog_options).get_table(table_identifier)
+ target_schema = _target_schema(table)
+ inputs = ray_data.from_items(
+ [{"path": source.path} for source in files],
+ override_num_blocks=len(files),
+ )
+ transformed = inputs.map_batches(
+ _TransformHdf5File,
+ fn_constructor_kwargs={
+ "transform": transform,
+ "source_options": validated_options,
+ "target_schema": target_schema,
+ },
+ batch_format="pyarrow",
+ batch_size=1,
+ concurrency=concurrency,
+ **dict(ray_remote_args or {}),
+ )
+ write_result = write_paimon(
+ transformed,
+ table_identifier,
+ catalog_options,
+ concurrency=concurrency,
+ ray_remote_args=ray_remote_args,
+ )
+ return Hdf5LoadResult(
+ file_count=len(files),
+ batch_count=None,
+ row_count=0 if write_result is None else write_result.row_count,
+ snapshot_id=(
+ None if write_result is None else write_result.snapshot_id
+ ),
+ )
+
+
+class _TransformHdf5File:
+
+ def __init__(self, *, transform, source_options, target_schema):
+ self.transform = transform
+ self.source_options = source_options
+ self.target_schema = target_schema
+
+ def __call__(self, batch):
+ if batch.num_rows != 1:
+ raise ValueError("Ray HDF5 transform requires one source per
batch.")
+
+ from pypaimon.common.options import Options
+ from pypaimon.multimodal.hdf5 import (
+ Hdf5File,
+ _Hdf5SourceFileIO,
+ _transform_hdf5_file,
+ )
+
+ try:
+ import h5py
+ except ImportError as error:
+ raise ImportError(
+ "load_from_hdf5 requires h5py; install 'pypaimon[ray,hdf5]'."
+ ) from error
+
+ source = Hdf5File(path=batch["path"][0].as_py())
+ source_file_io = _Hdf5SourceFileIO(Options(self.source_options))
+ try:
+ for table in _transform_hdf5_file(
+ source,
+ self.transform,
+ source_file_io,
+ h5py,
+ self.target_schema):
+ if table.num_rows:
+ yield table
+ finally:
+ source_file_io.close()
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py
b/paimon-python/pypaimon/ray/ray_paimon.py
index 2a5bbf0a26..26968d899d 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -33,6 +33,7 @@ from pypaimon.common.predicate import Predicate
if TYPE_CHECKING:
import ray.data
+ from pypaimon.write.ray_datasink import PaimonWriteResult
def _require_ray_data():
@@ -290,7 +291,7 @@ def write_paimon(
concurrency: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
hash_fixed_precluster: str = "auto",
-) -> None:
+) -> "Optional[PaimonWriteResult]":
"""Write a Ray Dataset to a Paimon table.
HASH_FIXED rows are assigned to the correct bucket by the Paimon
@@ -312,6 +313,10 @@ def write_paimon(
hash_fixed_precluster: Pre-clustering mode. ``"auto"`` follows
table options, ``"off"`` disables it, and ``"map_groups"``
explicitly enables HASH_FIXED grouping.
+
+ Returns:
+ Metadata for the exact committed snapshot, or ``None`` when no
+ snapshot was committed.
"""
_require_ray_data()
@@ -321,7 +326,7 @@ def write_paimon(
catalog = CatalogFactory.create(catalog_options)
table = catalog.get_table(table_identifier)
- write_paimon_dataset(
+ return write_paimon_dataset(
dataset,
table,
overwrite=overwrite,
diff --git a/paimon-python/pypaimon/sample/robomind_agilex.py
b/paimon-python/pypaimon/sample/robomind_agilex.py
new file mode 100644
index 0000000000..aa56133e25
--- /dev/null
+++ b/paimon-python/pypaimon/sample/robomind_agilex.py
@@ -0,0 +1,797 @@
+# 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.
+
+"""RoboMIND AgileX HDF5 ingestion and canonical action backfill."""
+
+import argparse
+import hashlib
+import json
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import numpy as np
+import pyarrow as pa
+
+import pypaimon.multimodal as pmm
+
+
+DEFAULT_DATABASE = "robomind"
+EPISODES_TABLE = "episodes_agilex"
+FRAMES_TABLE = "frames_agilex"
+FEATURE_STATS_TABLE = "feature_stats_agilex"
+DEFAULT_STATISTICS_VERSION = "robomind-agilex-joint-position@1"
+
+TABLE_OPTIONS = {
+ "deletion-vectors.enabled": "true",
+ "blob-as-descriptor": "false",
+ "vector.file.format": "vortex",
+}
+
+NUMERIC_FIELDS = (
+ ("state_end_effector_left", "puppet/end_effector_left"),
+ ("state_end_effector_right", "puppet/end_effector_right"),
+ ("state_joint_effort_left", "puppet/joint_effort_left"),
+ ("state_joint_effort_right", "puppet/joint_effort_right"),
+ ("state_joint_position_left", "puppet/joint_position_left"),
+ ("state_joint_position_right", "puppet/joint_position_right"),
+ ("state_joint_velocity_left", "puppet/joint_velocity_left"),
+ ("state_joint_velocity_right", "puppet/joint_velocity_right"),
+ ("action_end_effector_left", "master/end_effector_left"),
+ ("action_end_effector_right", "master/end_effector_right"),
+ ("action_joint_effort_left", "master/joint_effort_left"),
+ ("action_joint_effort_right", "master/joint_effort_right"),
+ ("action_joint_position_left", "master/joint_position_left"),
+ ("action_joint_position_right", "master/joint_position_right"),
+ ("action_joint_velocity_left", "master/joint_velocity_left"),
+ ("action_joint_velocity_right", "master/joint_velocity_right"),
+)
+IMAGE_FIELDS = (
+ ("rgb_front", "observations/rgb_images/camera_front"),
+ ("rgb_left_wrist", "observations/rgb_images/camera_left_wrist"),
+ ("rgb_right_wrist", "observations/rgb_images/camera_right_wrist"),
+ ("depth_front", "observations/depth_images/camera_front"),
+ ("depth_left_wrist", "observations/depth_images/camera_left_wrist"),
+ ("depth_right_wrist", "observations/depth_images/camera_right_wrist"),
+)
+
+_ACTION_LEFT = "action_joint_position_left"
+_ACTION_RIGHT = "action_joint_position_right"
+_ACTION_COLUMN = "action"
+_ACTION_VECTOR_TYPE = pa.list_(pa.float32(), 14)
+_STANDARD_DEVIATION_FLOOR = 1e-2
+
+
+@dataclass(frozen=True)
+class EpisodeSource:
+ """RoboMIND metadata derived without opening the HDF5 source."""
+
+ path: Path
+ source_key: str
+ episode_id: str
+ split: str
+ success: bool
+
+
+@dataclass(frozen=True)
+class IngestResult:
+ """Small control-plane result returned by an AgileX ingestion."""
+
+ mode: str
+ episode_count: int
+ frame_count: int
+ episodes_snapshot_id: int
+ frames_snapshot_id: int
+
+
+@dataclass(frozen=True)
+class BackfillResult:
+ """Result of materializing canonical action and its statistics row."""
+
+ row_count: int
+ frames_snapshot_id: int
+ statistics_snapshot_id: int
+ statistics_version: str
+
+
+@dataclass(frozen=True)
+class LocalPipelineResult:
+ """Result of the complete local ingestion and backfill pipeline."""
+
+ ingest: IngestResult
+ backfill: BackfillResult
+
+
+def episode_schema():
+ """Return the shared AgileX episode business schema."""
+ return pa.schema([
+ pa.field("episode_id", pa.string(), nullable=False),
+ pa.field("source_key", pa.string(), nullable=False),
+ pa.field("split", pa.string(), nullable=False),
+ pa.field("success", pa.bool_(), nullable=False),
+ pa.field("instruction", pa.string()),
+ pa.field("instruction_embedding", pa.list_(pa.float32(), 768)),
+ pa.field("frame_count", pa.int32(), nullable=False),
+ pa.field("hdf5_compress", pa.bool_()),
+ pa.field("hdf5_sim", pa.bool_()),
+ ])
+
+
+def frame_schema():
+ """Return the shared AgileX frame schema before canonical backfill."""
+ fields = [
+ pa.field("episode_id", pa.string(), nullable=False),
+ pa.field("frame_index", pa.int32(), nullable=False),
+ ]
+ fields.extend(
+ pa.field(name, pa.large_binary(), nullable=False)
+ for name, _ in IMAGE_FIELDS
+ )
+ fields.extend(
+ pa.field(name, pa.list_(pa.float64(), 7), nullable=False)
+ for name, _ in NUMERIC_FIELDS
+ )
+ return pa.schema(fields)
+
+
+def backfilled_frame_schema():
+ """Return the frame schema after canonical action is added."""
+ return frame_schema().append(pa.field(_ACTION_COLUMN, _ACTION_VECTOR_TYPE))
+
+
+def feature_stats_schema():
+ """Return the versioned normalization-statistics schema."""
+ return pa.schema([
+ pa.field("statistics_version", pa.string(), nullable=False),
+ pa.field("source_table", pa.string(), nullable=False),
+ pa.field("source_snapshot_id", pa.int64(), nullable=False),
+ pa.field("source_split", pa.string(), nullable=False),
+ pa.field("split_manifest_sha256", pa.string(), nullable=False),
+ pa.field("feature_name", pa.string(), nullable=False),
+ pa.field("frame_count", pa.int64(), nullable=False),
+ pa.field("action_mean", pa.list_(pa.float64(), 14), nullable=False),
+ pa.field("action_std", pa.list_(pa.float64(), 14), nullable=False),
+ pa.field("standard_deviation_floor", pa.float64(), nullable=False),
+ ])
+
+
+def discover_episodes(input_root):
+ """Discover RoboMIND episode paths without reading HDF5 contents."""
+ root = Path(input_root).expanduser().resolve()
+ if not root.is_dir():
+ raise ValueError("RoboMIND input root does not exist: %s" % root)
+ paths = sorted(root.glob("**/data/trajectory.hdf5"))
+ if not paths:
+ raise ValueError("No RoboMIND trajectory.hdf5 files found below %s." %
root)
+
+ episodes = []
+ episode_ids = set()
+ for path in paths:
+ resolved_path = path.resolve()
+ try:
+ resolved_path.relative_to(root)
+ except ValueError:
+ raise ValueError(
+ "RoboMIND trajectory path escapes input root: %s" % path)
+ if path.is_symlink():
+ raise ValueError(
+ "RoboMIND trajectory path must not be a symlink: %s" % path)
+ source_key = path.relative_to(root).as_posix()
+ split = _path_component(source_key, ("train", "val"), "split")
+ status = _path_component(
+ source_key, ("success_episodes", "failed_episodes"), "status")
+ episode_id = path.parent.parent.name
+ if episode_id in episode_ids:
+ raise ValueError("Duplicate RoboMIND episode_id %r." % episode_id)
+ episode_ids.add(episode_id)
+ episodes.append(EpisodeSource(
+ path=resolved_path,
+ source_key=source_key,
+ episode_id=episode_id,
+ split=split,
+ success=status == "success_episodes",
+ ))
+ return episodes
+
+
+class _RoboMindAgileXTransform:
+
+ def __init__(self, episodes):
+ self._episodes = {
+ episode.path: episode for episode in episodes
+ }
+ if not self._episodes:
+ raise ValueError("episodes must not be empty.")
+
+ def _source(self, source):
+ source_path = source.local_path
+ if source_path is None:
+ raise ValueError(
+ "RoboMIND AgileX requires a local HDF5 source: %s"
+ % source.path
+ )
+ source_path = source_path.resolve()
+ episode = self._episodes.get(source_path)
+ if episode is None:
+ raise ValueError("Unknown RoboMIND source path %r." % source_path)
+ return episode
+
+
+class RoboMindAgileXEpisodeTransform(_RoboMindAgileXTransform):
+ """Validate one AgileX file and emit its episode metadata row."""
+
+ def __call__(self, h5, source):
+ episode = self._source(source)
+ frame_count = _validate_source(h5, episode.source_key)
+ yield self._episode_batch(h5, episode, frame_count)
+
+ @staticmethod
+ def _episode_batch(h5, episode, frame_count):
+ instruction = _instruction(h5, episode.source_key)
+ embedding = _instruction_embedding(h5, episode.source_key)
+ return pa.RecordBatch.from_pydict({
+ "episode_id": [episode.episode_id],
+ "source_key": [episode.source_key],
+ "split": [episode.split],
+ "success": [episode.success],
+ "instruction": [instruction],
+ "instruction_embedding": [embedding],
+ "frame_count": [frame_count],
+ "hdf5_compress": [_optional_bool(h5.attrs.get("compress"))],
+ "hdf5_sim": [_optional_bool(h5.attrs.get("sim"))],
+ }, schema=episode_schema())
+
+
+class RoboMindAgileXFrameTransform(_RoboMindAgileXTransform):
+ """Validate one AgileX file and stream its frame rows."""
+
+ def __init__(self, episodes, *, batch_size=64):
+ super().__init__(episodes)
+ self.batch_size = _positive_int(batch_size, "batch_size")
+
+ def __call__(self, h5, source):
+ episode = self._source(source)
+ frame_count = _validate_source(h5, episode.source_key)
+ for begin in range(0, frame_count, self.batch_size):
+ end = min(begin + self.batch_size, frame_count)
+ count = end - begin
+ columns = {
+ "episode_id": [episode.episode_id] * count,
+ "frame_index": np.arange(begin, end, dtype=np.int32),
+ }
+ for name, hdf5_path in IMAGE_FIELDS:
+ columns[name] = [
+ np.asarray(value, dtype=np.uint8).tobytes()
+ for value in h5[hdf5_path][begin:end]
+ ]
+ for name, hdf5_path in NUMERIC_FIELDS:
+ values = np.asarray(h5[hdf5_path][begin:end], dtype=np.float64)
+ if not np.isfinite(values).all():
+ raise ValueError(
+ "%s: /%s contains NaN or Inf."
+ % (episode.source_key, hdf5_path)
+ )
+ columns[name] = pa.FixedSizeListArray.from_arrays(
+ pa.array(values.reshape(-1), type=pa.float64()), 7)
+ yield pa.RecordBatch.from_pydict(columns, schema=frame_schema())
+
+
+def ingest_local(
+ input_root,
+ warehouse,
+ *,
+ database=DEFAULT_DATABASE,
+ batch_size=64):
+ """Ingest AgileX episodes locally through strict ``load_from_hdf5``."""
+ episodes = discover_episodes(input_root)
+ connection, _, _ = _create_tables(warehouse, database)
+ paths = [episode.path for episode in episodes]
+ episode_result = connection.load_from_hdf5(
+ EPISODES_TABLE,
+ paths,
+ transform=RoboMindAgileXEpisodeTransform(episodes),
+ )
+ frame_result = connection.load_from_hdf5(
+ FRAMES_TABLE,
+ paths,
+ transform=RoboMindAgileXFrameTransform(
+ episodes, batch_size=batch_size),
+ )
+ del connection
+ return IngestResult(
+ mode="local",
+ 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 run_local_pipeline(
+ input_root,
+ warehouse,
+ *,
+ database=DEFAULT_DATABASE,
+ batch_size=64,
+ statistics_version=DEFAULT_STATISTICS_VERSION):
+ """Run local AgileX ingestion and canonical-action backfill."""
+ ingest = ingest_local(
+ input_root,
+ warehouse,
+ database=database,
+ batch_size=batch_size,
+ )
+ backfill = backfill_canonical_action(
+ warehouse,
+ database=database,
+ statistics_version=statistics_version,
+ )
+ return LocalPipelineResult(ingest=ingest, backfill=backfill)
+
+
+def ingest_ray(
+ input_root,
+ warehouse,
+ *,
+ database=DEFAULT_DATABASE,
+ batch_size=64,
+ concurrency=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)
+
+ try:
+ import ray
+ except ImportError:
+ raise ImportError(
+ "Ray ingestion requires ray; install pypaimon[ray,hdf5].")
+
+ initialized_here = not ray.is_initialized()
+ if initialized_here:
+ init_args = {
+ "include_dashboard": False,
+ "ignore_reinit_error": True,
+ }
+ if ray_address is None:
+ init_args["num_cpus"] = 2
+ else:
+ init_args["address"] = ray_address
+ ray.init(**init_args)
+ elif ray_address is not None:
+ raise ValueError(
+ "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),
+ 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,
+ )
+ finally:
+ if initialized_here:
+ ray.shutdown()
+
+
+def build_canonical_action_backfill(source):
+ """Build canonical actions keyed by the physical row ID."""
+ required = [_ACTION_LEFT, _ACTION_RIGHT, "_ROW_ID"]
+ missing = [name for name in required if name not in source.column_names]
+ if missing:
+ raise ValueError("Source is missing required columns: %s." % missing)
+ left = np.asarray(source[_ACTION_LEFT].to_pylist(), dtype=np.float64)
+ right = np.asarray(source[_ACTION_RIGHT].to_pylist(), dtype=np.float64)
+ if left.ndim != 2 or left.shape[1:] != (7,):
+ raise ValueError(
+ "%s must have shape (rows, 7), got %s."
+ % (_ACTION_LEFT, left.shape)
+ )
+ if right.shape != left.shape:
+ raise ValueError(
+ "%s must have shape %s, got %s."
+ % (_ACTION_RIGHT, left.shape, right.shape)
+ )
+ action64 = np.concatenate([left, right], axis=1)
+ if not np.isfinite(action64).all():
+ raise ValueError("Canonical action input contains NaN or Inf.")
+ action = action64.astype(np.float32)
+ return pa.table({
+ "_ROW_ID": source["_ROW_ID"],
+ _ACTION_COLUMN: pa.array(action.tolist(), type=_ACTION_VECTOR_TYPE),
+ })
+
+
+def backfill_canonical_action(
+ warehouse,
+ *,
+ database=DEFAULT_DATABASE,
+ statistics_version=DEFAULT_STATISTICS_VERSION):
+ """Run the independently recoverable action and statistics stages."""
+ row_count, frames_snapshot_id = materialize_canonical_action(
+ warehouse, database=database)
+ 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 = pmm.connect(
+ database=database,
+ options={"warehouse": str(Path(warehouse).expanduser().resolve())},
+ )
+ frames_table = connection.get_table(FRAMES_TABLE)
+
+ from pypaimon.schema.data_types import AtomicType, VectorType
+ from pypaimon.schema.schema_change import SchemaChange
+
+ action_type = VectorType(True, AtomicType("FLOAT"), 14)
+ if _validate_backfill_target(frames_table, action_type):
+ connection.catalog.alter_table(
+ frames_table.identifier,
+ [SchemaChange.add_column(
+ _ACTION_COLUMN,
+ action_type,
+ comment=(
+ "Canonical AgileX action: master joint position left "
+ "followed by right."),
+ )],
+ 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
+
+
+def refresh_action_statistics(
+ warehouse,
+ *,
+ database=DEFAULT_DATABASE,
+ statistics_version=DEFAULT_STATISTICS_VERSION):
+ """Stage two: recompute statistics from committed episode/frame tables."""
+ if not isinstance(statistics_version, str) or not statistics_version:
+ raise ValueError("statistics_version must be a non-empty string.")
+ connection = pmm.connect(
+ database=database,
+ options={"warehouse": str(Path(warehouse).expanduser().resolve())},
+ )
+ episodes_table = connection.get_table(EPISODES_TABLE)
+ frames_table = connection.get_table(FRAMES_TABLE)
+ if _ACTION_COLUMN not in frames_table.raw_table.field_names:
+ raise ValueError("Canonical action column does not exist.")
+
+ episode_rows = _read_raw(
+ episodes_table.raw_table, ["episode_id", "split", "success"])
+ train_episode_ids = sorted(
+ row["episode_id"]
+ for row in episode_rows.to_pylist()
+ if row["split"] == "train" and row["success"]
+ )
+ frames_snapshot_id = _snapshot_id(frames_table)
+ statistics = _stream_action_statistics(
+ frames_table.raw_table, train_episode_ids)
+
+ split_manifest_sha256 = hashlib.sha256(
+ "".join("%s\n" % value for value in train_episode_ids)
+ .encode("utf-8")
+ ).hexdigest()
+ stats_table = connection.create_table(
+ FEATURE_STATS_TABLE,
+ schema=feature_stats_schema(),
+ options=TABLE_OPTIONS,
+ ignore_if_exists=True,
+ )
+ stats_table.add(pa.Table.from_pylist([{
+ "statistics_version": statistics_version,
+ "source_table": "%s.%s" % (database, FRAMES_TABLE),
+ "source_snapshot_id": frames_snapshot_id,
+ "source_split": "train",
+ "split_manifest_sha256": split_manifest_sha256,
+ "feature_name": _ACTION_COLUMN,
+ "frame_count": statistics["frame_count"],
+ "action_mean": statistics["action_mean"],
+ "action_std": statistics["action_std"],
+ "standard_deviation_floor": statistics[
+ "standard_deviation_floor"],
+ }], schema=feature_stats_schema()))
+ return _snapshot_id(stats_table)
+
+
+def _create_tables(warehouse, database):
+ connection = pmm.connect(
+ database=database,
+ options={"warehouse": str(Path(warehouse).expanduser().resolve())},
+ )
+ episodes_table = connection.create_table(
+ EPISODES_TABLE, schema=episode_schema(), options=TABLE_OPTIONS)
+ frames_table = connection.create_table(
+ FRAMES_TABLE, schema=frame_schema(), options=TABLE_OPTIONS)
+ return connection, episodes_table, frames_table
+
+
+def _validate_backfill_target(frames_table, action_type):
+ missing = [
+ name for name in (_ACTION_LEFT, _ACTION_RIGHT)
+ if name not in frames_table.raw_table.field_names
+ ]
+ if missing:
+ raise ValueError("Frames table is missing raw action columns: %s." %
missing)
+ action_field = next(
+ (field for field in frames_table.raw_table.table_schema.fields
+ if field.name == _ACTION_COLUMN),
+ None,
+ )
+ if action_field is None:
+ return True
+ if action_field.type != action_type:
+ raise ValueError(
+ "Canonical action column has incompatible type: %s."
+ % action_field.type)
+ return False
+
+
+def _update_canonical_action_batches(table):
+ """Transform one planned Paimon split at a time and commit all updates
once."""
+ builder = table.new_batch_write_builder()
+ commit = builder.new_commit()
+ row_count = 0
+
+ def updates():
+ nonlocal row_count
+ for source in _iter_raw(
+ table, [_ACTION_LEFT, _ACTION_RIGHT, "_ROW_ID"]):
+ if source.num_rows == 0:
+ continue
+ update = build_canonical_action_backfill(source)
+ row_count += len(update)
+ yield update
+
+ try:
+ update_batches = updates()
+ first = next(update_batches, None)
+ if first is None:
+ messages = []
+ else:
+ def all_updates():
+ yield first
+ yield from update_batches
+
+ messages = (
+ builder.new_update()
+ .with_update_type([_ACTION_COLUMN])
+ .update_by_arrow_batches_with_row_id(all_updates())
+ )
+ commit.commit(messages)
+ finally:
+ commit.close()
+ return row_count
+
+
+def _stream_action_statistics(table, train_episode_ids):
+ """Accumulate fixed-size count, running mean, and M2 on the driver."""
+ train_ids = set(train_episode_ids)
+ if not train_ids:
+ raise ValueError("Cannot compute action statistics without train
episodes.")
+ count = 0
+ mean = np.zeros(14, dtype=np.float64)
+ m2 = np.zeros(14, dtype=np.float64)
+ for source in _iter_raw(table, ["episode_id", _ACTION_COLUMN]):
+ selected = [
+ index for index, value in
enumerate(source["episode_id"].to_pylist())
+ if value in train_ids
+ ]
+ if not selected:
+ continue
+ action = np.asarray(
+ source[_ACTION_COLUMN].take(pa.array(selected)).to_pylist(),
+ dtype=np.float64,
+ )
+ batch_count = len(action)
+ batch_mean = action.mean(axis=0)
+ batch_m2 = np.square(action - batch_mean).sum(axis=0)
+ delta = batch_mean - mean
+ combined_count = count + batch_count
+ mean += delta * batch_count / combined_count
+ m2 += (
+ batch_m2
+ + np.square(delta) * count * batch_count / combined_count
+ )
+ count = combined_count
+ if count == 0:
+ raise ValueError("No frame rows belong to the train episodes.")
+ variance = np.maximum(m2 / count, 0.0)
+ return {
+ "frame_count": count,
+ "action_mean": mean.tolist(),
+ "action_std": np.maximum(
+ np.sqrt(variance), _STANDARD_DEVIATION_FLOOR).tolist(),
+ "standard_deviation_floor": _STANDARD_DEVIATION_FLOOR,
+ }
+
+
+def _iter_raw(table, columns):
+ builder = table.new_read_builder().with_projection(columns)
+ read = builder.new_read()
+ for split in builder.new_scan().plan().splits():
+ yield read.to_arrow([split])
+
+
+def _read_raw(table, columns):
+ builder = table.new_read_builder().with_projection(columns)
+ plan = builder.new_scan().plan()
+ return builder.new_read().to_arrow(plan.splits())
+
+
+def _snapshot_id(table):
+ raw_table = table.raw_table if hasattr(table, "raw_table") else table
+ snapshot = raw_table.snapshot_manager().get_latest_snapshot()
+ if snapshot is None:
+ raise RuntimeError("Expected a committed Paimon snapshot.")
+ return snapshot.id
+
+
+def _validate_source(h5, source_key):
+ lengths = set()
+ for _, hdf5_path in NUMERIC_FIELDS:
+ if hdf5_path not in h5 or h5[hdf5_path].shape[1:] != (7,):
+ raise ValueError(
+ "%s: invalid /%s shape." % (source_key, hdf5_path))
+ if h5[hdf5_path].dtype != np.dtype("float64"):
+ raise ValueError(
+ "%s: invalid /%s dtype." % (source_key, hdf5_path))
+ lengths.add(int(h5[hdf5_path].shape[0]))
+ for _, hdf5_path in IMAGE_FIELDS:
+ if hdf5_path not in h5 or len(h5[hdf5_path].shape) != 1:
+ raise ValueError(
+ "%s: invalid /%s shape." % (source_key, hdf5_path))
+ lengths.add(int(h5[hdf5_path].shape[0]))
+ if len(lengths) != 1:
+ raise ValueError("%s: frame lengths differ." % source_key)
+ frame_count = lengths.pop()
+ if frame_count <= 0:
+ raise ValueError("%s: episode has no frames." % source_key)
+ _instruction(h5, source_key)
+ _instruction_embedding(h5, source_key)
+ return frame_count
+
+
+def _instruction(h5, source_key):
+ if "language_raw" not in h5:
+ return None
+ if h5["language_raw"].shape != (1,):
+ raise ValueError("%s: invalid /language_raw shape." % source_key)
+ value = h5["language_raw"][0]
+ if isinstance(value, bytes):
+ return value.decode("utf-8")
+ if isinstance(value, str):
+ return value
+ raise ValueError("%s: /language_raw is not UTF-8 text." % source_key)
+
+
+def _instruction_embedding(h5, source_key):
+ if "language_distilbert" not in h5:
+ return None
+ if h5["language_distilbert"].shape != (1, 1, 768):
+ raise ValueError(
+ "%s: invalid /language_distilbert shape." % source_key)
+ values = np.asarray(h5["language_distilbert"][0, 0], dtype=np.float32)
+ if not np.isfinite(values).all():
+ raise ValueError("%s: language embedding contains NaN or Inf." %
source_key)
+ return values.tolist()
+
+
+def _path_component(source_key, candidates, label):
+ matches = [value for value in Path(source_key).parts if value in
candidates]
+ if len(matches) != 1:
+ raise ValueError(
+ "Cannot derive RoboMIND %s from %s." % (label, source_key))
+ return matches[0]
+
+
+def _optional_bool(value):
+ return None if value is None else bool(value)
+
+
+def _positive_int(value, name):
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ raise ValueError("%s must be a positive int." % name)
+ return value
+
+
+def _pipeline_summary(result):
+ """Return the fixed-size control-plane result printed by the CLI."""
+ return {
+ "ingest": {
+ "mode": result.ingest.mode,
+ "episode_count": result.ingest.episode_count,
+ "frame_count": result.ingest.frame_count,
+ "episodes_snapshot_id": result.ingest.episodes_snapshot_id,
+ "frames_snapshot_id": result.ingest.frames_snapshot_id,
+ },
+ "backfill": asdict(result.backfill),
+ }
+
+
+def main(argv=None):
+ """Run the complete local RoboMIND AgileX pipeline from the command
line."""
+ parser = argparse.ArgumentParser(
+ description=(
+ "Ingest a downloaded RoboMIND AgileX HDF5 directory and "
+ "materialize canonical actions and normalization statistics."
+ )
+ )
+ parser.add_argument(
+ "--input", required=True, metavar="DIRECTORY",
+ help="downloaded RoboMIND AgileX HDF5 root",
+ )
+ parser.add_argument(
+ "--warehouse", required=True, metavar="DIRECTORY",
+ help="new local Paimon warehouse directory",
+ )
+ parser.add_argument(
+ "--database", default=DEFAULT_DATABASE,
+ help="Paimon database name (default: %(default)s)",
+ )
+ parser.add_argument(
+ "--batch-size", default=64, type=int, metavar="ROWS",
+ help="frame rows per transform batch (default: %(default)s)",
+ )
+ parser.add_argument(
+ "--statistics-version", default=DEFAULT_STATISTICS_VERSION,
+ help="version stored with train-split action statistics",
+ )
+ args = parser.parse_args(argv)
+ result = run_local_pipeline(
+ args.input,
+ args.warehouse,
+ database=args.database,
+ batch_size=args.batch_size,
+ statistics_version=args.statistics_version,
+ )
+ print(json.dumps(_pipeline_summary(result), sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/paimon-python/pypaimon/tests/ray_hdf5_test.py
b/paimon-python/pypaimon/tests/ray_hdf5_test.py
new file mode 100644
index 0000000000..3aac6f79d9
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_hdf5_test.py
@@ -0,0 +1,78 @@
+# 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 patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.ray.hdf5 import _TransformHdf5File
+
+
+h5py = pytest.importorskip("h5py")
+
+
+def test_ray_hdf5_worker_uses_shared_file_transform():
+ schema = pa.schema([pa.field("value", pa.int64(), nullable=False)])
+ expected = pa.table({"value": [1]}, schema=schema)
+ transform = object()
+ worker = _TransformHdf5File(
+ transform=transform,
+ source_options={},
+ target_schema=schema,
+ )
+
+ with patch(
+ "pypaimon.multimodal.hdf5._transform_hdf5_file",
+ return_value=iter([expected])) as shared_transform:
+ actual = list(worker(pa.table({"path": ["file:///tmp/source.h5"]})))
+
+ assert len(actual) == 1
+ assert actual[0].equals(expected)
+ args = shared_transform.call_args.args
+ assert args[0].path == "file:///tmp/source.h5"
+ assert args[1] is transform
+ assert args[4] == schema
+
+
+def test_ray_hdf5_worker_requires_one_source_per_batch():
+ worker = _TransformHdf5File(
+ transform=object(),
+ source_options={},
+ target_schema=pa.schema([pa.field("value", pa.int64())]),
+ )
+
+ with pytest.raises(ValueError, match="requires one source per batch"):
+ list(worker(pa.table({"path": ["a.h5", "b.h5"]})))
+
+
+def test_ray_hdf5_worker_filters_empty_transform_batches():
+ schema = pa.schema([pa.field("value", pa.int64(), nullable=False)])
+ empty = pa.table({"value": pa.array([], type=pa.int64())}, schema=schema)
+ expected = pa.table({"value": [1]}, schema=schema)
+ worker = _TransformHdf5File(
+ transform=object(),
+ source_options={},
+ target_schema=schema,
+ )
+
+ with patch(
+ "pypaimon.multimodal.hdf5._transform_hdf5_file",
+ return_value=iter([empty, expected, empty])):
+ actual = list(worker(pa.table({"path": ["file:///tmp/source.h5"]})))
+
+ assert len(actual) == 1
+ assert actual[0].equals(expected)
diff --git a/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
b/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
new file mode 100644
index 0000000000..92013a9017
--- /dev/null
+++ b/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
@@ -0,0 +1,520 @@
+# 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 json
+import subprocess
+import sys
+from unittest.mock import MagicMock
+
+import numpy as np
+import pyarrow as pa
+import pytest
+
+import pypaimon.multimodal as pmm
+from pypaimon.sample import robomind_agilex as agilex
+
+
+h5py = pytest.importorskip("h5py")
+
+requires_vortex = pytest.mark.skipif(
+ importlib.util.find_spec("vortex") is None,
+ reason="RoboMIND ingestion uses Vortex, which requires Python >= 3.11",
+)
+
+
+_NUMERIC_PATHS = [path for _, path in agilex.NUMERIC_FIELDS]
+_IMAGE_PATHS = [path for _, path in agilex.IMAGE_FIELDS]
+
+
+def _write_episode(
+ root, split, name, offset, frames=3, status="success_episodes"):
+ path = (root / "13_packbowl" / status / split / name
+ / "data" / "trajectory.hdf5")
+ path.parent.mkdir(parents=True)
+ with h5py.File(path, "w") as h5:
+ h5.attrs["compress"] = True
+ h5.attrs["sim"] = False
+ h5.create_dataset("language_raw", data=[b"pack the bowl"])
+ h5.create_dataset(
+ "language_distilbert",
+ data=np.full((1, 1, 768), offset, dtype=np.float16),
+ )
+ for index, hdf5_path in enumerate(_NUMERIC_PATHS):
+ values = np.arange(frames * 7, dtype=np.float64).reshape(frames, 7)
+ h5.create_dataset(hdf5_path, data=values + offset + index * 100)
+ variable = h5py.vlen_dtype(np.dtype("uint8"))
+ for index, hdf5_path in enumerate(_IMAGE_PATHS):
+ dataset = h5.create_dataset(hdf5_path, (frames,), dtype=variable)
+ for frame_index in range(frames):
+ payload = "%s:%s:%s" % (name, index, frame_index)
+ dataset[frame_index] = np.frombuffer(
+ payload.encode("utf-8"), dtype=np.uint8)
+ return path
+
+
[email protected]
+def agilex_input(tmp_path):
+ paths = [
+ _write_episode(tmp_path, "train", "train-a", 0),
+ _write_episode(tmp_path, "train", "train-b", 10),
+ _write_episode(tmp_path, "val", "val-a", 20),
+ _write_episode(
+ tmp_path, "val", "val-b", 30, status="failed_episodes"),
+ ]
+ return tmp_path / "13_packbowl", paths
+
+
[email protected]
+def customer_agilex_input(request):
+ value = request.config.getoption("--robomind-agilex-input")
+ if not value:
+ pytest.skip("use --robomind-agilex-input to test downloaded data")
+ return value
+
+
+@requires_vortex
+def test_explicit_customer_input_uses_downloaded_episodes(
+ customer_agilex_input, tmp_path):
+ episodes = agilex.discover_episodes(customer_agilex_input)
+ result = agilex.ingest_local(
+ customer_agilex_input, tmp_path / "customer-warehouse")
+ assert result.episode_count == len(episodes)
+ assert result.frame_count > 0
+
+
+def _read(warehouse, table_name, columns=None):
+ connection = pmm.connect(
+ database=agilex.DEFAULT_DATABASE,
+ options={"warehouse": str(warehouse)},
+ )
+ table = connection.get_table(table_name)
+ query = table.scan()
+ if columns is not None:
+ query = query.select(columns)
+ return table, query.to_arrow()
+
+
+def _logical_rows(warehouse, table_name, schema):
+ _, rows = _read(warehouse, table_name, schema.names)
+ if "frame_index" in schema.names:
+ sort_keys = [
+ ("episode_id", "ascending"),
+ ("frame_index", "ascending"),
+ ]
+ elif "episode_id" in schema.names:
+ sort_keys = [("episode_id", "ascending")]
+ else:
+ sort_keys = [("statistics_version", "ascending")]
+ return rows.sort_by(sort_keys)
+
+
+def _contains_payload(value):
+ if isinstance(value, (bytes, pa.Table, pa.RecordBatch, pa.Array)):
+ return True
+ if isinstance(value, dict):
+ return any(_contains_payload(item) for item in value.values())
+ if isinstance(value, (list, tuple)):
+ return any(_contains_payload(item) for item in value)
+ return False
+
+
+def test_shared_transform_streams_complete_agilex_business_schema(
+ agilex_input):
+ root, paths = agilex_input
+ episodes = agilex.discover_episodes(root)
+ transform = agilex.RoboMindAgileXFrameTransform(
+ episodes, batch_size=2)
+ source = pmm.Hdf5File(path=paths[0].as_uri())
+
+ with h5py.File(paths[0], "r") as h5:
+ batches = list(transform(h5, source))
+
+ assert [batch.num_rows for batch in batches] == [2, 1]
+ assert all(batch.schema == agilex.frame_schema() for batch in batches)
+ frames = pa.Table.from_batches(batches)
+ assert frames["episode_id"].to_pylist() == ["train-a"] * 3
+ assert frames["frame_index"].to_pylist() == [0, 1, 2]
+ assert frames["rgb_front"][0].as_py() == b"train-a:0:0"
+ assert frames["depth_right_wrist"][2].as_py() == b"train-a:5:2"
+ assert frames["action_joint_position_left"][1].as_py() == [
+ float(value) for value in range(1207, 1214)
+ ]
+
+
+def _consume_frame_transform(root, path):
+ episodes = agilex.discover_episodes(root)
+ transform = agilex.RoboMindAgileXFrameTransform(episodes)
+ with h5py.File(path, "r") as h5:
+ return list(transform(h5, pmm.Hdf5File(path=path.as_uri())))
+
+
+def test_episode_transform_accepts_published_shape_without_language(tmp_path):
+ path = _write_episode(tmp_path, "train", "no-language", 0)
+ with h5py.File(path, "r+") as h5:
+ del h5["language_raw"]
+ del h5["language_distilbert"]
+
+ episodes = agilex.discover_episodes(tmp_path / "13_packbowl")
+ transform = agilex.RoboMindAgileXEpisodeTransform(episodes)
+ with h5py.File(path, "r") as h5:
+ batches = list(
+ transform(h5, pmm.Hdf5File(path=path.as_uri())))
+
+ assert len(batches) == 1
+ assert batches[0]["instruction"].to_pylist() == [None]
+ assert batches[0]["instruction_embedding"].to_pylist() == [None]
+
+
+def test_discover_rejects_duplicate_episode_ids(tmp_path):
+ _write_episode(tmp_path / "task-a", "train", "duplicate", 0)
+ _write_episode(tmp_path / "task-b", "val", "duplicate", 10)
+
+ with pytest.raises(ValueError, match="Duplicate RoboMIND episode_id"):
+ agilex.discover_episodes(tmp_path)
+
+
+def test_discover_rejects_trajectory_symlink_outside_root(tmp_path):
+ target = _write_episode(tmp_path / "outside", "train", "target", 0)
+ root = tmp_path / "input"
+ link = (root / "success_episodes" / "train" / "linked"
+ / "data" / "trajectory.hdf5")
+ link.parent.mkdir(parents=True)
+ link.symlink_to(target)
+
+ with pytest.raises(ValueError, match="escapes input root"):
+ agilex.discover_episodes(root)
+
+
[email protected]("invalid_value", [np.nan, np.inf])
+def test_frame_transform_rejects_non_finite_numeric_values(
+ tmp_path, invalid_value):
+ path = _write_episode(tmp_path, "train", "invalid-action", 0)
+ with h5py.File(path, "r+") as h5:
+ h5["master/joint_position_left"][0, 0] = invalid_value
+
+ with pytest.raises(ValueError, match="contains NaN or Inf"):
+ _consume_frame_transform(tmp_path / "13_packbowl", path)
+
+
+def test_transform_rejects_invalid_numeric_dtype(tmp_path):
+ path = _write_episode(tmp_path, "train", "invalid-dtype", 0)
+ numeric_path = _NUMERIC_PATHS[0]
+ with h5py.File(path, "r+") as h5:
+ values = h5[numeric_path][...].astype(np.float32)
+ del h5[numeric_path]
+ h5.create_dataset(numeric_path, data=values)
+
+ with pytest.raises(ValueError, match="invalid /.+ dtype"):
+ _consume_frame_transform(tmp_path / "13_packbowl", path)
+
+
+def test_transform_rejects_missing_camera(tmp_path):
+ path = _write_episode(tmp_path, "train", "missing-camera", 0)
+ with h5py.File(path, "r+") as h5:
+ del h5[_IMAGE_PATHS[0]]
+
+ with pytest.raises(ValueError, match="invalid /observations/rgb_images"):
+ _consume_frame_transform(tmp_path / "13_packbowl", path)
+
+
+def test_transform_rejects_different_frame_lengths(tmp_path):
+ path = _write_episode(tmp_path, "train", "length-mismatch", 0)
+ image_path = _IMAGE_PATHS[0]
+ with h5py.File(path, "r+") as h5:
+ del h5[image_path]
+ h5.create_dataset(image_path, (2,), dtype=h5py.vlen_dtype(np.uint8))
+
+ with pytest.raises(ValueError, match="frame lengths differ"):
+ _consume_frame_transform(tmp_path / "13_packbowl", path)
+
+
+def test_transform_rejects_empty_episode_but_accepts_one_frame(tmp_path):
+ empty = _write_episode(tmp_path, "train", "empty", 0, frames=0)
+ with pytest.raises(ValueError, match="episode has no frames"):
+ _consume_frame_transform(tmp_path / "13_packbowl", empty)
+
+ one = _write_episode(tmp_path, "train", "one", 1, frames=1)
+ batches = _consume_frame_transform(tmp_path / "13_packbowl", one)
+ assert [batch.num_rows for batch in batches] == [1]
+
+
+@requires_vortex
+def test_local_ingest_and_backfill_materialize_only_canonical_action(
+ agilex_input, tmp_path):
+ root, paths = agilex_input
+ warehouse = tmp_path / "local-warehouse"
+
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-m", "pypaimon.sample.robomind_agilex",
+ "--input", str(root),
+ "--warehouse", str(warehouse),
+ "--batch-size", "2",
+ "--statistics-version", "synthetic-actions@1",
+ ],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ universal_newlines=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+ result = json.loads(completed.stdout)
+ ingest = result["ingest"]
+ backfill = result["backfill"]
+
+ assert ingest["mode"] == "local"
+ assert ingest["episode_count"] == len(paths)
+ assert ingest["frame_count"] == 12
+ assert set(ingest) == {
+ "mode",
+ "episode_count",
+ "frame_count",
+ "episodes_snapshot_id",
+ "frames_snapshot_id",
+ }
+ assert not _contains_payload(result)
+ assert backfill["row_count"] == 12
+ assert backfill["statistics_version"] == "synthetic-actions@1"
+
+ episodes, episode_rows = _read(
+ warehouse, agilex.EPISODES_TABLE, agilex.episode_schema().names)
+ frames, frame_rows = _read(warehouse, agilex.FRAMES_TABLE)
+ stats, stats_rows = _read(warehouse, agilex.FEATURE_STATS_TABLE)
+ assert episode_rows.num_rows == 4
+ assert set(episode_rows["split"].to_pylist()) == {"train", "val"}
+ assert set(episode_rows["success"].to_pylist()) == {True, False}
+ for table in (episodes, frames, stats):
+ options = table.raw_table.table_schema.options
+ assert options["deletion-vectors.enabled"] == "true"
+ assert options["vector.file.format"] == "vortex"
+ assert options["blob-as-descriptor"] == "false"
+ assert "file.format" not in agilex.TABLE_OPTIONS
+ assert "action" in frames.raw_table.field_names
+ assert "act_action_normalized" not in frames.raw_table.field_names
+ assert frame_rows.num_rows == 12
+ frame_keys = list(zip(
+ frame_rows["episode_id"].to_pylist(),
+ frame_rows["frame_index"].to_pylist(),
+ ))
+ assert len(frame_keys) == len(set(frame_keys)) == 12
+
+ ordered = frame_rows.select([
+ "episode_id",
+ "frame_index",
+ "action_joint_position_left",
+ "action_joint_position_right",
+ "action",
+ ]).sort_by([
+ ("episode_id", "ascending"),
+ ("frame_index", "ascending"),
+ ])
+ for row in ordered.to_pylist():
+ expected = np.asarray(
+ row["action_joint_position_left"]
+ + row["action_joint_position_right"], dtype=np.float32)
+ assert np.array_equal(np.asarray(row["action"], dtype=np.float32),
expected)
+
+ assert stats_rows.num_rows == 1
+ stats_row = stats_rows.to_pylist()[0]
+ assert stats_row["statistics_version"] == "synthetic-actions@1"
+ assert stats_row["source_snapshot_id"] == backfill["frames_snapshot_id"]
+ assert stats_row["source_split"] == "train"
+ assert stats_row["frame_count"] == 6
+ assert stats_row["standard_deviation_floor"] == 0.01
+ expected_mean = np.concatenate([
+ np.arange(1212, 1219),
+ np.arange(1312, 1319),
+ ])
+ expected_std = np.full(14, np.sqrt(173.0 / 3.0))
+ np.testing.assert_allclose(
+ stats_row["action_mean"], expected_mean, rtol=0, atol=1e-12)
+ np.testing.assert_allclose(
+ stats_row["action_std"], expected_std, rtol=1e-12, atol=1e-12)
+
+ refreshed_snapshot = agilex.refresh_action_statistics(
+ warehouse, statistics_version="synthetic-actions-refresh@1")
+ assert refreshed_snapshot > backfill["statistics_snapshot_id"]
+
+
+@requires_vortex
+def test_canonical_action_backfill_resumes_after_schema_change(
+ agilex_input, tmp_path, monkeypatch):
+ root, _ = agilex_input
+ warehouse = tmp_path / "retry-warehouse"
+ agilex.ingest_local(root, warehouse)
+
+ original_update = agilex._update_canonical_action_batches
+
+ def fail_after_alter(table):
+ raise RuntimeError("injected update failure")
+
+ monkeypatch.setattr(
+ agilex, "_update_canonical_action_batches", fail_after_alter)
+ with pytest.raises(RuntimeError, match="injected update failure"):
+ agilex.materialize_canonical_action(warehouse)
+
+ monkeypatch.setattr(
+ agilex, "_update_canonical_action_batches", original_update)
+ row_count, snapshot_id = agilex.materialize_canonical_action(warehouse)
+
+ frames, rows = _read(
+ warehouse,
+ agilex.FRAMES_TABLE,
+ ["action_joint_position_left", "action_joint_position_right",
"action"],
+ )
+ assert row_count == rows.num_rows == 12
+ assert snapshot_id == agilex._snapshot_id(frames)
+ for row in rows.to_pylist():
+ expected = np.asarray(
+ row["action_joint_position_left"]
+ + row["action_joint_position_right"], dtype=np.float32)
+ np.testing.assert_array_equal(row["action"], expected)
+
+
+@requires_vortex
+def test_ray_ingest_matches_local_schema_rows_and_backfill(
+ agilex_input, tmp_path):
+ ray = pytest.importorskip("ray")
+
+ root, paths = agilex_input
+ local_warehouse = tmp_path / "local-comparison"
+ ray_warehouse = tmp_path / "ray-comparison"
+ agilex.ingest_local(root, local_warehouse, batch_size=2)
+ 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()
+ 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
+ assert ray_backfill.row_count == local_backfill.row_count == 12
+
+ for table_name, schema in (
+ (agilex.EPISODES_TABLE, agilex.episode_schema()),
+ (agilex.FRAMES_TABLE, agilex.backfilled_frame_schema()),
+ (agilex.FEATURE_STATS_TABLE, agilex.feature_stats_schema())):
+ local_table, _ = _read(local_warehouse, table_name)
+ ray_table, _ = _read(ray_warehouse, table_name)
+ assert local_table.raw_table.table_schema.options == (
+ ray_table.raw_table.table_schema.options)
+ assert _logical_rows(local_warehouse, table_name, schema).equals(
+ _logical_rows(ray_warehouse, table_name, schema))
+
+
+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)
+ feature = np.arange(14, dtype=np.float64).reshape(1, -1)
+ values = (
+ 1e6 + row * 0.25 + feature * 0.5
+ + ((row % 7) - 3) * 0.125
+ ).astype(np.float32)
+ source = pa.table({
+ "episode_id": ["train-a"] * frame_count,
+ "action": pa.array(values.tolist(), type=pa.list_(pa.float32(), 14)),
+ })
+ reversed_source = source.take(
+ pa.array(np.arange(frame_count - 1, -1, -1), type=pa.int64()))
+
+ def statistics(batches):
+ monkeypatch.setattr(
+ agilex, "_iter_raw", lambda table, columns: iter(batches))
+ return agilex._stream_action_statistics(object(), ["train-a"])
+
+ forward = statistics([source.slice(0, 100), source.slice(100)])
+ reverse = statistics([
+ reversed_source.slice(0, 57),
+ reversed_source.slice(57, 100),
+ reversed_source.slice(157),
+ ])
+ expected_mean = values.astype(np.float64).mean(axis=0)
+ expected_std = values.astype(np.float64).std(axis=0)
+
+ for actual in (forward, reverse):
+ assert actual["frame_count"] == frame_count
+ np.testing.assert_allclose(
+ actual["action_mean"], expected_mean, rtol=0, atol=1e-9)
+ np.testing.assert_allclose(
+ actual["action_std"], expected_std, rtol=0, atol=1e-9)
+
+
+def test_canonical_action_update_skips_empty_planned_split(monkeypatch):
+ empty = pa.table({
+ "action_joint_position_left": pa.array([], type=pa.list_(pa.float64(),
7)),
+ "action_joint_position_right": pa.array([],
type=pa.list_(pa.float64(), 7)),
+ "_ROW_ID": pa.array([], type=pa.int64()),
+ })
+ table = MagicMock()
+ builder = table.new_batch_write_builder.return_value
+ commit = builder.new_commit.return_value
+ monkeypatch.setattr(
+ agilex, "_iter_raw", lambda raw_table, columns: iter([empty]))
+
+ assert agilex._update_canonical_action_batches(table) == 0
+
+ builder.new_update.assert_not_called()
+ commit.commit.assert_called_once_with([])
+ commit.close.assert_called_once_with()
+
+
+def test_canonical_action_update_reuses_one_row_id_updater(monkeypatch):
+ def source(row_id, offset):
+ return pa.table({
+ "action_joint_position_left": pa.array(
+ [[offset + value for value in range(7)]],
+ type=pa.list_(pa.float64(), 7),
+ ),
+ "action_joint_position_right": pa.array(
+ [[offset + value for value in range(7, 14)]],
+ type=pa.list_(pa.float64(), 7),
+ ),
+ "_ROW_ID": pa.array([row_id], type=pa.int64()),
+ })
+
+ sources = [source(0, 0), source(1, 100)]
+ table = MagicMock()
+ builder = table.new_batch_write_builder.return_value
+ update = builder.new_update.return_value
+ update.with_update_type.return_value = update
+ captured = []
+
+ def update_batches(batches):
+ captured.extend(batches)
+ return ["message"]
+
+ update.update_by_arrow_batches_with_row_id.side_effect = update_batches
+ monkeypatch.setattr(
+ agilex, "_iter_raw", lambda raw_table, columns: iter(sources))
+
+ assert agilex._update_canonical_action_batches(table) == 2
+
+ builder.new_update.assert_called_once_with()
+ assert len(captured) == 2
+ assert captured[0]["_ROW_ID"].to_pylist() == [0]
+ assert captured[1]["_ROW_ID"].to_pylist() == [1]
+ builder.new_commit.return_value.commit.assert_called_once_with(["message"])
diff --git a/paimon-python/pypaimon/tests/table_update_test.py
b/paimon-python/pypaimon/tests/table_update_test.py
index 8c8a7d00b8..9b183be85d 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -31,12 +31,46 @@ from pypaimon.tests.data_evolution_test_helpers import (
DataEvolutionTestBase,
StreamModeMixin,
)
+from pypaimon.write.table_update import BatchTableUpdate
# ======================================================================
# Shared base for batch & stream table-update tests
# ======================================================================
+
+def test_batch_row_id_update_batches_reuse_file_index():
+ table = mock.MagicMock()
+ table.field_names = ["value"]
+ batches = [
+ pa.table({"_ROW_ID": [0], "value": [10]}),
+ pa.table({"_ROW_ID": [1], "value": [20]}),
+ ]
+
+ with mock.patch(
+ "pypaimon.write.table_update.TableUpdateByRowId") as factory:
+ updater = factory.return_value
+ updater.commit_messages = []
+
+ def update_columns(batch, columns):
+ updater.commit_messages.append((batch, columns))
+ return updater.commit_messages
+
+ updater.update_columns.side_effect = update_columns
+ messages = (
+ BatchTableUpdate(table, "user")
+ .with_update_type(["value"])
+ .update_by_arrow_batches_with_row_id(iter(batches))
+ )
+
+ factory.assert_called_once()
+ assert updater.update_columns.call_count == 2
+ assert messages == [
+ (batches[0], ["value"]),
+ (batches[1], ["value"]),
+ ]
+
+
class _TableUpdateTestBase(DataEvolutionTestBase):
"""Shared tests for ``TableUpdate.update_by_arrow_with_row_id``.
diff --git a/paimon-python/pypaimon/write/ray_datasink.py
b/paimon-python/pypaimon/write/ray_datasink.py
index e121bd2432..baf8d49597 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -21,6 +21,7 @@ Module to write a Paimon table from a Ray Dataset, by using
the Ray Datasink API
import logging
import traceback
+from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
from ray.data.datasource.datasink import Datasink
@@ -30,6 +31,8 @@ from ray.data.block import BlockAccessor, Block
from ray.data._internal.execution.interfaces import TaskContext
import pyarrow as pa
+from pypaimon.write.commit_callback import CommitCallback
+
if TYPE_CHECKING:
from pypaimon.table.table import Table
from pypaimon.write.write_builder import WriteBuilder
@@ -38,6 +41,31 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+@dataclass(frozen=True)
+class PaimonWriteResult:
+ """Metadata reported by the exact coordinator commit."""
+
+ row_count: int
+ snapshot_id: int
+
+
+class _SnapshotIdRecorder(CommitCallback):
+
+ def __init__(self):
+ self.snapshot_id = None
+
+ def call(self, context):
+ self.snapshot_id = context.snapshot.id
+
+
+class _TaskCommitMessages(list):
+ """Commit messages plus the logical rows written by one Ray task."""
+
+ def __init__(self, messages=(), row_count=0):
+ super().__init__(messages)
+ self.row_count = row_count
+
+
def _cast_binary_to_table_schema(table: pa.Table, target_schema: pa.Schema) ->
pa.Table:
"""Cast binary to large_binary for BLOB fields.
@@ -82,6 +110,7 @@ class PaimonDatasink(_DatasinkBase):
self._postpone_bucket_plan = postpone_bucket_plan
self._table_name = table.identifier.get_full_name()
self._writer_builder: Optional["WriteBuilder"] = None
+ self.commit_result: Optional[PaimonWriteResult] = None
def _is_overwrite(self) -> bool:
return self.overwrite or self.static_partition is not None
@@ -115,6 +144,7 @@ class PaimonDatasink(_DatasinkBase):
ctx: TaskContext,
) -> List["CommitMessage"]:
commit_messages_list: List["CommitMessage"] = []
+ row_count = 0
table_write = None
try:
@@ -139,6 +169,7 @@ class PaimonDatasink(_DatasinkBase):
if block_arrow.num_rows == 0:
continue
+ row_count += block_arrow.num_rows
block_arrow = _cast_binary_to_table_schema(block_arrow,
target_pa_schema)
@@ -149,7 +180,7 @@ class PaimonDatasink(_DatasinkBase):
table_write.close()
table_write = None
- return commit_messages_list
+ return _TaskCommitMessages(commit_messages_list, row_count)
except Exception:
if table_write is not None:
try:
@@ -175,6 +206,15 @@ class PaimonDatasink(_DatasinkBase):
"lists. Refusing to proceed to avoid silent data loss."
)
+ @staticmethod
+ def _extract_row_count(write_result: Any, write_returns) -> int:
+ if hasattr(write_result, "num_rows"):
+ return int(write_result.num_rows)
+ return sum(
+ int(getattr(messages, "row_count", 0))
+ for messages in write_returns
+ )
+
def on_write_complete(
self, write_result: Any
):
@@ -205,7 +245,15 @@ class PaimonDatasink(_DatasinkBase):
)
table_commit = self._writer_builder.new_commit()
+ recorder = _SnapshotIdRecorder()
+ table_commit.add_commit_callback(recorder)
table_commit.commit(non_empty_messages)
+ if recorder.snapshot_id is not None:
+ self.commit_result = PaimonWriteResult(
+ row_count=self._extract_row_count(
+ write_result, write_returns),
+ snapshot_id=recorder.snapshot_id,
+ )
logger.info(f"Successfully committed write job for table
{self._table_name}")
except Exception as e:
@@ -243,7 +291,7 @@ def write_paimon_dataset(
ray_remote_args: Optional[Dict[str, Any]] = None,
hash_fixed_precluster: str = "auto",
postpone_bucket_planner=None,
-) -> None:
+) -> Optional[PaimonWriteResult]:
"""Write a Ray Dataset through the safe path for the table's bucket
mode."""
from pypaimon.ray.shuffle import (
HASH_FIXED_PRECLUSTER_MAP_GROUPS,
@@ -294,7 +342,7 @@ def write_paimon_dataset(
overwrite or static_partition is not None
),
)
- _write_postpone_primary_key_blocks(
+ return _write_postpone_primary_key_blocks(
dataset,
table,
overwrite=overwrite,
@@ -304,14 +352,13 @@ def write_paimon_dataset(
bucket_extractor=PostponeFixedBucketRowKeyExtractor(table, plan),
postpone_bucket_plan=plan,
)
- return
if (
hash_fixed_precluster == HASH_FIXED_PRECLUSTER_MAP_GROUPS
and table.bucket_mode() == BucketMode.HASH_FIXED
and getattr(table, "is_primary_key_table", False)
):
- _write_primary_key_groups(
+ return _write_primary_key_groups(
dataset,
table,
overwrite=overwrite,
@@ -319,18 +366,19 @@ def write_paimon_dataset(
concurrency=concurrency,
ray_remote_args=ray_remote_args,
)
- return
dataset = maybe_apply_repartition(dataset, table, hash_fixed_precluster)
+ datasink = PaimonDatasink(
+ table,
+ overwrite=overwrite,
+ static_partition=static_partition,
+ )
dataset.write_datasink(
- PaimonDatasink(
- table,
- overwrite=overwrite,
- static_partition=static_partition,
- ),
+ datasink,
concurrency=concurrency,
ray_remote_args=ray_remote_args,
)
+ return datasink.commit_result
def _write_postpone_primary_key_blocks(
@@ -343,7 +391,7 @@ def _write_postpone_primary_key_blocks(
ray_remote_args: Optional[Dict[str, Any]],
bucket_extractor,
postpone_bucket_plan,
-) -> None:
+) -> Optional[PaimonWriteResult]:
import pickle
from pypaimon.ray.shuffle import (
@@ -405,7 +453,7 @@ def _write_postpone_primary_key_blocks(
static_partition=static_partition,
)
coordinator.on_write_start()
- _consume_write_results(
+ return _consume_write_results(
results, coordinator, message_col, error_col
)
@@ -435,7 +483,7 @@ def _consume_write_results(
coordinator,
message_col,
error_col=None,
-) -> None:
+) -> Optional[PaimonWriteResult]:
import pickle
write_returns = []
@@ -459,6 +507,7 @@ def _consume_write_results(
)
)
coordinator.on_write_complete(write_returns)
+ return coordinator.commit_result
except Exception as error:
coordinator.on_write_failed(error)
raise
@@ -517,7 +566,7 @@ def _write_primary_key_groups(
ray_remote_args: Optional[Dict[str, Any]],
bucket_extractor=None,
postpone_bucket_plan=None,
-) -> None:
+) -> Optional[PaimonWriteResult]:
import pickle
from pypaimon.ray.shuffle import (
@@ -577,6 +626,6 @@ def _write_primary_key_groups(
static_partition=static_partition,
)
coordinator.on_write_start()
- _consume_write_results(
+ return _consume_write_results(
messages, coordinator, message_col, error_col
)
diff --git a/paimon-python/pypaimon/write/table_update.py
b/paimon-python/pypaimon/write/table_update.py
index 175af0367e..a9404b32fe 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -16,7 +16,7 @@
# under the License.
from collections import defaultdict
-from typing import Any, List, Mapping, Optional, Sequence, Tuple
+from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple
import pyarrow
import pyarrow as pa
@@ -166,6 +166,26 @@ class TableUpdate:
self.table, self.commit_user, commit_identifier,
).update_columns(table, cols)
+ def _update_by_arrow_batches_with_row_id(
+ self, tables: Iterable[pa.Table], commit_identifier: int
+ ) -> List[CommitMessage]:
+ updater = None
+ try:
+ for table in tables:
+ cols = self.update_cols if self.update_cols is not None else [
+ c for c in table.column_names
+ if c != SpecialFields.ROW_ID.name
+ ]
+ if updater is None:
+ updater = TableUpdateByRowId(
+ self.table, self.commit_user, commit_identifier)
+ updater.update_columns(table, cols)
+ return [] if updater is None else updater.commit_messages
+ except Exception:
+ if updater is not None:
+ _abort_commit_messages(self.table, updater.commit_messages)
+ raise
+
def _upsert_by_arrow_with_key(
self,
table: pa.Table,
@@ -624,6 +644,13 @@ class BatchTableUpdate(TableUpdate):
"""Apply column updates keyed by ``_ROW_ID`` to existing rows."""
return self._update_by_arrow_with_row_id(table,
BATCH_COMMIT_IDENTIFIER)
+ def update_by_arrow_batches_with_row_id(
+ self, tables: Iterable[pa.Table]
+ ) -> List[CommitMessage]:
+ """Apply row-id updates from batches using one target-file index."""
+ return self._update_by_arrow_batches_with_row_id(
+ tables, BATCH_COMMIT_IDENTIFIER)
+
def upsert_by_arrow_with_key(
self, table: pa.Table, upsert_keys: List[str]
) -> List[CommitMessage]: