blob: 78309200ac8d0723ed2c3f9f453f891d440c0abd [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.
"""
Top-level API for reading and writing Paimon tables with Ray Datasets.
Usage::
from pypaimon.ray import read_paimon, write_paimon
ds = read_paimon("db.table", catalog_options={"warehouse": "/path"})
write_paimon(ds, "db.table", catalog_options={"warehouse": "/path"})
"""
import importlib
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
from pypaimon.common.predicate import Predicate
if TYPE_CHECKING:
import ray.data
def _require_ray_data():
try:
return importlib.import_module("ray.data")
except ModuleNotFoundError as e:
if e.name not in ("ray", "ray.data"):
raise
raise ImportError(
"PyPaimon Ray APIs require the 'ray' package. "
"Install it with: pip install pypaimon[ray]"
) from e
def read_paimon(
table_identifier: str,
catalog_options: Dict[str, str],
*,
filter: Optional[Predicate] = None,
projection: Optional[List[str]] = None,
limit: Optional[int] = None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
dynamic_options: Optional[Dict[str, str]] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
concurrency: Optional[int] = None,
override_num_blocks: Optional[int] = None,
**read_args,
) -> "ray.data.Dataset":
"""Read a Paimon table into a Ray Dataset.
Args:
table_identifier: Full table name, e.g. ``"db_name.table_name"``.
catalog_options: Options passed to ``CatalogFactory.create()``,
e.g. ``{"warehouse": "/path/to/warehouse"}``.
filter: Optional predicate to push down into the scan.
projection: Optional list of column names to read.
limit: Optional row limit for the scan.
snapshot_id: Optional snapshot id to time-travel to. Mutually
exclusive with ``tag_name``.
tag_name: Optional tag name to time-travel to. Mutually
exclusive with ``snapshot_id``.
dynamic_options: Optional dynamic options to override at read time.
ray_remote_args: Optional kwargs passed to ``ray.remote`` in read tasks.
concurrency: Optional max number of Ray read tasks to run concurrently.
override_num_blocks: Optional override for the number of output blocks.
**read_args: Additional kwargs forwarded to ``ray.data.read_datasource``.
Returns:
A ``ray.data.Dataset`` containing the table data.
"""
ray_data = _require_ray_data()
from pypaimon.read.datasource.ray_datasource import RayDatasource
from pypaimon.read.datasource.split_provider import CatalogSplitProvider
from pypaimon.schema.data_types import PyarrowFieldParser
if snapshot_id is not None and tag_name is not None:
raise ValueError(
"snapshot_id and tag_name cannot be set at the same time"
)
if override_num_blocks is not None and override_num_blocks < 1:
raise ValueError(
"override_num_blocks must be at least 1, got {}".format(override_num_blocks)
)
split_provider = CatalogSplitProvider(
table_identifier=table_identifier,
catalog_options=catalog_options,
predicate=filter,
projection=projection,
limit=limit,
snapshot_id=snapshot_id,
tag_name=tag_name,
dynamic_options=dynamic_options,
)
if not split_provider.splits():
schema = PyarrowFieldParser.from_paimon_schema(
split_provider.read_type()
)
import pyarrow
empty_table = pyarrow.Table.from_arrays(
[pyarrow.array([], type=field.type) for field in schema],
schema=schema,
)
return ray_data.from_arrow(empty_table)
datasource = RayDatasource(split_provider)
ds = ray_data.read_datasource(
datasource,
ray_remote_args=ray_remote_args,
concurrency=concurrency,
override_num_blocks=override_num_blocks,
**read_args,
)
# Per-task limit short-circuits each worker's reader, but N workers
# could collectively overshoot the user-visible limit. Cap on top.
if limit is not None:
ds = ds.limit(limit)
return ds
def map_with_blobs(
dataset: "ray.data.Dataset",
columns,
fn: Callable,
*,
file_io=None,
all_blob_columns=None,
parallelism: int = 64,
batch_size: Optional[int] = 1024,
fn_kwargs: Optional[Dict[str, Any]] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
**map_args,
) -> "ray.data.Dataset":
"""Fetch BLOB payloads in Ray batches and call ``fn``.
``fn(scalar_batch, blobs, **fn_kwargs)`` receives a ``pyarrow.Table`` of
non-BLOB columns and a row-aligned ``dict`` of BLOB bytes. Return a small
Ray-compatible batch; for side-effect-only work, return an empty
``pyarrow.Table`` instead of ``None``. Call this directly on
``scan().to_ray()`` output, or pass ``file_io`` and ``all_blob_columns``.
Tune ``batch_size`` for BLOB size and worker memory.
"""
_require_ray_data()
if not callable(fn):
raise ValueError("fn must be callable")
if isinstance(columns, str):
blob_cols = [columns]
else:
blob_cols = list(dict.fromkeys(columns))
if not blob_cols:
raise ValueError("columns must contain at least one BLOB column")
if parallelism < 1:
raise ValueError("parallelism must be at least 1, got {}".format(parallelism))
if batch_size is not None and batch_size < 1:
raise ValueError("batch_size must be at least 1, got {}".format(batch_size))
resolved_file_io = file_io
if resolved_file_io is None:
resolved_file_io = getattr(dataset, "_paimon_blob_file_io", None)
if resolved_file_io is None:
raise ValueError(
"map_with_blobs requires a FileIO. Use table.scan().to_ray() or "
"pass file_io= explicitly.")
batch_format = map_args.pop("batch_format", "pyarrow")
if batch_format != "pyarrow":
raise ValueError("map_with_blobs requires batch_format='pyarrow'")
kwargs = dict(map_args)
kwargs["batch_format"] = "pyarrow"
if batch_size is not None:
kwargs.setdefault("batch_size", batch_size)
if ray_remote_args is not None:
_set_map_batches_remote_args(dataset, kwargs, ray_remote_args)
all_blob_cols = all_blob_columns
if all_blob_cols is None:
all_blob_cols = getattr(dataset, "_paimon_blob_columns", None)
if all_blob_cols is None:
raise ValueError(
"map_with_blobs requires all_blob_columns when Dataset lacks "
"BLOB metadata.")
all_blob = set(all_blob_cols)
invalid = [name for name in blob_cols if name not in all_blob]
if invalid:
raise ValueError("Column {!r} is not a BLOB column.".format(invalid[0]))
return dataset.map_batches(
_map_blob_batch,
fn_kwargs={
"file_io": resolved_file_io,
"blob_cols": blob_cols,
"all_blob_cols": list(all_blob_cols),
"parallelism": parallelism,
"fn": fn,
"fn_kwargs": dict(fn_kwargs or {}),
},
**kwargs)
def _set_map_batches_remote_args(dataset, kwargs, ray_remote_args):
import inspect
param = inspect.signature(dataset.map_batches).parameters.get("ray_remote_args")
if param is not None and param.kind != inspect.Parameter.VAR_KEYWORD:
kwargs["ray_remote_args"] = ray_remote_args
else:
kwargs.update(ray_remote_args)
def _map_blob_batch(
batch, file_io, blob_cols, all_blob_cols, parallelism, fn, fn_kwargs):
from pypaimon.multimodal.blob_read import fetch_blob_bodies
missing = [name for name in blob_cols if name not in batch.schema.names]
if missing:
raise ValueError("BLOB column(s) not found in Ray Dataset: {}".format(
", ".join(missing)))
all_blob = set(all_blob_cols)
scalar_cols = [name for name in batch.schema.names if name not in all_blob]
unknown = _unknown_blob_descriptor_columns(batch, scalar_cols)
if unknown:
raise ValueError(
"Column {!r} holds BLOB descriptors this table does not own "
"(likely from a joined BLOB table). Fetch it with its own "
"table.map_with_blobs() in a separate pass, or drop it before "
"mapping.".format(unknown[0]))
bodies = fetch_blob_bodies(
file_io, batch.select(blob_cols).to_pydict(), blob_cols, parallelism)
result = fn(batch.select(scalar_cols), bodies, **fn_kwargs)
if result is None:
raise ValueError(
"map_with_blobs UDF must return a Ray-compatible batch, such as a "
"pyarrow.Table. For side-effect-only processing, return an empty "
"pyarrow.Table instead of None.")
return result
def _unknown_blob_descriptor_columns(batch, scalar_cols):
return [
name for name in scalar_cols
if _looks_like_blob_descriptor(batch.column(name))]
def _looks_like_blob_descriptor(column):
import pyarrow as pa
from pypaimon.table.row.blob import BlobDescriptor
if not (pa.types.is_binary(column.type) or pa.types.is_large_binary(column.type)):
return False
chunks = getattr(column, "chunks", None) or [column]
for chunk in chunks:
for value in chunk:
if value.is_valid:
return BlobDescriptor.is_blob_descriptor(value.as_py())
return False
def write_paimon(
dataset: "ray.data.Dataset",
table_identifier: str,
catalog_options: Dict[str, str],
*,
overwrite: bool = False,
concurrency: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
hash_fixed_precluster: str = "auto",
) -> None:
"""Write a Ray Dataset to a Paimon table.
HASH_FIXED rows are assigned to the correct bucket by the Paimon
writer. For primary-key tables, ``map_groups`` writes each complete
``(partition_keys..., bucket)`` group in one Ray task. Postpone-bucket
writes follow ``postpone.batch-write-fixed-bucket`` by default. Their
bucket plan is resolved once on the driver and workers write sorted
blocks to real buckets.
HASH_DYNAMIC and CROSS_PARTITION primary-key Ray writes are rejected
because Ray write tasks create independent Paimon writers.
Args:
dataset: The Ray Dataset to write.
table_identifier: Full table name, e.g. ``"db_name.table_name"``.
catalog_options: Options passed to ``CatalogFactory.create()``.
overwrite: If ``True``, overwrite existing data in the table.
concurrency: Optional max number of Ray write tasks to run concurrently.
ray_remote_args: Optional kwargs passed to ``ray.remote`` in write tasks.
hash_fixed_precluster: Pre-clustering mode. ``"auto"`` follows
table options, ``"off"`` disables it, and ``"map_groups"``
explicitly enables HASH_FIXED grouping.
"""
_require_ray_data()
from pypaimon.catalog.catalog_factory import CatalogFactory
from pypaimon.write.ray_datasink import write_paimon_dataset
catalog = CatalogFactory.create(catalog_options)
table = catalog.get_table(table_identifier)
write_paimon_dataset(
dataset,
table,
overwrite=overwrite,
concurrency=concurrency,
ray_remote_args=ray_remote_args,
hash_fixed_precluster=hash_fixed_precluster,
)