| # 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 ROSBag validation and ingestion with a materialize barrier.""" |
| |
| import pickle |
| from pathlib import Path |
| import sys |
| from tempfile import TemporaryDirectory |
| from typing import Any, Dict, Mapping, Optional |
| |
| |
| def load_from_rosbag( |
| table_identifier: str, |
| paths, |
| catalog_options: Dict[str, str], |
| *, |
| transform, |
| default_typestore=None, |
| typestore_factory=None, |
| source_options: Optional[Mapping[str, object]] = None, |
| staging=None, |
| allow_storage_fragment: bool = False, |
| concurrency: Optional[int] = None, |
| ray_remote_args: Optional[Dict[str, Any]] = None): |
| """Validate ROSBag sources on Ray before starting the Paimon sink. |
| |
| ``transform(reader, source)`` returns Arrow tables or record batches. |
| Ray may retry it, so it must be deterministic and free of non-idempotent |
| external side effects. Validated output is materialized before the |
| append-only Paimon write starts; a commit exception may have an unknown |
| result. |
| """ |
| if sys.version_info < (3, 10): |
| raise RuntimeError( |
| "Ray ROSBag loading requires Python 3.10 or newer; the rosbag " |
| "extra is not available on older Python versions.") |
| if not callable(transform): |
| raise ValueError("transform must be callable.") |
| if default_typestore is not None and typestore_factory is not None: |
| raise ValueError( |
| "default_typestore and typestore_factory are mutually exclusive.") |
| if typestore_factory is not None and not callable(typestore_factory): |
| raise ValueError("typestore_factory must be callable.") |
| |
| from pypaimon.catalog.catalog_factory import CatalogFactory |
| from pypaimon.common.options import Options |
| from pypaimon.multimodal.rosbag.api import ( |
| RosbagLoadResult, |
| RosbagStagingConfig, |
| _path_values, |
| _validate_staging_config, |
| ) |
| from pypaimon.multimodal.rosbag.source import _discover_rosbag_sources |
| from pypaimon.multimodal.source_utils import ( |
| _SourceFileIO, |
| _validated_source_options, |
| _validate_source_kerberos, |
| ) |
| from pypaimon.multimodal.table import _target_schema |
| from pypaimon.ray.ray_paimon import _require_ray_data, write_paimon |
| |
| if staging is None: |
| staging = RosbagStagingConfig() |
| if not isinstance(staging, RosbagStagingConfig): |
| raise ValueError("staging must be a RosbagStagingConfig.") |
| _validate_staging_config(staging) |
| options = _validated_source_options(source_options) |
| path_values = _path_values(paths) |
| _validate_source_kerberos(path_values, options, source_name="ROSBag") |
| source_file_io = _SourceFileIO(Options(options)) |
| try: |
| manifests = _discover_rosbag_sources( |
| path_values, |
| source_file_io, |
| allow_storage_fragment=allow_storage_fragment, |
| ) |
| finally: |
| source_file_io.close() |
| if not manifests: |
| return RosbagLoadResult(0, None, 0, None) |
| |
| ray_data = _require_ray_data() |
| _require_ray_serializable("transform", transform) |
| if default_typestore is not None: |
| _require_ray_serializable("default_typestore", default_typestore) |
| if typestore_factory is not None: |
| _require_ray_serializable("typestore_factory", typestore_factory) |
| table = CatalogFactory.create(catalog_options).get_table(table_identifier) |
| target_schema = _target_schema(table) |
| inputs = ray_data.from_items( |
| [{"manifest": pickle.dumps(manifest)} for manifest in manifests], |
| override_num_blocks=len(manifests), |
| ) |
| transformed = inputs.map_batches( |
| _TransformRosbagSource, |
| fn_constructor_kwargs={ |
| "transform": transform, |
| "default_typestore": default_typestore, |
| "typestore_factory": typestore_factory, |
| "source_options": options, |
| "staging": staging, |
| "target_schema": target_schema, |
| }, |
| batch_format="pyarrow", |
| batch_size=1, |
| concurrency=concurrency, |
| **dict(ray_remote_args or {}), |
| ) |
| validated = transformed.materialize() |
| from pypaimon.multimodal.rosbag.staging import _verify_manifest_members |
| source_file_io = _SourceFileIO(Options(options)) |
| try: |
| for manifest in manifests: |
| _verify_manifest_members(manifest, source_file_io) |
| finally: |
| source_file_io.close() |
| write_result = write_paimon( |
| validated, |
| table_identifier, |
| catalog_options, |
| concurrency=concurrency, |
| ray_remote_args=ray_remote_args, |
| ) |
| return RosbagLoadResult( |
| source_count=len(manifests), |
| 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), |
| ) |
| |
| |
| def _require_ray_serializable(name, value): |
| import ray.cloudpickle as cloudpickle |
| |
| try: |
| cloudpickle.dumps(value) |
| except Exception as error: |
| raise ValueError( |
| "%s must be Ray-serializable: %s" % (name, error)) from error |
| |
| |
| class _TransformRosbagSource: |
| """Ray callable that will validate one complete source per input batch.""" |
| |
| def __init__( |
| self, |
| *, |
| transform, |
| default_typestore, |
| typestore_factory, |
| source_options, |
| staging, |
| target_schema): |
| self.transform = transform |
| self.default_typestore = default_typestore |
| self.typestore_factory = typestore_factory |
| self.source_options = source_options |
| self.staging = staging |
| self.target_schema = target_schema |
| |
| def __call__(self, batch): |
| if batch.num_rows != 1: |
| raise ValueError( |
| "Ray ROSBag transform requires one source per batch.") |
| |
| from pypaimon.common.options import Options |
| from pypaimon.multimodal.rosbag.loader import ( |
| _transform_rosbag_manifest, |
| ) |
| from pypaimon.multimodal.source_utils import _SourceFileIO |
| try: |
| from rosbags.highlevel import AnyReader |
| except ImportError as error: |
| raise ImportError( |
| "Ray ROSBag loading requires rosbags on every worker; " |
| "install 'pypaimon[ray,rosbag]'.") from error |
| |
| manifest = pickle.loads(batch["manifest"][0].as_py()) |
| source_file_io = _SourceFileIO(Options(self.source_options)) |
| try: |
| with TemporaryDirectory( |
| prefix="pypaimon_ray_rosbag_", |
| dir=self.staging.directory) as temp_dir: |
| for table in _transform_rosbag_manifest( |
| manifest, |
| self.transform, |
| source_file_io, |
| AnyReader, |
| self.target_schema, |
| default_typestore=self.default_typestore, |
| typestore_factory=self.typestore_factory, |
| staging=self.staging, |
| staging_root=Path(temp_dir) / "source"): |
| if table.num_rows: |
| yield table |
| finally: |
| source_file_io.close() |