blob: 8347dda7aa7f4ad4442def1aec23c98c7c0c6cf4 [file]
# 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()