chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<!-- Loaded on-demand when Claude works on Ray Data files. -->
<!-- Keep under 50 lines. Multi-step procedures → skills. Code style → rules/. -->
# Ray Data
## Key Modules
<!-- Entry points, important abstractions, non-obvious dependencies -->
## Gotchas
<!-- Non-obvious behaviors, common mistakes, things that break silently -->
+9
View File
@@ -0,0 +1,9 @@
<!-- Add Ray Data team-specific rules here as .md files. -->
<!-- Rules with paths: frontmatter only load when matching files are edited. -->
<!-- Example:
---
paths:
- "python/ray/data/**/*.py"
---
- Use logical operators from ray.data._internal.logical.operators
-->
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
# Short term workaround for https://github.com/ray-project/ray/issues/32435
# Dataset has a hard dependency on pandas, so it doesn't need to be delayed.
import pandas # noqa
from packaging.version import parse as parse_version
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data._internal.compute import ActorPoolStrategy, TaskPoolStrategy
from ray.data._internal.execution.interfaces import (
ExecutionOptions,
ExecutionResources,
NodeIdStr,
)
from ray.data._internal.logging import configure_logging
from ray.data._internal.random_config import RandomSeedConfig
from ray.data.context import DataContext, DatasetContext
from ray.data.dataset import (
Dataset,
Schema,
SinkMode,
ClickHouseTableSettings,
SaveMode,
)
from ray.data._internal.logical.operators.n_ary_operator import (
MixStoppingCondition,
)
from ray.data.stats import DatasetSummary
from ray.data.datasource import (
BlockBasedFileDatasink,
Datasink,
Datasource,
FileShuffleConfig,
ReadTask,
RowBasedFileDatasink,
)
from ray.data.iterator import DataIterator, DatasetIterator
from ray.data.preprocessor import Preprocessor
from ray.data.read_api import ( # noqa: F401
KafkaAuthConfig, # noqa: F401
from_arrow,
from_arrow_refs,
from_blocks,
from_daft,
from_dask,
from_huggingface,
from_items,
from_mars,
from_modin,
from_numpy,
from_numpy_refs,
from_pandas,
from_pandas_refs,
from_spark,
from_tf,
from_torch,
range,
range_tensor,
read_audio,
read_avro,
read_bigquery,
read_binary_files,
read_clickhouse,
read_csv,
read_databricks_tables,
read_datasource,
read_delta,
read_delta_sharing_tables,
read_kafka,
read_hudi,
read_iceberg,
read_images,
read_json,
read_lance,
read_mcap,
read_mongo,
read_numpy,
read_parquet,
read_snowflake,
read_sql,
read_text,
read_tfrecords,
read_unity_catalog,
read_videos,
read_webdataset,
read_zarr,
)
from ray.data.catalog import (
Catalog,
ReaderFormat,
ResolvedSource,
DatabricksUnityCatalog,
)
# Module-level cached global functions for callable classes. It needs to be defined here
# since it has to be process-global across cloudpickled funcs.
_map_actor_context = None
configure_logging()
try:
import pyarrow as pa
# Import these arrow extension types to ensure that they are registered.
from ray.data._internal.tensor_extensions.arrow import ( # noqa
ArrowTensorType,
ArrowVariableShapedTensorType,
)
# https://github.com/apache/arrow/pull/38608 deprecated `PyExtensionType`, and
# disabled it's deserialization by default. To ensure that users can load data
# written with earlier version of Ray Data, we enable auto-loading of serialized
# tensor extensions.
#
# NOTE: `PyExtensionType` is deleted from Arrow >= 21.0
pyarrow_version = get_pyarrow_version()
if pyarrow_version is None or pyarrow_version >= parse_version("21.0.0"):
pass
else:
from ray._common.utils import env_bool
RAY_DATA_AUTOLOAD_PYEXTENSIONTYPE = env_bool(
"RAY_DATA_AUTOLOAD_PYEXTENSIONTYPE", False
)
if (
pyarrow_version >= parse_version("14.0.1")
and RAY_DATA_AUTOLOAD_PYEXTENSIONTYPE
):
pa.PyExtensionType.set_auto_load(True)
except ModuleNotFoundError:
pass
__all__ = [
"ActorPoolStrategy",
"BlockBasedFileDatasink",
"ClickHouseTableSettings",
"Dataset",
"DataContext",
"DatasetContext", # Backwards compatibility alias.
"DatasetSummary",
"DataIterator",
"DatasetIterator", # Backwards compatibility alias.
"Datasink",
"Datasource",
"ExecutionOptions",
"ExecutionResources",
"FileShuffleConfig",
"MixStoppingCondition",
"NodeIdStr",
"RandomSeedConfig",
"ReadTask",
"RowBasedFileDatasink",
"Schema",
"SinkMode",
"SaveMode",
"TaskPoolStrategy",
"from_daft",
"from_dask",
"from_items",
"from_arrow",
"from_arrow_refs",
"from_blocks",
"from_mars",
"from_modin",
"from_numpy",
"from_numpy_refs",
"from_pandas",
"from_pandas_refs",
"from_spark",
"from_tf",
"from_torch",
"from_huggingface",
"range",
"range_tensor",
"read_audio",
"read_avro",
"read_text",
"read_binary_files",
"read_clickhouse",
"read_csv",
"read_datasource",
"read_delta",
"read_delta_sharing_tables",
"read_kafka",
"read_hudi",
"read_iceberg",
"read_images",
"read_json",
"read_lance",
"read_mcap",
"read_numpy",
"read_mongo",
"read_parquet",
"read_snowflake",
"read_sql",
"read_tfrecords",
"read_unity_catalog",
"read_videos",
"read_zarr",
"read_webdataset",
"Catalog",
"ReaderFormat",
"ResolvedSource",
"DatabricksUnityCatalog",
"KafkaAuthConfig",
"Preprocessor",
]
@@ -0,0 +1,31 @@
from typing import TYPE_CHECKING
from .autoscaling_actor_pool import ActorPoolScalingRequest, AutoscalingActorPool
from .base_actor_autoscaler import ActorAutoscaler
from .default_actor_autoscaler import DefaultActorAutoscaler, _get_max_scale_up
if TYPE_CHECKING:
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.data._internal.execution.streaming_executor_state import Topology
from ray.data.context import AutoscalingConfig
def create_actor_autoscaler(
topology: "Topology",
resource_manager: "ResourceManager",
config: "AutoscalingConfig",
) -> ActorAutoscaler:
return DefaultActorAutoscaler(
topology,
resource_manager,
config=config,
)
__all__ = [
"ActorAutoscaler",
"ActorPoolScalingRequest",
"AutoscalingActorPool",
"create_actor_autoscaler",
"_get_max_scale_up",
]
@@ -0,0 +1,284 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Optional
from ray import ObjectRef
from ray.actor import ActorHandle
from ray.data._internal.execution.interfaces.common import NodeIdStr
from ray.data._internal.execution.interfaces.execution_options import ExecutionResources
from ray.data._internal.execution.interfaces.ref_bundle import RefBundle
from ray.util.annotations import DeveloperAPI
@dataclass(frozen=True)
class ActorPoolScalingRequest:
delta: int
force: bool = field(default=False)
reason: Optional[str] = field(default=None)
@classmethod
def no_op(cls, *, reason: Optional[str] = None) -> "ActorPoolScalingRequest":
return ActorPoolScalingRequest(delta=0, reason=reason)
@classmethod
def upscale(cls, *, delta: int, reason: Optional[str] = None):
assert delta > 0
return ActorPoolScalingRequest(delta=delta, reason=reason)
@classmethod
def downscale(
cls, *, delta: int, force: bool = False, reason: Optional[str] = None
):
assert delta < 0, "For scale down delta is expected to be negative!"
return ActorPoolScalingRequest(delta=delta, force=force, reason=reason)
@dataclass(frozen=True)
class AutoscalingActorConfig:
"""
per_actor_resource_usage: The resource usage per actor.
min_size: The minimum number of running actors to be maintained
in the pool. Note, that this constraint could be violated when
no new work is available for scheduling in the actor pool (ie
when operator completes execution).
max_size: The maximum number of running actors to be maintained
in the pool.
initial_size: The initial number of actors to start with.
max_actor_concurrency: The maximum number of concurrent tasks a
single actor can execute (derived from `ray_remote_args`
passed to the operator).
max_tasks_in_flight_per_actor: The maximum number of tasks that can
be submitted to a single actor at any given time.
"""
min_size: int
max_size: int
initial_size: int
max_tasks_in_flight_per_actor: int
max_actor_concurrency: int
per_actor_resource_usage: ExecutionResources
def __post_init__(self):
assert self.min_size >= 1
assert self.max_size >= self.min_size
assert self.initial_size <= self.max_size
assert self.initial_size >= self.min_size
assert self.max_tasks_in_flight_per_actor >= 1
@dataclass(frozen=True)
class ActorPoolInfo:
"""Breakdown of the state of the actors used by the ``PhysicalOperator``"""
running: int
pending: int
restarting: int
active: int = 0
idle: int = 0
pool_utilization: float = 0.0
tasks_in_flight: int = 0
def __str__(self):
return (
f"running={self.running}, restarting={self.restarting}, "
f"pending={self.pending}, active={self.active}, idle={self.idle}, "
f"util={self.pool_utilization:.3f}, tasks_in_flight={self.tasks_in_flight}"
)
@DeveloperAPI
class AutoscalingActorPool(ABC):
"""Abstract interface of an autoscaling actor pool.
A `PhysicalOperator` can manage one or more `AutoscalingActorPool`s.
`Autoscaler` is responsible for deciding autoscaling of these actor
pools.
"""
_LOGICAL_ACTOR_ID_LABEL_KEY = "__ray_data_logical_actor_id"
_DEFAULT_POOL_UTILIZATION = 0
def __init__(self, config: AutoscalingActorConfig):
self._config = config
@abstractmethod
def num_running_actors(self) -> int:
"""Number of running actors."""
...
@abstractmethod
def num_restarting_actors(self) -> int:
"""Number of restarting actors"""
...
@abstractmethod
def num_active_actors(self) -> int:
"""Number of actors with at least one active task."""
...
@abstractmethod
def num_pending_actors(self) -> int:
"""Number of actors pending creation."""
...
@abstractmethod
def num_tasks_in_flight(self) -> int:
"""Number of current in-flight tasks (ie total nubmer of tasks that have been
submitted to the actor pool)."""
...
def can_schedule_task(self) -> bool:
"""Returns `True` iff the actor pool has an available actor that can run a task."""
return self.select_actors() is not None
@abstractmethod
def scale(self, req: ActorPoolScalingRequest):
"""Applies autoscaling action"""
...
@abstractmethod
def refresh_actor_state(self):
"""Refreshes the actor pool state (for, example, running, restarting, pending)"""
...
@abstractmethod
def on_task_submitted(self, actor: ActorHandle):
"""Callback when an actor is picked for running a task"""
...
@abstractmethod
def on_task_completed(self, actor: ActorHandle):
"""Called when a task completes. Returns the provided actor to the pool."""
...
@abstractmethod
def select_actors(
self,
bundle: Optional[RefBundle] = None,
actor_locality_enabled: bool = False,
) -> Optional[ActorHandle]:
"""Select an actor to process the given bundle.
When ``bundle`` is ``None``, returns any available actor with spare
capacity (used by ``can_schedule_task`` to probe schedulability).
When ``bundle`` is provided, returns the best actor for that bundle
(considering locality when ``actor_locality_enabled`` is True).
Args:
bundle: The bundle to find an actor for. If ``None``, returns any
available actor with spare capacity.
actor_locality_enabled: Whether to consider locality when selecting
an actor.
Returns:
An actor handle if an actor with capacity is available, otherwise
``None``.
"""
...
@abstractmethod
def get_pending_actor_refs(self) -> List[ObjectRef]:
"""Return the list of object refs for actors that are pending creation."""
...
@abstractmethod
def pending_to_running(self, ready_ref: ObjectRef) -> Optional[ActorHandle]:
"""Mark the actor corresponding to the provided ready future as running.
Args:
ready_ref: The ready future for the actor to mark as running.
Returns:
The actor handle if the actor is still alive, otherwise ``None``.
"""
...
@abstractmethod
def get_actor_location(self, actor: ActorHandle) -> NodeIdStr:
"""Get the node_id of the actor"""
...
@abstractmethod
def shutdown(self, force: bool = False):
"""Kills all actors, including running/active actors.
This is called once the operator is shutting down.
"""
...
def get_logical_id_label_key(self) -> str:
"""Get the label key for the logical actor ID.
Actors launched by this pool should have this label.
"""
return self._LOGICAL_ACTOR_ID_LABEL_KEY
def get_actor_info(self) -> ActorPoolInfo:
"""Returns current snapshot of actors' being used in the pool"""
pool_util = self.get_pool_util()
# Handle infinite utilization case (no actors)
if pool_util == float("inf"):
pool_util = self._DEFAULT_POOL_UTILIZATION
return ActorPoolInfo(
running=self.num_alive_actors(),
pending=self.num_pending_actors(),
restarting=self.num_restarting_actors(),
active=self.num_active_actors(),
idle=self.num_idle_actors(),
pool_utilization=pool_util,
tasks_in_flight=self.num_tasks_in_flight(),
)
def num_alive_actors(self) -> int:
"""Alive actors are all the running actors in ALIVE state."""
return self.num_running_actors() - self.num_restarting_actors()
def num_idle_actors(self) -> int:
"""Return the number of idle actors in the pool."""
return self.num_running_actors() - self.num_active_actors()
def per_actor_resource_usage(self) -> ExecutionResources:
"""Per actor resource usage."""
return self._config.per_actor_resource_usage
def max_actor_concurrency(self) -> int:
"""Returns max number of tasks single actor could run concurrently."""
return self._config.max_actor_concurrency
def max_tasks_in_flight_per_actor(self) -> int:
"""Max number of in-flight tasks per actor."""
return self._config.max_tasks_in_flight_per_actor
def initial_size(self) -> int:
return self._config.initial_size
def current_size(self) -> int:
return self.num_pending_actors() + self.num_running_actors()
def min_size(self) -> int:
"""Min size of the actor pool."""
return self._config.min_size
def max_size(self) -> int:
"""Max size of the actor pool."""
return self._config.max_size
def get_pool_util(self) -> float:
"""Calculate the utilization of the given actor pool."""
# If there are no running actors, we set the utilization to indicate that the pool should be scaled up immediately.
if self.current_size() == 0:
return float("inf")
else:
# We compute utilization as a ratio of
# - Number of submitted tasks over
# - Max number of tasks that Actor Pool could currently run
#
# This value could exceed 100%, since by default actors are allowed
# to queue tasks (to pipeline task execution by overlapping block
# fetching with the execution of the previous task)
return self.num_tasks_in_flight() / (
self.max_actor_concurrency() * self.current_size()
)
@@ -0,0 +1,31 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.data._internal.execution.streaming_executor_state import Topology
@DeveloperAPI
class ActorAutoscaler(ABC):
"""Abstract interface for Ray Data actor autoscaler."""
def __init__(
self,
topology: "Topology",
resource_manager: "ResourceManager",
):
self._topology = topology
self._resource_manager = resource_manager
@abstractmethod
def try_trigger_scaling(self):
"""Try trigger autoscaling.
This method will be called each time when StreamingExecutor makes
a scheduling decision. A subclass should override this method to
handle the autoscaling of `AutoscalingActorPool`s.
"""
...
@@ -0,0 +1,326 @@
from __future__ import annotations
import logging
import math
import sys
from typing import TYPE_CHECKING, Optional
from .autoscaling_actor_pool import ActorPoolScalingRequest, AutoscalingActorPool
from .base_actor_autoscaler import ActorAutoscaler
from ray.data._internal.execution.interfaces.execution_options import ExecutionResources
from ray.data.context import WARN_PREFIX, AutoscalingConfig
if TYPE_CHECKING:
from ray.data._internal.execution.interfaces import PhysicalOperator
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.data._internal.execution.streaming_executor_state import OpState, Topology
logger = logging.getLogger(__name__)
class DefaultActorAutoscaler(ActorAutoscaler):
def __init__(
self,
topology: "Topology",
resource_manager: "ResourceManager",
*,
config: AutoscalingConfig,
):
super().__init__(topology, resource_manager)
self._actor_pool_scaling_up_threshold: float = (
config.actor_pool_util_upscaling_threshold
)
self._actor_pool_scaling_down_threshold: float = (
config.actor_pool_util_downscaling_threshold
)
self._actor_pool_max_upscaling_delta: Optional[
int
] = config.actor_pool_max_upscaling_delta
self._validate_autoscaling_config()
def try_trigger_scaling(self):
for op, state in self._topology.items():
actor_pools = op.get_autoscaling_actor_pools()
for actor_pool in actor_pools:
# Trigger auto-scaling
actor_pool.scale(
self._derive_target_scaling_config(actor_pool, op, state)
)
def _derive_target_scaling_config(
self,
actor_pool: AutoscalingActorPool,
op: "PhysicalOperator",
op_state: "OpState",
) -> ActorPoolScalingRequest:
# If all inputs have been consumed, short-circuit
if op.has_completed() or (
op._inputs_complete and op_state.total_enqueued_input_blocks() == 0
):
num_to_scale_down = self._compute_downscale_delta(actor_pool)
return ActorPoolScalingRequest.downscale(
delta=-num_to_scale_down, force=True, reason="consumed all inputs"
)
if actor_pool.current_size() < actor_pool.min_size():
# Scale up, if the actor pool is below min size.
return ActorPoolScalingRequest.upscale(
delta=actor_pool.min_size() - actor_pool.current_size(),
reason="pool below min size",
)
elif actor_pool.current_size() > actor_pool.max_size():
return ActorPoolScalingRequest.downscale(
delta=-(actor_pool.current_size() - actor_pool.max_size()),
reason="pool exceeding max size",
)
allocation = self._resource_manager.get_allocation(op)
op_usage = self._resource_manager.get_op_usage(op)
if allocation is not None and op_usage is not None:
over_budget_scale_down = _get_required_scale_down(
actor_pool, allocation.subtract(op_usage)
)
if over_budget_scale_down > 0:
max_can_release = actor_pool.current_size() - actor_pool.min_size()
num_to_scale_down = min(over_budget_scale_down, max_can_release)
if num_to_scale_down > 0:
return ActorPoolScalingRequest.downscale(
delta=-num_to_scale_down,
reason="actor pool exceeds resource allocation",
)
return ActorPoolScalingRequest.no_op(
reason="actor pool exceeds resource allocation "
"but cannot scale below min size",
)
# To prevent unexpected downscaling from the initial size, short-circuit if
# the operator hasn't received any inputs.
if op.metrics.num_inputs_received == 0:
return ActorPoolScalingRequest.no_op(reason="no inputs received")
# Determine whether to scale up based on the actor pool utilization.
util = actor_pool.get_pool_util()
if util >= self._actor_pool_scaling_up_threshold:
# Do not scale up if either
# - Actor Pool is at max size already
# - Op is throttled (ie exceeding allocated resource quota)
if actor_pool.current_size() >= actor_pool.max_size():
return ActorPoolScalingRequest.no_op(reason="reached max size")
if not op_state._scheduling_status.under_resource_limits:
return ActorPoolScalingRequest.no_op(
reason="operator exceeding resource quota"
)
budget = self._resource_manager.get_budget(op)
budget_max_scale_up = (
_get_max_scale_up(actor_pool, budget) if budget else sys.maxsize
)
# Determine maximum available scale up based on
# - Maximum available resource budget
# - Configured max scale-up delta (or "+inf" if not configured)
# - Total # of actors needed to reach `max_size`
max_scale_up: int = min(
budget_max_scale_up,
self._get_actor_pool_max_upscaling_delta(),
actor_pool.max_size() - actor_pool.current_size(),
)
if max_scale_up == 0:
return ActorPoolScalingRequest.no_op(reason="exceeded resource limits")
if util == float("inf"):
return ActorPoolScalingRequest.upscale(
delta=1, reason="no running actors, scale up immediately"
)
delta = self._compute_upscale_delta(actor_pool, op_state)
# At least scale up by 1
delta = max(1, delta)
# Cap delta
delta = min(delta, max_scale_up)
return ActorPoolScalingRequest.upscale(
delta=delta,
reason=(
f"utilization of {util} >= "
f"{self._actor_pool_scaling_up_threshold}"
),
)
elif util <= self._actor_pool_scaling_down_threshold:
if actor_pool.num_pending_actors() > 0:
return ActorPoolScalingRequest.no_op(
reason="no downscaling while actors are pending"
)
if actor_pool.current_size() <= actor_pool.min_size():
return ActorPoolScalingRequest.no_op(reason="reached min size")
max_can_release = actor_pool.current_size() - actor_pool.min_size()
num_to_scale_down = min(
self._compute_downscale_delta(actor_pool), max_can_release
)
return ActorPoolScalingRequest.downscale(
delta=-num_to_scale_down,
reason=(
f"utilization of {util} <= "
f"{self._actor_pool_scaling_down_threshold}"
),
)
else:
return ActorPoolScalingRequest.no_op(
reason=(
f"utilization of {util} w/in limits "
f"[{self._actor_pool_scaling_down_threshold}, "
f"{self._actor_pool_scaling_up_threshold}]"
)
)
def _get_actor_pool_max_upscaling_delta(self) -> int:
return (
self._actor_pool_max_upscaling_delta
if self._actor_pool_max_upscaling_delta is not None
else sys.maxsize
)
def _validate_autoscaling_config(self):
# Validate that max upscaling delta is positive to prevent override by safeguard
if (
self._actor_pool_max_upscaling_delta is not None
and self._actor_pool_max_upscaling_delta <= 0
):
raise ValueError(
f"actor_pool_max_upscaling_delta must be positive, "
f"got {self._actor_pool_max_upscaling_delta}"
)
# Validate that upscaling threshold is positive to prevent division by zero
# and incorrect scaling calculations
if self._actor_pool_scaling_up_threshold <= 0:
raise ValueError(
f"actor_pool_util_upscaling_threshold must be positive, "
f"got {self._actor_pool_scaling_up_threshold}"
)
for op, state in self._topology.items():
for actor_pool in op.get_autoscaling_actor_pools():
self._validate_actor_pool_autoscaling_config(actor_pool, op)
def _validate_actor_pool_autoscaling_config(
self,
actor_pool: AutoscalingActorPool,
op: "PhysicalOperator",
) -> None:
"""Validate autoscaling configuration.
Args:
actor_pool: Actor pool to validate configuration thereof.
op: ``PhysicalOperator`` using target actor pool.
"""
# Fixed-size pools don't autoscale by design
if actor_pool.min_size() == actor_pool.max_size():
return
max_tasks_in_flight_per_actor = actor_pool.max_tasks_in_flight_per_actor()
max_concurrency = actor_pool.max_actor_concurrency()
if (
max_tasks_in_flight_per_actor / max_concurrency
< self._actor_pool_scaling_up_threshold
):
logger.warning(
f"{WARN_PREFIX} Actor Pool configuration of the {op} will not allow it to scale up: "
f"configured utilization threshold ({self._actor_pool_scaling_up_threshold * 100}%) "
f"couldn't be reached with configured max_concurrency={max_concurrency} "
f"and max_tasks_in_flight_per_actor={max_tasks_in_flight_per_actor} "
f"(max utilization will be max_tasks_in_flight_per_actor / max_concurrency = {(max_tasks_in_flight_per_actor / max_concurrency) * 100:g}%)"
)
def _compute_upscale_delta(
self, actor_pool: AutoscalingActorPool, op_state: OpState
) -> int:
# Calculate desired delta based on utilization
return math.ceil(
actor_pool.current_size()
* (actor_pool.get_pool_util() / self._actor_pool_scaling_up_threshold - 1)
)
def _compute_downscale_delta(self, actor_pool: "AutoscalingActorPool") -> int:
return 1
def _estimate_total_available_task_slots(actor_pool: "AutoscalingActorPool") -> int:
# Estimates number of available task slots to schedule new tasks
#
# NOTE: This must include pending actors for estimation to make sure
# autoscaler appropriately accounts task slots that will be available
# once pending actors become running.
return (
actor_pool.max_tasks_in_flight_per_actor() * actor_pool.current_size()
- actor_pool.num_tasks_in_flight()
)
def _get_max_scale_up(
actor_pool: AutoscalingActorPool,
budget: ExecutionResources,
) -> int:
"""Get the maximum number of actors that can be scaled up.
Args:
actor_pool: The actor pool to scale up.
budget: The budget to scale up.
Returns:
The maximum number of actors that can be scaled up, or `None` if you can
scale up infinitely.
"""
assert budget.cpu >= 0 and budget.gpu >= 0 and budget.memory >= 0
per_actor = actor_pool.per_actor_resource_usage()
assert per_actor.cpu >= 0 and per_actor.gpu >= 0 and per_actor.memory >= 0
# floordiv handles per_actor.x == 0 → inf (no constraint from that resource)
# and budget.x == inf → inf. We ignore object_store_memory since it is not
# a per-actor declared resource.
divisions = budget.floordiv(per_actor)
max_scale_up = min(divisions.cpu, divisions.gpu, divisions.memory)
if math.isinf(max_scale_up):
return sys.maxsize
return int(max_scale_up)
def _get_required_scale_down(
actor_pool: AutoscalingActorPool,
budget: ExecutionResources,
) -> int:
"""Get the number of actors that must be removed to fit within budget.
Args:
actor_pool: The actor pool to scale down.
budget: The net remaining budget (allocation - usage). Can be negative
if the operator is over its allocation.
Returns:
The number of actors that need to be removed, or 0 if the pool
is within budget.
"""
per_actor = actor_pool.per_actor_resource_usage()
required_cpu_scale_down = 0
if per_actor.cpu > 0 and budget.cpu < 0:
required_cpu_scale_down = math.ceil(abs(budget.cpu) / per_actor.cpu)
required_gpu_scale_down = 0
if per_actor.gpu > 0 and budget.gpu < 0:
required_gpu_scale_down = math.ceil(abs(budget.gpu) / per_actor.gpu)
required_memory_scale_down = 0
if per_actor.memory > 0 and budget.memory < 0:
required_memory_scale_down = math.ceil(abs(budget.memory) / per_actor.memory)
return max(
required_cpu_scale_down, required_gpu_scale_down, required_memory_scale_down
)
+694
View File
@@ -0,0 +1,694 @@
import logging
import random
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterator,
List,
Mapping,
Optional,
Tuple,
TypeVar,
Union,
)
import numpy as np
import pandas as pd
from packaging.version import parse as parse_version
from ray._common.utils import env_integer
from ray.data._internal.arrow_ops import transform_polars, transform_pyarrow
from ray.data._internal.arrow_ops.transform_pyarrow import shuffle
from ray.data._internal.row import row_repr, row_repr_pretty, row_str
from ray.data._internal.table_block import TableBlockAccessor, TableBlockBuilder
from ray.data._internal.tensor_extensions.arrow import (
convert_to_pyarrow_array,
pyarrow_table_from_pydict,
)
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.block import (
Block,
BlockAccessor,
BlockColumn,
BlockColumnAccessor,
BlockExecStats,
BlockMetadataWithSchema,
BlockType,
U,
)
from ray.data.context import DEFAULT_TARGET_MAX_BLOCK_SIZE, DataContext
from ray.data.expressions import Expr
try:
import pyarrow
except ImportError:
pyarrow = None
if TYPE_CHECKING:
import pandas
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
T = TypeVar("T")
logger = logging.getLogger(__name__)
_MIN_PYARROW_VERSION_TO_NUMPY_ZERO_COPY_ONLY = parse_version("13.0.0")
_BATCH_SIZE_PRESERVING_STUB_COL_NAME = "__bsp_stub"
def _is_user_visible_column(name: str) -> bool:
return name != _BATCH_SIZE_PRESERVING_STUB_COL_NAME
# Set the max chunk size in bytes for Arrow to Batches conversion in
# ArrowBlockAccessor.iter_rows(). Default to 4MB, to optimize for image
# datasets in parquet format.
ARROW_MAX_CHUNK_SIZE_BYTES = env_integer(
"RAY_DATA_ARROW_MAX_CHUNK_SIZE_BYTES",
int(DEFAULT_TARGET_MAX_BLOCK_SIZE / 32),
)
# We offload some transformations to polars for performance.
def get_sort_transform(context: DataContext) -> Callable:
if context.use_polars or context.use_polars_sort:
return transform_polars.sort
else:
return transform_pyarrow.sort
def get_concat_and_sort_transform(context: DataContext) -> Callable:
if context.use_polars or context.use_polars_sort:
return transform_polars.concat_and_sort
else:
return transform_pyarrow.concat_and_sort
class ArrowRow(Mapping):
"""
Row of a tabular Dataset backed by a Arrow Table block.
"""
def __init__(self, row: Any):
self._row = row
def __getitem__(self, key: Union[str, List[str]]) -> Any:
from ray.data.extensions import get_arrow_extension_tensor_types
tensor_arrow_extension_types = get_arrow_extension_tensor_types()
def get_item(keys: List[str]) -> Any:
schema = self._row.schema
if isinstance(schema.field(keys[0]).type, tensor_arrow_extension_types):
# Build a tensor row.
return tuple(
[
ArrowBlockAccessor._build_tensor_row(
self._row, col_name=key, row_idx=0
)
for key in keys
]
)
table = self._row.select(keys)
if len(table) == 0:
return None
items = [col[0] for col in table.columns]
try:
# Try to interpret this as a pyarrow.Scalar value.
return tuple([item.as_py() for item in items])
except AttributeError:
# Assume that this row is an element of an extension array, and
# that it is bypassing pyarrow's scalar model for Arrow < 8.0.0.
return items
is_single_item = isinstance(key, str)
keys = [key] if is_single_item else key
items = get_item(keys)
if items is None:
return None
elif is_single_item:
return items[0]
else:
return items
def __iter__(self) -> Iterator:
for k in self._row.column_names:
yield k
def __len__(self):
return self._row.num_columns
def as_pydict(self) -> Dict[str, Any]:
return dict(self.items())
def __str__(self):
return row_str(self)
def __repr__(self):
return row_repr(self)
def _repr_pretty_(self, p, cycle):
return row_repr_pretty(self, p, cycle)
class ArrowBlockBuilder(TableBlockBuilder):
def __init__(self):
if pyarrow is None:
raise ImportError("Run `pip install pyarrow` for Arrow support")
super().__init__((pyarrow.Table, bytes))
@staticmethod
def _table_from_pydict(columns: Dict[str, List[Any]]) -> Block:
return pyarrow_table_from_pydict(
{
column_name: convert_to_pyarrow_array(column_values, column_name)
for column_name, column_values in columns.items()
}
)
@staticmethod
def _combine_tables(tables: List[Block]) -> Block:
if len(tables) > 1:
return transform_pyarrow.concat(
tables, promote_types=True, preserve_order=True
)
else:
return tables[0]
@staticmethod
def _concat_would_copy() -> bool:
return False
@staticmethod
def _empty_table() -> "pyarrow.Table":
return pyarrow_table_from_pydict({})
def block_type(self) -> BlockType:
return BlockType.ARROW
def _get_max_chunk_size(
table: "pyarrow.Table", max_chunk_size_bytes: int
) -> Optional[int]:
"""
Calculate the max chunk size in rows for Arrow to Batches conversion in
ArrowBlockAccessor.iter_rows().
Args:
table: The pyarrow table to calculate the max chunk size for.
max_chunk_size_bytes: The max chunk size in bytes.
Returns:
The max chunk size in rows, or None if the table is empty.
"""
if table.nbytes == 0:
return None
else:
avg_row_size = table.nbytes / table.num_rows
return max(1, int(max_chunk_size_bytes / avg_row_size))
class ArrowBlockAccessor(TableBlockAccessor):
ROW_TYPE = ArrowRow
def __init__(self, table: "pyarrow.Table"):
if pyarrow is None:
raise ImportError("Run `pip install pyarrow` for Arrow support")
super().__init__(table)
self._max_chunk_size: Optional[int] = None
def _get_row(self, index: int) -> ArrowRow:
base_row = self.slice(index, index + 1, copy=False)
return ArrowRow(base_row)
def column_names(self) -> List[str]:
return self._table.column_names
def fill_column(self, name: str, value: Any) -> Block:
import pyarrow.compute as pc
# Check if value is array-like - if so, use upsert_column logic
if isinstance(value, (pyarrow.Array, pyarrow.ChunkedArray)):
return self.upsert_column(name, value)
else:
# Scalar value - use original fill_column logic
if isinstance(value, pyarrow.Scalar):
type = value.type
else:
type = pyarrow.infer_type([value])
array = pyarrow.nulls(len(self._table), type=type)
array = pc.fill_null(array, value)
return self.upsert_column(name, array)
@classmethod
def from_bytes(cls, data: bytes) -> "ArrowBlockAccessor":
reader = pyarrow.ipc.open_stream(data)
return cls(reader.read_all())
@staticmethod
def _build_tensor_row(row: ArrowRow, row_idx: int, col_name: str) -> np.ndarray:
element = row[col_name][row_idx]
arr = element.as_py()
assert isinstance(arr, np.ndarray), type(arr)
return arr
def slice(self, start: int, end: int, copy: bool = False) -> "pyarrow.Table":
view = self._table.slice(start, end - start)
if copy:
view = transform_pyarrow.combine_chunks(view, copy)
return view
def random_shuffle(self, random_seed: Optional[int]) -> "pyarrow.Table":
return shuffle(self._table, random_seed)
def schema(self) -> "pyarrow.lib.Schema":
return self._table.schema
def to_pandas(self) -> "pandas.DataFrame":
from ray.data.util.data_batch_conversion import _cast_tensor_columns_to_ndarrays
# We specify ignore_metadata=True because pyarrow will use the metadata
# to build the Table. This is handled incorrectly for older pyarrow versions
ctx = DataContext.get_current()
# types_mapper preserves Arrow dtypes through the pandas round-trip:
# - Standard Arrow types become pd.ArrowDtype, so pa.Table.from_pandas()
# can reconstruct them exactly without lossy numpy conversion.
# - Extension types (Ray's ArrowTensorType / ArrowPythonObjectType and
# pyarrow's native FixedShapeTensorType) return None, falling back to
# their own to_pandas_dtype() hooks. Note: native FixedShapeTensorType
# subclasses BaseExtensionType but not ExtensionType, so we check the
# broader BaseExtensionType.
def _types_mapper(t):
if isinstance(t, pyarrow.BaseExtensionType) or pyarrow.types.is_dictionary(
t
):
return None
return pd.ArrowDtype(t)
df = self._table.to_pandas(
ignore_metadata=ctx.pandas_block_ignore_metadata,
types_mapper=_types_mapper,
)
if ctx.enable_tensor_extension_casting:
df = _cast_tensor_columns_to_ndarrays(df, arrow_schema=self._table.schema)
return df
def to_numpy(
self, columns: Optional[Union[str, List[str]]] = None
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
if columns is None:
columns = self._table.column_names
should_be_single_ndarray = False
elif isinstance(columns, list):
should_be_single_ndarray = False
else:
columns = [columns]
should_be_single_ndarray = True
column_names_set = set(self._table.column_names)
for column in columns:
if column not in column_names_set:
raise ValueError(
f"Cannot find column {column}, available columns: "
f"{column_names_set}"
)
column_values_ndarrays = []
for col_name in columns:
col = self._table[col_name]
# Combine columnar values arrays to make these contiguous
# (making them compatible with numpy format)
combined_array = transform_pyarrow.combine_chunked_array(col)
column_values_ndarrays.append(
transform_pyarrow.to_numpy(combined_array, zero_copy_only=False)
)
if should_be_single_ndarray:
assert len(columns) == 1
return column_values_ndarrays[0]
else:
return dict(zip(columns, column_values_ndarrays))
def to_arrow(self) -> "pyarrow.Table":
return self._table
def num_rows(self) -> int:
# Arrow may represent an empty table via an N > 0 row, 0-column table, e.g. when
# slicing an empty table, so we return 0 if num_columns == 0.
return self._table.num_rows if self._table.num_columns > 0 else 0
def size_bytes(self) -> int:
return self._table.nbytes
def _zip(self, acc: BlockAccessor) -> "Block":
r = self.to_arrow()
s = acc.to_arrow()
for col_name in s.column_names:
col = s.column(col_name)
# Ensure the column names are unique after zip.
if col_name in r.column_names:
i = 1
new_name = col_name
while new_name in r.column_names:
new_name = "{}_{}".format(col_name, i)
i += 1
col_name = new_name
r = r.append_column(col_name, col)
return r
def upsert_column(
self, column_name: str, column_data: BlockColumn
) -> "pyarrow.Table":
assert isinstance(
column_data, (pyarrow.Array, pyarrow.ChunkedArray)
), f"Expected either a pyarrow.Array or pyarrow.ChunkedArray, got: {type(column_data)}"
column_idx = self._table.schema.get_field_index(column_name)
if column_idx == -1:
return self._table.append_column(column_name, column_data)
else:
return self._table.set_column(column_idx, column_name, column_data)
@staticmethod
def builder() -> ArrowBlockBuilder:
return ArrowBlockBuilder()
@staticmethod
def _empty_table() -> "pyarrow.Table":
return ArrowBlockBuilder._empty_table()
def take(
self,
indices: Union[List[int], "pyarrow.Array", "pyarrow.ChunkedArray"],
) -> "pyarrow.Table":
"""Select rows from the underlying table.
This method is an alternative to pyarrow.Table.take(), which breaks for
extension arrays.
"""
return transform_pyarrow.take_table(self._table, indices)
def drop(self, columns: List[str]) -> Block:
return self._table.drop(columns)
def select(self, columns: List[str]) -> "pyarrow.Table":
if not all(isinstance(col, str) for col in columns):
raise ValueError(
"Columns must be a list of column name strings when aggregating on "
f"Arrow blocks, but got: {columns}."
)
if len(columns) == 0:
# Empty projection (e.g. count or ``select_columns([])``).
# Drop every existing column, then append the stub so row
# counts survive downstream ``pa.concat_tables`` calls (which
# collapse num_rows to 0 when all inputs have 0 columns).
# ``pa.Table`` tracks num_rows as metadata independent of
# columns, so ``select([])`` preserves it here. The stub is
# filtered out of the user-visible schema; it's a physical
# placeholder only.
narrowed = self._table.select([])
return ArrowBlockAccessor(narrowed).fill_column(
_BATCH_SIZE_PRESERVING_STUB_COL_NAME, None
)
return self._table.select(columns)
def rename_columns(self, columns_rename: Dict[str, str]) -> "pyarrow.Table":
return self._table.rename_columns(columns_rename)
def hstack(self, other_block: "pyarrow.Table") -> "pyarrow.Table":
result_table = self._table
for name, column in zip(other_block.column_names, other_block.columns):
result_table = result_table.append_column(name, column)
return result_table
def _sample(self, n_samples: int, sort_key: "SortKey") -> "pyarrow.Table":
indices = random.sample(range(self._table.num_rows), n_samples)
table = self._table.select(sort_key.get_columns())
return transform_pyarrow.take_table(table, indices)
def sort(self, sort_key: "SortKey") -> Block:
assert (
sort_key.get_columns()
), f"Sorting columns couldn't be empty (got {sort_key.get_columns()})"
if self._table.num_rows == 0:
# If the pyarrow table is empty we may not have schema
# so calling sort_indices() will raise an error.
return self._empty_table()
context = DataContext.get_current()
sort = get_sort_transform(context)
return sort(self._table, sort_key)
def sort_and_partition(
self, boundaries: List[T], sort_key: "SortKey"
) -> List["Block"]:
table = self.sort(sort_key)
if table.num_rows == 0:
return [self._empty_table() for _ in range(len(boundaries) + 1)]
elif len(boundaries) == 0:
return [table]
return BlockAccessor.for_block(table)._find_partitions_sorted(
boundaries, sort_key
)
@staticmethod
def merge_sorted_blocks(
blocks: List[Block], sort_key: "SortKey"
) -> Tuple[Block, BlockMetadataWithSchema]:
stats = BlockExecStats.builder()
blocks = [b for b in blocks if b.num_rows > 0]
if len(blocks) == 0:
ret = ArrowBlockAccessor._empty_table()
else:
# Handle blocks of different types.
blocks = TableBlockAccessor.normalize_block_types(blocks, BlockType.ARROW)
concat_and_sort = get_concat_and_sort_transform(DataContext.get_current())
ret = concat_and_sort(blocks, sort_key, promote_types=True)
return ret, BlockMetadataWithSchema.from_block(
ret, block_exec_stats=stats.build()
)
def block_type(self) -> BlockType:
return BlockType.ARROW
def iter_rows(
self, public_row_format: bool
) -> Iterator[Union[Mapping, np.ndarray]]:
table = self._table
if public_row_format:
from ray.data._internal.utils.transform_pyarrow import (
_is_native_tensor_type,
)
if self._max_chunk_size is None:
# Calling _get_max_chunk_size in constructor makes it slow, so we
# are calling it here only when needed.
self._max_chunk_size = _get_max_chunk_size(
table, ARROW_MAX_CHUNK_SIZE_BYTES
)
contains_native_tensor_columns = any(
_is_native_tensor_type(column.type) for column in table.columns
)
for batch in table.to_batches(max_chunksize=self._max_chunk_size):
if contains_native_tensor_columns:
# HACK: For v1 and v2 tensors, we can control what is returned
# by overriding ExtensionScalar.as_py (see ArrowTensorScalar).
# For pyarrow native FixedShapeTensorArrays we cannot, so we
# use _iter_rows_from_batch_with_tensors to handle conversion.
yield from _iter_rows_from_batch_with_tensors(batch)
else:
yield from batch.to_pylist()
else:
num_rows = self.num_rows()
for i in range(num_rows):
yield self._get_row(i)
def filter(self, predicate_expr: "Expr") -> "pyarrow.Table":
"""Filter rows based on a predicate expression."""
if self._table.num_rows == 0:
return self._table
from ray.data._internal.planner.plan_expression.expression_evaluator import (
eval_expr,
)
# Evaluate the expression to get a boolean mask
mask = eval_expr(predicate_expr, self._table)
# Use PyArrow's built-in filter method
return self._table.filter(mask)
def _iter_rows_from_batch_with_tensors(
batch: "pyarrow.RecordBatch",
) -> Iterator[Dict[str, Any]]:
"""Iterate over rows in a batch that may contain native tensor columns.
For pyarrow native FixedShapeTensorArrays, we must manually convert them
to ndarrays which preserve shape/ndim. Without this, FixedShapeTensorArrays
would be translated to contiguous 1d arrays.
See: https://arrow.apache.org/docs/python/generated/pyarrow.FixedShapeTensorArray.html
Args:
batch: A PyArrow RecordBatch that may contain tensor columns.
Yields:
Dict[str, Any]: Dictionaries mapping column names to values for each row.
"""
from ray.data._internal.utils.transform_pyarrow import _is_native_tensor_type
col_values = []
for column in batch.columns:
if _is_native_tensor_type(column.type):
col_values.append(column.to_numpy_ndarray())
else:
col_values.append(column.to_pylist())
for idx in range(batch.num_rows):
yield {name: col[idx] for name, col in zip(batch.column_names, col_values)}
class ArrowBlockColumnAccessor(BlockColumnAccessor):
def __init__(self, col: Union["pyarrow.Array", "pyarrow.ChunkedArray"]):
super().__init__(col)
def count(self, *, ignore_nulls: bool, as_py: bool = True) -> Optional[U]:
import pyarrow.compute as pac
res = pac.count(self._column, mode="only_valid" if ignore_nulls else "all")
return res.as_py() if as_py else res
def sum(self, *, ignore_nulls: bool, as_py: bool = True) -> Optional[U]:
import pyarrow.compute as pac
res = pac.sum(self._column, skip_nulls=ignore_nulls)
return res.as_py() if as_py else res
def min(self, *, ignore_nulls: bool, as_py: bool = True) -> Optional[U]:
import pyarrow.compute as pac
res = pac.min(self._column, skip_nulls=ignore_nulls)
return res.as_py() if as_py else res
def max(self, *, ignore_nulls: bool, as_py: bool = True) -> Optional[U]:
import pyarrow.compute as pac
res = pac.max(self._column, skip_nulls=ignore_nulls)
return res.as_py() if as_py else res
def mean(self, *, ignore_nulls: bool, as_py: bool = True) -> Optional[U]:
import pyarrow.compute as pac
res = pac.mean(self._column, skip_nulls=ignore_nulls)
return res.as_py() if as_py else res
def sum_of_squared_diffs_from_mean(
self, ignore_nulls: bool, mean: Optional[U] = None, as_py: bool = True
) -> Optional[U]:
import pyarrow.compute as pac
# Calculate mean if not provided
if mean is None:
mean = self.mean(ignore_nulls=ignore_nulls)
if mean is None:
return None
res = pac.sum(
pac.power(pac.subtract(self._column, mean), 2), skip_nulls=ignore_nulls
)
return res.as_py() if as_py else res
def quantile(
self, *, q: float, ignore_nulls: bool, as_py: bool = True
) -> Optional[U]:
import pyarrow.compute as pac
array = pac.quantile(self._column, q=q, skip_nulls=ignore_nulls)
# NOTE: That quantile method still returns an array
res = array[0]
return res.as_py() if as_py else res
def unique(self) -> BlockColumn:
import pyarrow.compute as pac
if self.is_composed_of_lists():
# NOTE: Arrow doesn't provide unique kernels for `ListArray`s and
# such, so we rely on Polars to encode and compute unique
# values instead
import polars
return polars.from_arrow(self._column).unique().to_arrow()
return pac.unique(self._column)
def value_counts(self) -> Optional[Dict[str, List]]:
import pyarrow.compute as pac
value_counts: pyarrow.StructArray = pac.value_counts(self._column)
if len(value_counts) == 0:
return None
return {
"values": value_counts.field("values").to_pylist(),
"counts": value_counts.field("counts").to_pylist(),
}
def hash(self) -> BlockColumn:
import polars as pl
df = pl.DataFrame({"col": self._column})
hashes = df.hash_rows().cast(pl.Int64, wrap_numerical=True)
return hashes.to_arrow()
def flatten(self) -> BlockColumn:
import pyarrow.compute as pac
return pac.list_flatten(self._column)
def dropna(self) -> BlockColumn:
import pyarrow.compute as pac
return pac.drop_null(self._column)
def is_composed_of_lists(self) -> bool:
types = (pyarrow.lib.ListType, pyarrow.lib.LargeListType)
return isinstance(self._column.type, types)
def to_pylist(self) -> List[Any]:
return self._column.to_pylist()
def to_numpy(self, zero_copy_only: bool = False) -> np.ndarray:
if get_pyarrow_version() < _MIN_PYARROW_VERSION_TO_NUMPY_ZERO_COPY_ONLY:
if isinstance(
self._column, pyarrow.ChunkedArray
): # NOTE: ChunkedArray in Pyarrow < 13.0.0 does not support ``zero_copy_only``
return self._column.to_numpy()
else:
return self._column.to_numpy(zero_copy_only=zero_copy_only)
return self._column.to_numpy(zero_copy_only=zero_copy_only)
def _to_arrow_compatible_container(self) -> Union[List[Any], "pyarrow.Array"]:
return self._column
@@ -0,0 +1,59 @@
from typing import TYPE_CHECKING, List
from packaging.version import parse as parse_version
try:
import pyarrow
except ImportError:
pyarrow = None
if TYPE_CHECKING:
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
pl = None
# Polars 0.16.8 introduced the `descending` parameter for the `sort` method,
# replacing `reverse`.
# See https://github.com/pola-rs/polars/issues/5429 for more details.
_POLARS_SORT_DESCENDING_MIN_VERSION = parse_version("0.16.8")
def check_polars_installed():
try:
global pl
import polars as pl
except ImportError:
raise ImportError(
"polars not installed. Install with `pip install polars` or set "
"`DataContext.use_polars_sort = False` to fall back to pyarrow"
)
def sort(table: "pyarrow.Table", sort_key: "SortKey") -> "pyarrow.Table":
check_polars_installed()
df = pl.from_arrow(table)
if parse_version(pl.__version__) >= _POLARS_SORT_DESCENDING_MIN_VERSION:
return df.sort(
sort_key.get_columns(), descending=sort_key.get_descending()
).to_arrow()
else:
return df.sort(
sort_key.get_columns(), reverse=sort_key.get_descending()
).to_arrow()
def concat_and_sort(
blocks: List["pyarrow.Table"], sort_key: "SortKey", *, promote_types: bool = False
) -> "pyarrow.Table":
check_polars_installed()
blocks = [pl.from_arrow(block) for block in blocks]
if parse_version(pl.__version__) >= _POLARS_SORT_DESCENDING_MIN_VERSION:
df = pl.concat(blocks).sort(
sort_key.get_columns(), descending=sort_key.get_descending()
)
else:
df = pl.concat(blocks).sort(
sort_key.get_columns(), reverse=sort_key.get_descending()
)
return df.to_arrow()
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import itertools
from typing import List, Union
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
def _counts_to_offsets(counts: pa.Array) -> pa.Array:
"""Convert per-row counts to list offsets via cumulative sum."""
cumsum = pc.cumulative_sum(counts)
return pa.concat_arrays([pa.array([0], type=cumsum.type), cumsum])
def _combine_as_list_array(
column_values: List[Union[pa.Array, pa.ChunkedArray]] | None = None,
*,
offsets: pa.Array | None = None,
values: pa.Array | None = None,
is_large: bool = False,
null_mask: pa.Array | None = None,
) -> pa.Array:
"""Combine list arrays or build a list array from offsets and values."""
if column_values is None:
if offsets is None or values is None:
raise ValueError(
"Either column_values or both offsets and values must be provided."
)
else:
lens = [len(v) for v in column_values]
offsets_type = pa.int64() if is_large else pa.int32()
offsets = pa.array(np.concatenate([[0], np.cumsum(lens)]), type=offsets_type)
values = pa.concat_arrays(
itertools.chain(
*[
v.chunks if isinstance(v, pa.ChunkedArray) else [v]
for v in column_values
]
)
)
offsets_type = pa.int64() if is_large else pa.int32()
offsets = pc.cast(offsets, offsets_type)
array_cls = pa.LargeListArray if is_large else pa.ListArray
list_type = pa.large_list(values.type) if is_large else pa.list_(values.type)
return array_cls.from_arrays(offsets, values, list_type, mask=null_mask)
@@ -0,0 +1,47 @@
import time
from collections import deque
from typing import Deque, Tuple
class TimeWindowAverageCalculator:
"""A utility class to calculate the average of values reported in a time window."""
def __init__(
self,
window_s: float,
):
assert window_s > 0
# Time window in seconds.
self._window_s = window_s
# Buffer the values reported in the time window, each value is a
# tuple of (time, value).
self._values: Deque[Tuple[float, float]] = deque()
# Sum of all values in the time window.
self._sum: float = 0
def report(self, value: float):
"""Report a value to the calculator."""
now = time.time()
self._values.append((now, value))
self._sum += value
self._trim(now)
def get_average(self):
"""Get the average of values reported in the time window,
or None if no values reported in the last time window.
"""
self._trim(time.time())
if len(self._values) == 0:
return None
return self._sum / len(self._values)
def _trim(self, now):
"""Remove the values reported outside of the time window."""
while len(self._values) > 0 and now - self._values[0][0] > self._window_s:
_, value = self._values.popleft()
self._sum -= value
# Set sum to 0 if there are no values to avoid accumulated floating-point error
# from repeated += / -= operations.
if len(self._values) == 0:
self._sum = 0
+375
View File
@@ -0,0 +1,375 @@
import warnings
from typing import Optional
import numpy as np
from ray.data._internal.arrow_block import ArrowBlockAccessor
from ray.data._internal.arrow_ops import transform_pyarrow
from ray.data._internal.arrow_ops.transform_pyarrow import try_combine_chunked_columns
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.execution.util import memory_string
from ray.data._internal.util import get_total_obj_store_mem_on_node
from ray.data.block import Block, BlockAccessor
from ray.util import log_once
# Delay compaction until the shuffle buffer has reached this ratio over the min
# shuffle buffer size. Setting this to 1 minimizes memory usage, at the cost of
# frequent compactions. Setting this to higher values increases memory usage but
# reduces compaction frequency.
SHUFFLE_BUFFER_COMPACTION_RATIO = 1.5
# Ratio of remaining compacted rows to shuffle_buffer_min_size at which
# compaction (and re-shuffling of indices) is triggered. Experiments show 0.5
# is a good trade-off between throughput and randomness.
SHUFFLE_BUFFER_COMPACTION_THRESHOLD = 0.5
class BatcherInterface:
def add(self, block: Block):
"""Add a block to the block buffer.
Args:
block: Block to add to the block buffer.
"""
raise NotImplementedError()
def done_adding(self) -> bool:
"""Indicate to the batcher that no more blocks will be added to the buffer."""
raise NotImplementedError()
def has_batch(self) -> bool:
"""Whether this Batcher has any full batches."""
raise NotImplementedError()
def has_any(self) -> bool:
"""Whether this Batcher has any data."""
raise NotImplementedError()
def next_batch(self) -> Block:
"""Get the next batch from the block buffer.
Returns:
A batch represented as a Block.
"""
raise NotImplementedError()
class Batcher(BatcherInterface):
"""Chunks blocks into batches."""
# Implementation Note: When there are multiple batches per block, this batcher will
# slice off and return each batch and add the remaining block back to the buffer
# instead of optimally slicing and returning all batches from the block at once.
# This will result in extra (and nested) block slicing. However, since slices are
# zero-copy views, we sacrifice what should be a small performance hit for better
# readability.
def __init__(self, batch_size: Optional[int], ensure_copy: bool = False):
"""
Construct a batcher that yields batches of batch_sizes rows.
Args:
batch_size: The size of batches to yield.
ensure_copy: Whether batches are always copied from the underlying base
blocks (not zero-copy views).
"""
self._batch_size = batch_size
self._buffer = []
self._buffer_size = 0
self._done_adding = False
self._ensure_copy = ensure_copy
def add(self, block: Block):
"""Add a block to the block buffer.
Note empty block is not added to buffer.
Args:
block: Block to add to the block buffer.
"""
if BlockAccessor.for_block(block).num_rows() > 0:
self._buffer.append(block)
self._buffer_size += BlockAccessor.for_block(block).num_rows()
def done_adding(self) -> bool:
"""Indicate to the batcher that no more blocks will be added to the batcher."""
self._done_adding = True
def has_batch(self) -> bool:
"""Whether this Batcher has any full batches."""
return self.has_any() and (
self._batch_size is None or self._buffer_size >= self._batch_size
)
def has_any(self) -> bool:
"""Whether this Batcher has any data."""
return self._buffer_size > 0
def next_batch(self) -> Block:
"""Get the next batch from the block buffer.
Returns:
A batch represented as a Block.
"""
assert self.has_batch() or (self._done_adding and self.has_any())
needs_copy = self._ensure_copy
# If no batch size, short-circuit.
if self._batch_size is None:
assert len(self._buffer) == 1
block = self._buffer[0]
if needs_copy:
# Copy block if needing to ensure fresh batch copy.
block = BlockAccessor.for_block(block)
block = block.slice(0, block.num_rows(), copy=True)
self._buffer = []
self._buffer_size = 0
return block
output = DelegatingBlockBuilder()
leftover = []
needed = self._batch_size
for block in self._buffer:
accessor = BlockAccessor.for_block(block)
if needed <= 0:
# We already have a full batch, so add this block to
# the leftovers.
leftover.append(block)
elif accessor.num_rows() <= needed:
output.add_block(accessor.to_block())
needed -= accessor.num_rows()
else:
# Try de-fragmenting table in case its columns
# have too many chunks (potentially hindering performance of
# subsequent slicing operation)
if isinstance(accessor, ArrowBlockAccessor):
accessor = BlockAccessor.for_block(
transform_pyarrow.try_combine_chunked_columns(
block, min_chunks_to_combine=1
)
)
# We only need part of the block to fill out a batch.
output.add_block(accessor.slice(0, needed, copy=False))
# Add the rest of the block to the leftovers.
leftover.append(accessor.slice(needed, accessor.num_rows(), copy=False))
needed = 0
# Move the leftovers into the block buffer so they're the first
# blocks consumed on the next batch extraction.
self._buffer = leftover
self._buffer_size -= self._batch_size
needs_copy = needs_copy and not output.will_build_yield_copy()
batch = output.build()
if needs_copy:
# Need to ensure that the batch is a fresh copy.
batch = BlockAccessor.for_block(batch)
batch = batch.slice(0, batch.num_rows(), copy=True)
return batch
class ShufflingBatcher(BatcherInterface):
"""Chunks blocks into shuffled batches, using a local in-memory shuffle buffer.
Uses an **incremental index** approach: on each compaction a permutation
array is generated over the buffer rows, and each ``next_batch()`` call
gathers a small slice of that permutation via ``take``.
Properties of this approach:
* **Memory-efficient** -- the data buffer is kept as-is; only a
lightweight int64 index array is allocated on top.
* **Smooth per-batch latency** -- each ``take`` operates on a small
slice of indices, so per-batch work is short and uniform, making it
easy to hide behind prefetch threads.
Example with ``batch_size=3`` and a 9-row buffer::
buffer: [A, B, C, D, E, F, G, H, I]
indices: [4, 7, 1, 0, 8, 3, 6, 2, 5] # random permutation
next_batch() -> take([4, 7, 1]) -> [E, H, B] # batch_head 0 -> 3
next_batch() -> take([0, 8, 3]) -> [A, I, D] # batch_head 3 -> 6
next_batch() -> take([6, 2, 5]) -> [G, C, F] # batch_head 6 -> 9
"""
def __init__(
self,
batch_size: Optional[int],
shuffle_buffer_min_size: int,
shuffle_seed: Optional[int] = None,
):
"""Constructs a random-shuffling block batcher.
Args:
batch_size: Record batch size.
shuffle_buffer_min_size: Minimum number of rows that must be in the local
in-memory shuffle buffer in order to yield a batch. When there are no
more rows to be added to the buffer, the number of rows in the buffer
*will* decrease below this value while yielding the remaining batches,
and the final batch may have less than ``batch_size`` rows. Increasing
this will improve the randomness of the shuffle but may increase the
latency to the first batch.
shuffle_seed: The seed to use for the local random shuffle.
"""
if batch_size is None:
raise ValueError("Must specify a batch_size if using a local shuffle.")
self._batch_size = batch_size
self._rng = np.random.default_rng(shuffle_seed)
if shuffle_buffer_min_size < batch_size:
# Round it up internally to `batch_size` since our algorithm requires it.
# This is harmless since it only offers extra randomization.
shuffle_buffer_min_size = batch_size
self._shuffle_buffer_min_size = shuffle_buffer_min_size
self._min_rows_to_yield_batch = max(
1, int(shuffle_buffer_min_size * SHUFFLE_BUFFER_COMPACTION_THRESHOLD)
)
self._min_rows_to_trigger_compaction = int(
shuffle_buffer_min_size * SHUFFLE_BUFFER_COMPACTION_RATIO
)
self._builder = DelegatingBlockBuilder()
self._shuffle_buffer: Block = None
self._shuffled_indices: Optional[np.ndarray] = None
self._batch_head = 0
self._done_adding = False
self._total_object_store_nbytes = get_total_obj_store_mem_on_node()
self._total_num_rows_added = 0
self._total_nbytes_added = 0
def add(self, block: Block):
"""Add a block to the shuffle buffer.
Note empty block is not added to buffer.
Args:
block: Block to add to the shuffle buffer.
"""
# Because Arrow tables are memory mapped, blocks in the builder reside in object
# store memory and not local heap memory. So, if you specify a large buffer size
# and there isn't enough object store memory on the node, you encounter
# spilling.
if (
self._estimated_min_nbytes_in_buffers is not None
and self._estimated_min_nbytes_in_buffers > self._total_object_store_nbytes
and log_once("shuffle_buffer_mem_warning")
):
warnings.warn(
"The node you're iterating on has "
f"{memory_string(self._total_object_store_nbytes)} object "
"store memory, but the shuffle buffer is estimated to use "
f"{memory_string(self._estimated_min_nbytes_in_buffers)}. If you don't "
f"decrease the shuffle buffer size from "
f"{self._shuffle_buffer_min_size} rows, you might encounter spilling."
)
block_accessor = BlockAccessor.for_block(block)
if block_accessor.num_rows() > 0:
self._builder.add_block(block)
self._total_num_rows_added += block_accessor.num_rows()
self._total_nbytes_added += block_accessor.size_bytes()
@property
def _average_row_nbytes(self) -> Optional[int]:
"""Return the average number of bytes per row added to this batcher."""
return (
self._total_nbytes_added // self._total_num_rows_added
if self._total_num_rows_added > 0
else None
)
@property
def _estimated_min_nbytes_in_buffers(self) -> Optional[int]:
"""Return the estimated minimum number of bytes across all buffers.
This includes data in both the compacted and uncompacted buffers.
"""
if self._average_row_nbytes is None:
return None
return self._average_row_nbytes * self._min_rows_to_trigger_compaction
def done_adding(self) -> bool:
"""Indicate to the batcher that no more blocks will be added to the batcher.
No more blocks should be added to the batcher after calling this.
"""
self._done_adding = True
def has_any(self) -> bool:
"""Whether this batcher has any data."""
return self._num_rows() > 0
def has_batch(self) -> bool:
"""Whether this batcher has any batches."""
num_rows = self._num_rows()
if not self._done_adding:
# Delay pulling of batches until the buffer is large enough in order to
# amortize compaction overhead.
return num_rows >= self._batch_size and (
self._num_compacted_rows() >= self._min_rows_to_yield_batch
or num_rows - self._batch_size >= self._min_rows_to_trigger_compaction
)
else:
return num_rows >= self._batch_size
def _num_rows(self) -> int:
"""Return the total number of rows that haven't been yielded yet.
This includes rows in both the compacted and uncompacted buffers.
"""
return self._num_compacted_rows() + self._num_uncompacted_rows()
def _num_compacted_rows(self) -> int:
"""Return number of unyielded rows in the compacted buffer."""
if self._shuffle_buffer is None:
return 0
return max(0, len(self._shuffled_indices) - self._batch_head)
def _num_uncompacted_rows(self) -> int:
"""Return number of unyielded rows in the uncompacted buffer."""
return self._builder.num_rows()
def next_batch(self) -> Block:
"""Get the next shuffled batch from the shuffle buffer.
Returns:
A batch represented as a Block.
"""
assert self.has_batch() or (self._done_adding and self.has_any())
if self._num_uncompacted_rows() > 0 and (
self._done_adding
or self._num_compacted_rows() <= self._min_rows_to_yield_batch
):
if self._shuffle_buffer is not None and self._batch_head < len(
self._shuffled_indices
):
remaining_indices = self._shuffled_indices[self._batch_head :]
remaining_block = BlockAccessor.for_block(self._shuffle_buffer).take(
remaining_indices
)
self._builder.add_block(remaining_block)
self._shuffle_buffer = self._builder.build()
accessor = BlockAccessor.for_block(self._shuffle_buffer)
if isinstance(accessor, ArrowBlockAccessor):
self._shuffle_buffer = try_combine_chunked_columns(
self._shuffle_buffer, min_chunks_to_combine=1
)
accessor = BlockAccessor.for_block(self._shuffle_buffer)
num_rows = accessor.num_rows()
self._shuffled_indices = self._rng.permutation(num_rows)
self._builder = DelegatingBlockBuilder()
self._batch_head = 0
assert self._shuffle_buffer is not None
assert self._shuffled_indices is not None
remaining = len(self._shuffled_indices) - self._batch_head
batch_size = min(self._batch_size, remaining)
batch_indices = self._shuffled_indices[
self._batch_head : self._batch_head + batch_size
]
self._batch_head += batch_size
return BlockAccessor.for_block(self._shuffle_buffer).take(batch_indices)
@@ -0,0 +1,3 @@
from ray.data._internal.block_batching.block_batching import batch_blocks
__all__ = ["batch_blocks"]
@@ -0,0 +1,101 @@
from typing import Callable, Iterator, Optional, TypeVar
from ray.data._internal.block_batching.interfaces import ResolvedBlock
from ray.data._internal.block_batching.util import (
_MappingIterator,
blocks_to_batches,
collate,
format_batches,
)
from ray.data._internal.stats import DatasetStats
from ray.data.block import Block, DataBatch
T = TypeVar("T")
def batch_blocks(
blocks: Iterator[Block],
*,
stats: Optional[DatasetStats] = None,
batch_size: Optional[int] = None,
batch_format: str = "default",
drop_last: bool = False,
collate_fn: Optional[Callable[[DataBatch], DataBatch]] = None,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
) -> Iterator[DataBatch]:
"""Create formatted batches of data from 1 or more blocks.
This function takes in an iterator of already fetched blocks. Consequently, this
function doesn't support block prefetching.
"""
# TODO: make stage timings optional at _BatchingIterator so this
# shim can be removed. map() avoids holding block references.
wrapped_blocks = map(lambda b: ResolvedBlock(block=b), blocks)
# Build the processing pipeline
batch_iter = format_batches(
blocks_to_batches(
block_iter=wrapped_blocks,
stats=stats,
batch_size=batch_size,
drop_last=drop_last,
shuffle_buffer_min_size=shuffle_buffer_min_size,
shuffle_seed=shuffle_seed,
ensure_copy=ensure_copy,
),
batch_format=batch_format,
stats=stats,
ensure_copy=ensure_copy,
)
if collate_fn is not None:
batch_iter = collate(batch_iter, collate_fn=collate_fn, stats=stats)
return _UserTimingIterator(
_MappingIterator(batch_iter, lambda batch: batch.data), stats
)
class _UserTimingIterator(Iterator[DataBatch]):
def __init__(self, iter: Iterator[DataBatch], stats: Optional[DatasetStats]):
self._iter = iter
self._stats = stats
self._active_timer = None
def __iter__(self) -> Iterator[DataBatch]:
return self
def __next__(self) -> DataBatch:
# Since we're tracking time spent in user-code, we stop
# the timer immediately when `__next__` is called
self._stop_timer()
try:
res = next(self._iter)
# Reset timer and return
#
# NOTE: It's crucial that we reset the timer only after we
# retrieved the result to avoid starting the timer before
# we retrieve the next value
self._reset_timer()
return res
except StopIteration:
self._stop_timer()
raise
def _stop_timer(self):
if not self._stats:
return
if self._active_timer:
self._active_timer.__exit__(None, None, None)
self._active_timer = None
def _reset_timer(self):
if not self._stats:
return
self._active_timer = self._stats.iter_user_s.timer()
self._active_timer.__enter__()
@@ -0,0 +1,134 @@
import abc
from dataclasses import dataclass, field
from typing import Any, Iterable, List, Optional, Tuple
from ray.data._internal.stats import IterationStage, TimeSpan
from ray.data.block import Block, DataBatch
from ray.types import ObjectRef
@dataclass
class BlockStageTimings:
"""Per-block timing for production_wait + data_transfer.
Both fields are always populated when ``stage_timings`` is set on a
``ResolvedBlock``; the outer ``ResolvedBlock.stage_timings`` Optional
encodes "no timing recorded" (e.g. blocks already resolved before
entering the pipeline).
"""
production_wait: TimeSpan
data_transfer: TimeSpan
@dataclass
class ResolvedBlock:
"""A resolved block paired with its per-block stage timings.
``stage_timings`` is None when no timing was recorded (e.g. blocks
already resolved before entering the pipeline).
"""
block: Block
stage_timings: Optional[BlockStageTimings] = None
@dataclass
class BatchStageTimings:
"""Per-batch timing windows for each iteration stage.
Fetch stages (production_wait, data_transfer) accumulate one span per
block, so they are ``List[TimeSpan]``. Other stages run at most once
per batch, so they are ``Optional[TimeSpan]``. ``stages()`` yields
``List[TimeSpan]`` for all stages (single spans wrapped in a 1-element
list) so ``_attribute_blocked_time`` can use uniform overlap logic.
"""
production_wait: List[TimeSpan] = field(default_factory=list)
data_transfer: List[TimeSpan] = field(default_factory=list)
batching: Optional[TimeSpan] = None
format: Optional[TimeSpan] = None
collate: Optional[TimeSpan] = None
finalize: Optional[TimeSpan] = None
def stages(self) -> Iterable[Tuple[IterationStage, List[TimeSpan]]]:
"""Yield (stage, spans) pairs, wrapping single spans in a list."""
return (
(IterationStage.PRODUCTION_WAIT, self.production_wait),
(IterationStage.DATA_TRANSFER, self.data_transfer),
(
IterationStage.BATCHING,
[self.batching] if self.batching is not None else [],
),
(IterationStage.FORMAT, [self.format] if self.format is not None else []),
(
IterationStage.COLLATE,
[self.collate] if self.collate is not None else [],
),
(
IterationStage.FINALIZE,
[self.finalize] if self.finalize is not None else [],
),
)
def accumulate_block_timings(self, src: BlockStageTimings) -> None:
"""Accumulate a block's fetch timings into this batch's lists.
A boundary block whose rows span multiple batches is attributed
to the first batch it lands in.
"""
self.production_wait.append(src.production_wait)
self.data_transfer.append(src.data_transfer)
@dataclass
class BatchMetadata:
"""Metadata associated with a batch.
Attributes:
batch_idx: The global index of this batch so that downstream operations can
maintain ordering.
num_rows: Number of rows in this batch (for ``iter_rows_total``).
stage_timings: Per-stage timing windows.
"""
batch_idx: int
num_rows: int = 0
stage_timings: BatchStageTimings = field(default_factory=BatchStageTimings)
@dataclass
class Batch:
"""A batch of data.
Attributes:
metadata: Metadata associated with this batch.
data: The batch of data.
"""
metadata: BatchMetadata
data: DataBatch
class CollatedBatch(Batch):
"""A batch of collated data.
Attributes:
data: The batch of data which is the output of a user provided collate_fn
Therefore, the type of this data can be Any.
"""
data: Any
class BlockPrefetcher(metaclass=abc.ABCMeta):
"""Interface for prefetching blocks."""
@abc.abstractmethod
def prefetch_blocks(self, blocks: List[ObjectRef[Block]]):
"""Prefetch the provided blocks to this node."""
pass
def stop(self):
"""Stop prefetching and release resources."""
pass
@@ -0,0 +1,558 @@
import collections
import time
from contextlib import contextmanager, nullcontext
from typing import Any, Callable, Dict, Iterator, List, Optional
import ray
from ray._common.utils import env_integer
from ray.data._internal.block_batching.interfaces import (
Batch,
BlockPrefetcher,
)
from ray.data._internal.block_batching.util import (
ActorBlockPrefetcher,
WaitBlockPrefetcher,
blocks_to_batches,
collate,
finalize_batches,
format_batches,
iter_threaded,
resolve_block_refs,
)
from ray.data._internal.execution.interfaces.ref_bundle import RefBundle
from ray.data._internal.memory_tracing import trace_deallocation
from ray.data._internal.stats import DatasetStats, TimeSpan, _StatsManager
from ray.data.block import Block, DataBatch
from ray.data.context import DataContext
from ray.types import ObjectRef
DEFAULT_FORMAT_THREADPOOL_NUM_WORKERS = env_integer(
"RAY_DATA_MAX_FORMAT_THREADPOOL_NUM_WORKERS", 4
)
def _merged_duration(
spans: List["TimeSpan"], blocked_start_s: float, blocked_end_s: float
) -> float:
"""Total time ``spans`` overlap with ``[blocked_start_s, blocked_end_s]``,
with overlapping spans merged so nothing is double-counted."""
intervals = []
for s in spans:
lo = max(s.start_s, blocked_start_s)
hi = min(s.end_s, blocked_end_s)
if hi > lo:
intervals.append((lo, hi))
if not intervals:
return 0.0
intervals.sort()
merged = [intervals[0]]
for i in range(1, len(intervals)):
lo, hi = intervals[i]
if lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
else:
merged.append((lo, hi))
return sum(hi - lo for lo, hi in merged)
class BatchIterator:
"""Defines an iterator pipeline to convert a stream of block object references
into a stream of formatted batches ready to be consumed by the user.
This takes a block iterator and creates batch_size batches, slicing,
unioning, shuffling, prefetching, and formatting blocks as needed.
This involves both pipeline parallelism (e.g. prefetching)
and data parallelism (e.g. threadpool operations):
If prefetch_batches=2, these are all the batches in flight:
[User thread] trains on Batch 0
- [Fetch thread] Batch 1 finalization + move to output queue
- [Worker thread 1] Batch 2 formatting + collating
- [Worker thread 2] Batch 3 formatting + collating
- [Raylet] Batches 4 + 5 fetched to local object store memory
At any point in time there are prefetch_batches+1 batches in local heap memory.
And the next set of prefetch_batches in local object store memory.
The actual steps are as follows:
In a single async thread, do the following:
1. Trigger Ray local prefetching of `prefetch_batches` worth of block object
references.
2. Resolve (i.e. call `ray.get()`) on the block references.
3. Perform the necessary batch slicing to construct full batches, possibly
shuffling if necessary.
4. Then, in a threadpool consisting of `prefetch_batches` threads:
a. Format the batches to the provided batch format.
b. Apply the collate function.
5. If preserve_order, restore the original batch order from the
threadpool output.
6. Finalize each of the (now ordered) collated batches.
Args:
ref_bundles: An iterator over RefBundles.
stats: DatasetStats object to record timing and other statistics.
dataset_tag: The tag of the dataset to record timing and other statistics.
clear_block_after_read: Whether to clear the block from object store
manually (i.e. without waiting for Python's automatic GC) after it
is read. Doing so will reclaim memory faster and hence reduce the
memory footprint. However, the caller has to ensure the safety, i.e.
the block will never be accessed again.
batch_size: Record batch size, or None to let the system pick.
batch_format: The format in which to return each batch.
Specify "default" to use the current block format (promoting
Arrow to pandas automatically), "pandas" to
select ``pandas.DataFrame`` or "pyarrow" to select
``pyarrow.Table``, or None to use entire blocks
as batches. Default is "default".
drop_last: Whether to drop the last batch if it's incomplete.
collate_fn: A function to apply to each data batch before returning it.
finalize_fn: A function to apply to each data batch after it has been collated.
This function is not run in a threadpool so it can be used for
memory-intensive operations such as GPU preloading.
shuffle_buffer_min_size: If non-None, the data will be randomly shuffled using a
local in-memory shuffle buffer, and this value will serve as the minimum
number of rows that must be in the local in-memory shuffle buffer in order
to yield a batch.
shuffle_seed: The seed to use for the local random shuffle.
ensure_copy: Whether batches are always copied from the underlying base
blocks (not zero-copy views).
prefetch_batches: The number of batches to fetch ahead of the current batch to
process. If set to greater than 0, a separate thread will be used to fetch
the specified amount of formatted batches from blocks. This improves
performance for non-CPU bound UDFs, allowing batch fetching compute and
formatting to be overlapped with the UDF. Defaults to 1.
prefetch_bytes_callback: A callback to report prefetched bytes to the executor's
resource manager.
preserve_order: Whether to maintain the original order that the batches
were formed from the blocks (e.g., the input block order).
This only takes effect in the case that the format/collate threadpool
has more than one thread and the output batches have non-deterministic
order.
"""
UPDATE_METRICS_INTERVAL_S: float = 5.0
def __init__(
self,
ref_bundles: Iterator[RefBundle],
*,
stats: Optional[DatasetStats] = None,
dataset_tag: Optional[str] = None,
clear_block_after_read: bool = False,
batch_size: Optional[int] = None,
batch_format: Optional[str] = "default",
drop_last: bool = False,
collate_fn: Optional[Callable[[DataBatch], Any]] = None,
finalize_fn: Optional[Callable[[Any], Any]] = None,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
prefetch_batches: int = 1,
prefetch_bytes_callback: Optional[Callable[[int], None]] = None,
preserve_order: bool = False,
):
self._ref_bundles = ref_bundles
self._stats = stats
self._dataset_tag = dataset_tag
self._batch_size = batch_size
self._batch_format = batch_format
self._drop_last = drop_last
self._collate_fn = collate_fn
self._finalize_fn = finalize_fn
self._shuffle_buffer_min_size = shuffle_buffer_min_size
self._shuffle_seed = shuffle_seed
self._ensure_copy = ensure_copy
self._prefetch_batches = prefetch_batches
self._prefetch_bytes_callback = prefetch_bytes_callback
self._preserve_order = preserve_order
self._eager_free = (
clear_block_after_read and DataContext.get_current().eager_free
)
actor_prefetcher_enabled = (
prefetch_batches > 0
and DataContext.get_current().actor_prefetcher_enabled
and not ray.util.client.ray.is_connected()
)
self._prefetcher = (
ActorBlockPrefetcher()
if actor_prefetcher_enabled
else WaitBlockPrefetcher()
)
self._yielded_first_batch = False
# This stores the last time we updated the metrics.
# This allows us to update metrics on some interval,
# by comparing it with the current timestamp.
self._metrics_last_updated: float = 0.0
def _prefetch_blocks(
self, ref_bundles: Iterator[RefBundle]
) -> Iterator[ObjectRef[Block]]:
return prefetch_batches_locally(
ref_bundles=ref_bundles,
prefetcher=self._prefetcher,
num_batches_to_prefetch=self._prefetch_batches,
batch_size=self._batch_size,
eager_free=self._eager_free,
stats=self._stats,
)
def _resolve_block_refs(
self, block_refs: Iterator[ObjectRef[Block]]
) -> Iterator[Any]:
return resolve_block_refs(block_ref_iter=block_refs, stats=self._stats)
def _blocks_to_batches(self, blocks: Iterator[Block]) -> Iterator[Batch]:
return blocks_to_batches(
block_iter=blocks,
stats=self._stats,
batch_size=self._batch_size,
drop_last=self._drop_last,
shuffle_buffer_min_size=self._shuffle_buffer_min_size,
shuffle_seed=self._shuffle_seed,
ensure_copy=self._ensure_copy,
)
def _format_batches(self, batches: Iterator[Batch]) -> Iterator[Batch]:
num_threadpool_workers = min(
DEFAULT_FORMAT_THREADPOOL_NUM_WORKERS, self._prefetch_batches
)
return _format_in_threadpool(
batch_iter=batches,
stats=self._stats,
batch_format=self._batch_format,
collate_fn=self._collate_fn,
num_threadpool_workers=num_threadpool_workers,
ensure_copy=self._ensure_copy,
)
def _finalize_batches(
self,
batch_iter: Iterator[Batch],
) -> Iterator[Batch]:
if self._finalize_fn is None:
return batch_iter
return finalize_batches(
batch_iter, finalize_fn=self._finalize_fn, stats=self._stats
)
def _restore_original_batch_order(
self, batches: Iterator[Batch]
) -> Iterator[Batch]:
return restore_original_order(batches)
def _pipeline(self, ref_bundles: Iterator[RefBundle]) -> Iterator[Batch]:
# Step 1: Prefetch logical batches locally.
block_iter = self._prefetch_blocks(ref_bundles)
# Step 2: Resolve the blocks.
block_iter = self._resolve_block_refs(block_iter)
# Step 3: Batch and shuffle the resolved blocks.
batch_iter = self._blocks_to_batches(block_iter)
# Step 4: Format and collate the batches in a threadpool.
batch_iter = self._format_batches(batch_iter)
# Step 5 (optional): Restore the original order of the batches
# if preserve_order is True, in the case that the format/collate threadpool
# shuffles around the batches non-deterministically.
# NOTE: This should happen before finalize_fn so the reorder buffer
# holds CPU batches rather than finalize_fn outputs (e.g., GPU tensors).
if self._preserve_order:
batch_iter = self._restore_original_batch_order(batch_iter)
# Step 6: Finalize the batches (e.g., move to GPU).
batch_iter = self._finalize_batches(batch_iter)
yield from batch_iter
def _iter_batches(self) -> Iterator[DataBatch]:
"""Pull batches from the pipeline and yield batch data.
Captures the training thread's blocked window around each ``next()``
call and attributes it to pipeline stages via
``_attribute_blocked_time``.
"""
batch_iter = iter_threaded(self._ref_bundles, fn=self._pipeline)
self.before_epoch_start()
while True:
with self.get_next_batch_context():
blocked_start_s = time.perf_counter()
try:
batch = next(batch_iter)
except StopIteration:
break
blocked_end_s = time.perf_counter()
self._attribute_blocked_time(batch, blocked_start_s, blocked_end_s)
with self.yield_batch_context(batch):
yield batch.data
self.after_epoch_end()
def _attribute_blocked_time(
self, batch: Batch, blocked_start_s: float, blocked_end_s: float
) -> None:
"""Attribute per-stage blocked time via overlap with the training window.
Each stage's spans on ``batch.metadata.stage_timings`` are intersected
with the training thread's blocked window ``[blocked_start_s,
blocked_end_s]``. Overlapping spans are merged first, so the result
is the total time the stage was active during the stall (no
double-counting).
Limitation: only the yielded batch's spans are attributed. Other
in-flight batches (being processed by background threads) may also
overlap with the training stall window but are not counted.
TODO: track in-flight batches and union their spans for complete
attribution. The current implementation suffices for capturing
data-loading bottlenecks.
TODO: reorder buffer wait under ``preserve_order`` is unattributed
(per-stage spans are recorded at format/collate completion, before
the batch leaves ``restore_original_order``).
Args:
batch: Batch whose per-stage timings should be attributed.
blocked_start_s: perf_counter() just before next().
blocked_end_s: perf_counter() just after next() returned.
"""
if self._stats is None:
return
timings = batch.metadata.stage_timings
for stage, spans in timings.stages():
overlap_s = _merged_duration(spans, blocked_start_s, blocked_end_s)
if overlap_s > 0:
self._stats.get_blocked_timer(stage).add(overlap_s)
self._stats.iter_batches_total += 1
self._stats.iter_rows_total += batch.metadata.num_rows
def __iter__(self) -> Iterator[DataBatch]:
return self._iter_batches()
def before_epoch_start(self):
self._yielded_first_batch = False
def after_epoch_end(self):
# Report 0 prefetched bytes at the end of iteration.
if self._prefetch_bytes_callback is not None:
self._prefetch_bytes_callback(0)
if self._stats is None:
return
_StatsManager.update_iteration_metrics(self._stats, self._dataset_tag)
@contextmanager
def get_next_batch_context(self):
"""Context around ``next(batch_iter)``: tracks total blocked time
and time-to-first-batch."""
try:
if self._stats:
# Always track total blocked time
total_timer = self._stats.iter_total_blocked_s.timer()
# Also track the time until the first batch is ready
first_batch_ready_timer = (
self._stats.iter_time_to_first_batch_s.timer()
if not self._yielded_first_batch
else nullcontext()
)
with total_timer, first_batch_ready_timer:
yield
else:
yield
finally:
self._yielded_first_batch = True
@contextmanager
def yield_batch_context(self, batch: Batch):
"""Context around yielding a batch to the user: tracks user time
and periodically flushes metrics."""
with self._stats.iter_user_s.timer() if self._stats else nullcontext():
yield
# Report prefetched bytes to the executor's resource manager.
if self._prefetch_bytes_callback is not None and self._stats is not None:
self._prefetch_bytes_callback(self._stats.iter_prefetched_bytes)
if self._stats is None:
return
now = time.time()
if (now - self._metrics_last_updated) > self.UPDATE_METRICS_INTERVAL_S:
_StatsManager.update_iteration_metrics(self._stats, self._dataset_tag)
self._metrics_last_updated = now
def _format_in_threadpool(
batch_iter: Iterator[Batch],
stats: DatasetStats,
batch_format: Optional[str],
collate_fn: Optional[Callable[[DataBatch], Any]],
num_threadpool_workers: int,
ensure_copy: bool = False,
) -> Iterator[Batch]:
"""Executes the batching, formatting, and collation logic in a threadpool.
Args:
batch_iter: An iterator over logical batches.
stats: DatasetStats object to record timing and other statistics.
batch_format: The format in which to return each batch.
Specify "default" to use the current block format (promoting
Arrow to pandas automatically), "pandas" to
select ``pandas.DataFrame`` or "pyarrow" to select
``pyarrow.Table``, or None to use entire blocks
as batches.
collate_fn: A function to apply to each data batch before returning it.
num_threadpool_workers: The number of threads to use in the threadpool.
ensure_copy: Whether batches are always copied from the underlying base
blocks (not zero-copy views).
Returns:
An iterator over batches with formatting and collation applied.
"""
def threadpool_computations_format_collate(
batch_iter: Iterator[Batch],
) -> Iterator[Batch]:
# Step 4a: Format the batches.
formatted_batch_iter = format_batches(
batch_iter, batch_format=batch_format, stats=stats, ensure_copy=ensure_copy
)
# Step 4b: Apply the collate function if applicable.
if collate_fn is not None:
formatted_batch_iter = collate(
formatted_batch_iter, collate_fn=collate_fn, stats=stats
)
return formatted_batch_iter
if num_threadpool_workers > 0:
# Output order is non-deterministic across workers and is restored
# downstream by `restore_original_order`.
collated_iter = iter_threaded(
base_iterator=batch_iter,
fn=threadpool_computations_format_collate,
num_workers=num_threadpool_workers,
output_buffer_size=num_threadpool_workers,
)
else:
collated_iter = threadpool_computations_format_collate(batch_iter)
return collated_iter
def prefetch_batches_locally(
ref_bundles: Iterator[RefBundle],
prefetcher: BlockPrefetcher,
num_batches_to_prefetch: int,
batch_size: Optional[int],
eager_free: bool = False,
stats: Optional[DatasetStats] = None,
) -> Iterator[ObjectRef[Block]]:
"""Given an iterator of batched RefBundles, returns an iterator over the
corresponding block references while prefetching `num_batches_to_prefetch`
batches in advance.
Args:
ref_bundles: An iterator over batched RefBundles.
prefetcher: The prefetcher to use.
num_batches_to_prefetch: The number of batches to prefetch ahead of the
current batch during the scan.
batch_size: User specified batch size, or None to let the system pick.
eager_free: Whether to eagerly free the object reference from the object store.
stats: Dataset stats object used to store ref bundle retrieval time.
Yields:
Block: Block references (as ObjectRefs), in order.
"""
def get_next_ref_bundle() -> RefBundle:
with stats.iter_get_ref_bundles_s.timer() if stats else nullcontext():
return next(ref_bundles)
sliding_window = collections.deque()
current_window_size = 0
if num_batches_to_prefetch <= 0:
if stats:
stats.iter_prefetched_bytes = 0
for ref_bundle in ref_bundles:
for block_ref in ref_bundle.block_refs:
yield block_ref
return
if batch_size is not None:
num_rows_to_prefetch = num_batches_to_prefetch * batch_size
else:
num_rows_to_prefetch = None
# Create and fetch the initial window.
# Stop adding if the number of rows in this window is greater than requested
# batch size, or if the batch size is None and the number of blocks in this window
# is greater than requested batches to prefetch.
while (batch_size is not None and current_window_size < num_rows_to_prefetch) or (
batch_size is None and len(sliding_window) < num_batches_to_prefetch
):
try:
next_ref_bundle = get_next_ref_bundle()
sliding_window.extend(next_ref_bundle.blocks)
current_window_size += next_ref_bundle.num_rows()
except StopIteration:
break
prefetcher.prefetch_blocks([entry.ref for entry in sliding_window])
if stats:
stats.iter_prefetched_bytes = sum(
entry.metadata.size_bytes or 0 for entry in sliding_window
)
while sliding_window:
entry = sliding_window.popleft()
current_window_size -= entry.metadata.num_rows
if batch_size is None or current_window_size < num_rows_to_prefetch:
try:
next_ref_bundle = get_next_ref_bundle()
for next_entry in next_ref_bundle.blocks:
sliding_window.append(next_entry)
current_window_size += next_entry.metadata.num_rows
prefetcher.prefetch_blocks([entry.ref for entry in sliding_window])
except StopIteration:
pass
if stats:
stats.iter_prefetched_bytes = sum(
entry.metadata.size_bytes or 0 for entry in sliding_window
)
yield entry.ref
trace_deallocation(entry.ref, loc="iter_batches", free=eager_free)
prefetcher.stop()
def restore_original_order(batch_iter: Iterator[Batch]) -> Iterator[Batch]:
"""Restores the original order of the provided `batch_iter`
This function will yield items from `base_iterator` in the correct order based on
each batch's batch_idx. All indexes are expected to be unique.
`batch_iter` is expected to not have any missing indexes. All indexes from 0 to len
(base_iterator) must be present.
"""
next_index_required = 0
buffer: Dict[int, Batch] = {}
for batch in batch_iter:
assert batch.metadata.batch_idx not in buffer
buffer[batch.metadata.batch_idx] = batch
while next_index_required in buffer:
yield buffer.pop(next_index_required)
next_index_required += 1
while next_index_required in buffer:
yield buffer.pop(next_index_required)
next_index_required += 1
@@ -0,0 +1,561 @@
import dataclasses
import functools
import logging
import queue
import threading
import time
from typing import (
Any,
Callable,
Generator,
Generic,
Iterator,
List,
Optional,
Tuple,
TypeVar,
)
import ray
from ray.actor import ActorHandle
from ray.data._internal.batcher import Batcher, ShufflingBatcher
from ray.data._internal.block_batching.interfaces import (
Batch,
BatchMetadata,
BatchStageTimings,
BlockPrefetcher,
BlockStageTimings,
CollatedBatch,
ResolvedBlock,
)
from ray.data._internal.stats import DatasetStats, TimeSpan, _maybe_time
from ray.data.block import Block, BlockAccessor, DataBatch
from ray.types import ObjectRef
logger = logging.getLogger(__name__)
T = TypeVar("T")
U = TypeVar("U")
I = TypeVar("I")
O = TypeVar("O")
_SENTINEL = object()
def iter_threaded(
base_iterator: Iterator[T],
fn: Callable[[Iterator[T]], Iterator[U]],
num_workers: int = 1,
output_buffer_size: int = 1,
) -> Generator[U, None, None]:
"""Apply ``fn`` to ``base_iterator`` across ``num_workers`` background
threads, yielding results through a bounded queue.
Workers share ``base_iterator`` under a lock (so it may be a stateful,
non-thread-safe generator) and run ``fn`` concurrently. With
``num_workers > 1`` the output order is not preserved and must be restored
downstream by the consumer.
Invariant: the number of output-queue items + items in-flight in workers is
bounded by ``output_buffer_size``.
Workers reserve an output buffer slot before pulling from ``fn``, ensuring
they don't run ``fn`` (and hold the result) while waiting for queue space.
When the consumer stops early (``break``, ``.close()``, or GC), workers
are signaled via a stop event so they don't leak. Note: a hanging
``fn`` cannot be interrupted, so ``fn`` must terminate or raise within
bounded time per element. For example, the user function should have
timeouts if doing blocking I/O.
Args:
base_iterator: Iterator consumed (under a lock) by the workers.
fn: Transform applied by each worker to its view of ``base_iterator``.
num_workers: Number of background worker threads.
output_buffer_size: Max number of items held by the output-queue
+ in-flight in the workers.
"""
if num_workers < 1:
raise ValueError("num_workers must be at least 1.")
if output_buffer_size < 1:
raise ValueError("output_buffer_size must be at least 1.")
stopped = threading.Event()
result_queue: queue.Queue = queue.Queue()
slots = threading.Semaphore(output_buffer_size)
iter_lock = threading.Lock()
def _locked_iter() -> Iterator[T]:
while True:
with iter_lock:
if stopped.is_set():
return
try:
item = next(base_iterator)
except StopIteration:
return
yield item
def _acquire_slot() -> bool:
# Block until a slot is acquired or the consumer has stopped.
while not stopped.is_set():
if slots.acquire(timeout=0.1):
return True
return False
remaining_workers = num_workers
remaining_lock = threading.Lock()
def _worker():
nonlocal remaining_workers
slot_acquired = False
try:
# Construct `fn_iter` inside the try so any exception during
# construction propagates to the consumer via the outer except.
fn_iter = fn(_locked_iter())
while True:
slot_acquired = _acquire_slot()
if not slot_acquired:
break
item = next(fn_iter)
result_queue.put(item)
# The consumer pulling from the result_queue will release the slot.
# Resetting here prevents the finally block from double-releasing.
slot_acquired = False
except StopIteration:
pass
except Exception as e:
# Handle errors in `fn` by propagating them to the consumer.
if not stopped.is_set():
result_queue.put(e)
finally:
if slot_acquired:
slots.release()
with remaining_lock:
remaining_workers -= 1
is_last = remaining_workers == 0
# Signal the consumer that all thread workers have exhausted their input.
if is_last and not stopped.is_set():
result_queue.put(_SENTINEL)
worker_threads = [
threading.Thread(target=_worker, name="iter_threaded", daemon=True)
for _ in range(num_workers)
]
for t in worker_threads:
t.start()
try:
while True:
item = result_queue.get()
if item is _SENTINEL:
break
if isinstance(item, Exception):
raise item
# Release one slot at yield time so a worker can run `fn` for the next item.
slots.release()
yield item
finally:
stopped.set()
class _MappingIterator(Iterator[O], Generic[I, O]):
"""Iterator that applies a transform function to each element.
Unlike a generator, local variables in __next__ go out of scope when the method
returns, avoiding holding references to yielded values.
"""
def __init__(self, input_iter: Iterator[I], transform_fn: Callable[[I], O]):
self._input_iter = input_iter
self._transform_fn = transform_fn
def __iter__(self) -> "_MappingIterator[I, O]":
return self
def __next__(self) -> O:
return self._transform_fn(next(self._input_iter))
def _calculate_ref_hits(refs: List[ObjectRef[Any]]) -> Tuple[int, int, int]:
"""Given a list of object references, returns how many are already on the local
node, how many require fetching from another node, and how many have unknown
locations. If `DataContext.get_current().enable_get_object_locations_for_metrics` is
False, this will return `(0, 0, 0)` as getting object locations is disabled."""
current_node_id = ray.get_runtime_context().get_node_id()
ctx = ray.data.DataContext.get_current()
if ctx.enable_get_object_locations_for_metrics:
locs = ray.experimental.get_object_locations(refs)
nodes: List[List[str]] = [loc["node_ids"] for loc in locs.values()]
hits = sum(current_node_id in node_ids for node_ids in nodes)
unknowns = sum(1 for node_ids in nodes if not node_ids)
misses = len(nodes) - hits - unknowns
return hits, misses, unknowns
return 0, 0, 0
def resolve_block_refs(
block_ref_iter: Iterator[ObjectRef[Block]],
stats: Optional[DatasetStats] = None,
) -> Iterator[ResolvedBlock]:
"""Resolve block references via ``ray.get()`` and attach per-block
stage timings.
production_wait is captured manually (no Timer accumulation) to avoid
double-counting with ``prefetch_batches_locally``'s
``iter_get_ref_bundles_s`` timer; data_transfer uses ``_maybe_time``
normally (no overlap with other timers).
Args:
block_ref_iter: An iterator over block object references.
stats: An optional stats object to record block hits, misses, and
cumulative ray.get() time.
Yields:
ResolvedBlock: Each resolved block with its stage timings.
"""
hits = 0
misses = 0
unknowns = 0
while True:
production_wait_start = time.perf_counter() if stats else 0.0
try:
block_ref = next(block_ref_iter)
except StopIteration:
break
production_wait_span = (
TimeSpan(start_s=production_wait_start, end_s=time.perf_counter())
if stats
else None
)
current_hit, current_miss, current_unknown = _calculate_ref_hits([block_ref])
hits += current_hit
misses += current_miss
unknowns += current_unknown
# data_transfer: cross-node transfer via ray.get().
# TODO(amogkam): batch multiple references in one ray.get() call.
with _maybe_time(stats.iter_get_s if stats else None) as data_transfer_span:
block = ray.get(block_ref)
if stats:
assert production_wait_span is not None
assert data_transfer_span is not None
stage_timings = BlockStageTimings(
production_wait=production_wait_span,
data_transfer=data_transfer_span,
)
else:
stage_timings = None
yield ResolvedBlock(block=block, stage_timings=stage_timings)
if stats:
stats.iter_blocks_local = hits
stats.iter_blocks_remote = misses
stats.iter_unknown_location = unknowns
def blocks_to_batches(
block_iter: Iterator[ResolvedBlock],
stats: Optional[DatasetStats] = None,
batch_size: Optional[int] = None,
drop_last: bool = False,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
) -> Iterator[Batch]:
"""Given an iterator over blocks, returns an iterator over batches."""
return _BatchingIterator(
block_iter,
stats=stats,
batch_size=batch_size,
drop_last=drop_last,
shuffle_buffer_min_size=shuffle_buffer_min_size,
shuffle_seed=shuffle_seed,
ensure_copy=ensure_copy,
)
class _BatchingIterator(Iterator[Batch]):
"""Iterator that converts blocks to batches.
Unlike a generator, local variables in __next__ go out of scope when the method
returns, avoiding holding references to yielded values.
"""
def __init__(
self,
block_iter: Iterator[ResolvedBlock],
stats: Optional[DatasetStats] = None,
batch_size: Optional[int] = None,
drop_last: bool = False,
shuffle_buffer_min_size: Optional[int] = None,
shuffle_seed: Optional[int] = None,
ensure_copy: bool = False,
):
self._block_iter = block_iter
self._stats = stats
self._drop_last = drop_last
self._global_counter = 0
self._done_adding = False
# Accumulates per-block stage timings until a batch is yielded.
self._pending_timings = BatchStageTimings()
if shuffle_buffer_min_size is not None:
self._batcher = ShufflingBatcher(
batch_size=batch_size,
shuffle_buffer_min_size=shuffle_buffer_min_size,
shuffle_seed=shuffle_seed,
)
else:
self._batcher = Batcher(batch_size=batch_size, ensure_copy=ensure_copy)
def __iter__(self) -> "_BatchingIterator":
return self
def __next__(self) -> Batch:
# Try to get a batch from current batcher state
while True:
can_yield = self._batcher.has_batch() or (
self._batcher.has_any() and self._done_adding and not self._drop_last
)
if can_yield:
with _maybe_time(
self._stats.iter_next_batch_s if self._stats else None
) as span:
next_batch = self._batcher.next_batch()
self._pending_timings.batching = span
res = Batch(
metadata=BatchMetadata(
batch_idx=self._global_counter,
num_rows=BlockAccessor.for_block(next_batch).num_rows(),
stage_timings=self._pending_timings,
),
data=next_batch,
)
self._pending_timings = BatchStageTimings()
self._global_counter += 1
return res
elif not self._done_adding:
# If can't yield try adding more blocks
try:
# NOTE: Block ref is released immediately
block_result = next(self._block_iter)
if block_result.stage_timings is not None:
self._pending_timings.accumulate_block_timings(
block_result.stage_timings
)
self._batcher.add(block_result.block)
except StopIteration:
self._batcher.done_adding()
self._done_adding = True
else:
# In case when
# - We've exhausted input AND
# - There's nothing to yield anymore
#
# We stop the iteration
raise StopIteration
def _format_batch(
batch: Batch,
batch_format: Optional[str],
stats: Optional[DatasetStats],
ensure_copy: bool = False,
) -> Batch:
with _maybe_time(stats.iter_format_batch_s if stats else None) as span:
formatted_data = BlockAccessor.for_block(batch.data).to_batch_format(
batch_format
)
if ensure_copy:
formatted_data = _copy_batch(formatted_data)
batch.metadata.stage_timings.format = span
return dataclasses.replace(batch, data=formatted_data)
def _copy_batch(batch: "DataBatch") -> "DataBatch":
"""Return a copy of a batch, making it writable.
``pa.Array.to_numpy()`` returns read-only arrays by default, so when
a caller passes ``ensure_copy=True`` (i.e. ``zero_copy_batch=False``) and the
block is Arrow, the numpy-format batch must be explicitly copied to give the UDF
writable arrays.
"""
import numpy as np
if isinstance(batch, dict):
# Return a dictionary with the same keys (column names) and values (column numpy arrays),
# with the values copied
return {
k: v.copy() if isinstance(v, np.ndarray) else v for k, v in batch.items()
}
elif isinstance(batch, np.ndarray):
return batch.copy()
return batch
def format_batches(
batch_iter: Iterator[Batch],
batch_format: Optional[str],
stats: Optional[DatasetStats] = None,
ensure_copy: bool = False,
) -> Iterator[Batch]:
"""Given an iterator of batches, returns an iterator of formatted batches."""
return _MappingIterator(
batch_iter,
functools.partial(
_format_batch,
batch_format=batch_format,
stats=stats,
ensure_copy=ensure_copy,
),
)
def _collate_batch(
batch: Batch,
collate_fn: Callable[[DataBatch], Any],
stats: Optional[DatasetStats],
) -> CollatedBatch:
with _maybe_time(stats.iter_collate_batch_s if stats else None) as span:
collated_data = collate_fn(batch.data)
batch.metadata.stage_timings.collate = span
return CollatedBatch(metadata=batch.metadata, data=collated_data)
def collate(
batch_iter: Iterator[Batch],
collate_fn: Optional[Callable[[DataBatch], Any]],
stats: Optional[DatasetStats] = None,
) -> Iterator[CollatedBatch]:
"""Returns an iterator with the provided collate_fn applied to batches."""
if not isinstance(batch_iter, Iterator):
batch_iter = iter(batch_iter)
return _MappingIterator(
batch_iter,
functools.partial(_collate_batch, collate_fn=collate_fn, stats=stats),
)
def _finalize_batch(
batch: CollatedBatch,
finalize_fn: Callable[[Any], Any],
stats: Optional[DatasetStats],
) -> CollatedBatch:
with _maybe_time(stats.iter_finalize_batch_s if stats else None) as span:
finalized_data = finalize_fn(batch.data)
batch.metadata.stage_timings.finalize = span
return dataclasses.replace(batch, data=finalized_data)
def finalize_batches(
batch_iter: Iterator[CollatedBatch],
finalize_fn: Callable[[Any], Any],
stats: Optional[DatasetStats] = None,
) -> Iterator[CollatedBatch]:
"""Returns an iterator with finalize_fn applied to batches."""
if not isinstance(batch_iter, Iterator):
batch_iter = iter(batch_iter)
return _MappingIterator(
batch_iter,
functools.partial(_finalize_batch, finalize_fn=finalize_fn, stats=stats),
)
PREFETCHER_ACTOR_NAMESPACE = "ray.dataset"
class WaitBlockPrefetcher(BlockPrefetcher):
"""Block prefetcher using ray.wait."""
def __init__(self):
self._blocks = []
self._stopped = False
self._condition = threading.Condition()
self._thread = threading.Thread(
target=self._run,
name="Prefetcher",
daemon=True,
)
self._thread.start()
def _run(self):
while not self._stopped:
try:
with self._condition:
if len(self._blocks) == 0:
# Park, waiting for notification that prefetching
# should resume
self._condition.wait()
blocks_to_fetch, self._blocks = self._blocks[:], []
if len(blocks_to_fetch) > 0:
ray.wait(
blocks_to_fetch,
num_returns=1,
# NOTE: We deliberately setting timeout to 0 to avoid
# blocking the fetching thread unnecessarily
timeout=0,
fetch_local=True,
)
except Exception:
logger.exception("Error in prefetcher thread.")
logger.debug("Exiting prefetcher's background thread")
def prefetch_blocks(self, blocks: List[ObjectRef[Block]]):
with self._condition:
if self._stopped:
raise RuntimeError("Prefetcher is stopped.")
self._blocks = blocks
self._condition.notify()
def stop(self):
with self._condition:
if self._stopped:
return
self._stopped = True
self._condition.notify()
def __del__(self):
self.stop()
class ActorBlockPrefetcher(BlockPrefetcher):
"""Block prefetcher using a local actor."""
def __init__(self):
self.prefetch_actor = self._get_or_create_actor_prefetcher()
@staticmethod
def _get_or_create_actor_prefetcher() -> "ActorHandle":
node_id = ray.get_runtime_context().get_node_id()
actor_name = f"dataset-block-prefetcher-{node_id}"
return _BlockPretcher.options(
label_selector={ray._raylet.RAY_NODE_ID_KEY: node_id},
name=actor_name,
namespace=PREFETCHER_ACTOR_NAMESPACE,
get_if_exists=True,
).remote()
def prefetch_blocks(self, blocks: List[ObjectRef[Block]]):
self.prefetch_actor.prefetch.remote(*blocks)
@ray.remote(num_cpus=0)
class _BlockPretcher:
"""Helper actor that prefetches blocks asynchronously."""
def prefetch(self, *blocks) -> None:
pass
@@ -0,0 +1,39 @@
from typing import Generic
from ray.data.block import Block, BlockAccessor, BlockType, T
class BlockBuilder(Generic[T]):
"""A builder class for blocks."""
@staticmethod
def for_block(block: Block) -> "BlockBuilder":
return BlockAccessor.for_block(block).builder()
def add(self, item: T) -> None:
"""Append a single row to the block being built."""
raise NotImplementedError
def add_block(self, block: Block) -> None:
"""Append an entire block to the block being built."""
raise NotImplementedError
def will_build_yield_copy(self) -> bool:
"""Whether building this block will yield a new block copy."""
raise NotImplementedError
def build(self) -> Block:
"""Build the block."""
raise NotImplementedError
def num_rows(self) -> int:
"""Return the number of rows added in the block."""
raise NotImplementedError
def get_estimated_memory_usage(self) -> int:
"""Return the estimated memory usage so far in bytes."""
raise NotImplementedError
def block_type(self) -> BlockType:
"""Return the block type."""
raise NotImplementedError
@@ -0,0 +1,71 @@
import enum
import logging
import os
from typing import TYPE_CHECKING
from .base_autoscaling_coordinator import (
AutoscalingCoordinator,
ResourceDict,
ResourceRequestPriority,
)
from .base_cluster_autoscaler import ClusterAutoscaler
from .default_autoscaling_coordinator import (
DefaultAutoscalingCoordinator,
get_or_create_autoscaling_coordinator,
)
from .default_cluster_autoscaler_v2 import DefaultClusterAutoscalerV2
if TYPE_CHECKING:
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.data._internal.execution.streaming_executor_state import Topology
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
CLUSTER_AUTOSCALER_ENV_KEY = "RAY_DATA_CLUSTER_AUTOSCALER"
DEFAULT_CLUSTER_AUTOSCALER_VERSION = "V2"
class ClusterAutoscalerVersion(str, enum.Enum):
V2 = "V2"
def create_cluster_autoscaler(
topology: "Topology",
resource_manager: "ResourceManager",
data_context: "DataContext",
*,
execution_id: str,
) -> ClusterAutoscaler:
resource_limits = data_context.execution_options.resource_limits
label_selector = data_context.execution_options.label_selector
cluster_autoscaler_version = os.environ.get(
CLUSTER_AUTOSCALER_ENV_KEY, DEFAULT_CLUSTER_AUTOSCALER_VERSION
)
logger.debug(f"Using cluster autoscaler version: {cluster_autoscaler_version!r}")
if cluster_autoscaler_version == ClusterAutoscalerVersion.V2:
return DefaultClusterAutoscalerV2(
resource_manager,
execution_id=execution_id,
resource_limits=resource_limits,
label_selector=label_selector,
)
else:
valid_values = [version.value for version in ClusterAutoscalerVersion]
raise ValueError(
f"Cluster autoscaler version of {cluster_autoscaler_version} isn't a valid "
f"option. Valid options are: {valid_values}."
)
__all__ = [
"ClusterAutoscaler",
# Objects related to the `AutoscalingCoordinator`.
"AutoscalingCoordinator",
"DefaultAutoscalingCoordinator",
"get_or_create_autoscaling_coordinator",
"ResourceDict",
"ResourceRequestPriority",
]
@@ -0,0 +1,58 @@
import abc
from enum import Enum
from typing import Dict, List, Optional
ResourceDict = Dict[str, float]
class ResourceRequestPriority(Enum):
"""Priority of a resource request."""
LOW = -10
MEDIUM = 0
HIGH = 10
class AutoscalingCoordinator(abc.ABC):
@abc.abstractmethod
def request_resources(
self,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
) -> None:
"""Request cluster resources.
The requested resources should represent the full set of resources needed,
not just the incremental amount.
Args:
resources: The requested resources. This should match the format accepted
by `ray.autoscaler.sdk.request_resources`.
expire_after_s: Time in seconds after which this request will expire.
The requester is responsible for periodically sending new requests
to avoid the request being purged.
request_remaining: If true, after allocating requested resources to each
requester, remaining resources will also be allocated to this requester.
priority: The priority of the request. Higher value means higher priority.
label_selectors: Optional per-bundle label selectors, one per entry in
``resources``. Forwarded to the autoscaler as
``bundle_label_selectors``.
"""
...
@abc.abstractmethod
def cancel_request(self) -> None:
"""Cancel the resource request from the requester."""
...
@abc.abstractmethod
def get_allocated_resources(self) -> List[ResourceDict]:
"""Get the allocated resources for the requester.
Returns:
A list of dictionaries representing the allocated resources bundles.
"""
...
@@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.data._internal.execution.interfaces.execution_options import (
ExecutionResources,
)
@DeveloperAPI
class ClusterAutoscaler(ABC):
"""Abstract interface for Ray Data cluster autoscaler."""
@abstractmethod
def try_trigger_scaling(self):
"""Try trigger autoscaling.
This method will be called each time when StreamingExecutor makes
a scheduling decision. A subclass should override this method to
handle the autoscaling of the cluster.
"""
...
@abstractmethod
def on_executor_shutdown(self):
"""Callback when the StreamingExecutor is shutting down."""
...
@abstractmethod
def get_total_resources(self) -> "ExecutionResources":
"""Get the total resources that are available to this data execution."""
...
@@ -0,0 +1,577 @@
import copy
import functools
import logging
import threading
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
import ray
import ray.exceptions
from .base_autoscaling_coordinator import (
AutoscalingCoordinator,
ResourceDict,
ResourceRequestPriority,
)
from ray._common.utils import env_bool
from ray.data._internal.execution.util import memory_string
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
logger = logging.getLogger(__name__)
HEAD_NODE_RESOURCE_LABEL = "node:__internal_head__"
_RESOURCE_LOG_KEYS = ("CPU", "GPU", "memory", "object_store_memory")
_RESOURCE_LOG_MEMORY_KEYS = {"memory", "object_store_memory"}
# Label key the cluster autoscaler uses to bucket nodes by subcluster.
# Hardcoded so all components agree without per-Dataset configuration.
SUBCLUSTER_LABEL_KEY = "ray-subcluster"
# Sentinel for "no subcluster" — used as both a node-label fallback and
# the bucket key for unlabeled nodes in ``_cluster_node_resources``.
DEFAULT_SUBCLUSTER: Optional[str] = None
RAY_DATA_AUTOSCALING_COORDINATOR_LOG_TRACEBACK = env_bool(
"RAY_DATA_AUTOSCALING_COORDINATOR_LOG_TRACEBACK", True
)
def _format_resource_value_for_log(resource_name: str, value: float) -> str:
"""Format a numerical resource value to a human-readable string.
Args:
resource_name: The resource name.
value: The resource value.
Returns:
A human-readable string.
"""
if resource_name in _RESOURCE_LOG_MEMORY_KEYS:
return memory_string(value)
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
def _format_resource_bundle_for_log(bundle: ResourceDict) -> str:
"""Format a resource bundle to a human-readable string.
Drops custom resource keys (e.g. ``anyscale/...``, ``node:...``) and
zero-valued resources, keeping only the standard keys in ``_RESOURCE_LOG_KEYS``.
Args:
bundle: The resource bundle to format.
Returns:
A human-readable string, e.g. ``"{CPU: 8, memory: 32.0GiB}"``.
Example:
>>> from ray.data._internal.util import GiB
>>> _format_resource_bundle_for_log({"CPU": 8, "GPU": 0, "memory": 32 * GiB})
'{CPU: 8, memory: 32.0GiB}'
"""
resources = []
for resource_name in _RESOURCE_LOG_KEYS:
value = bundle.get(resource_name, 0)
if value == 0:
continue
resources.append(
f"{resource_name}: {_format_resource_value_for_log(resource_name, value)}"
)
return "{" + ", ".join(resources) + "}"
def _format_resources_for_log(resources: List[ResourceDict]) -> str:
"""Format and aggregate resource bundles for logging.
Bundles that format to the same string (after dropping custom/zero-valued
resources) are collapsed into a single ``N x {...}`` entry.
Args:
resources: The resource bundles to format.
Returns:
A human-readable string, e.g. ``"[2 x {CPU: 1}, 1 x {GPU: 1}]"``.
Example:
>>> _format_resources_for_log([{"CPU": 1}, {"CPU": 1}, {"GPU": 1}])
'[2 x {CPU: 1}, 1 x {GPU: 1}]'
"""
bundle_counts: Dict[str, int] = {}
for resource in resources:
bundle = _format_resource_bundle_for_log(resource)
if bundle == "{}":
continue
bundle_counts[bundle] = bundle_counts.get(bundle, 0) + 1
return (
"["
+ ", ".join(f"{count} x {bundle}" for bundle, count in bundle_counts.items())
+ "]"
)
@dataclass
class OngoingRequest:
"""Represents an ongoing resource request from a requester."""
# The time when the request was first received.
first_request_time: float
# Requested resources.
requested_resources: List[ResourceDict]
# The expiration time of the request.
expiration_time: float
# If true, after allocating requested resources to each requester,
# remaining resources will also be allocated to this requester.
request_remaining: bool
# The priority of the request, higher value means higher priority.
priority: int
# Resources that are already allocated to the requester.
allocated_resources: List[ResourceDict]
# Per-bundle label selectors, parallel to ``requested_resources``.
# Empty dicts mean no label constraint on that bundle. Required to have
# the same length as ``requested_resources``.
requested_label_selectors: List[Dict[str, str]]
def __lt__(self, other):
"""Used to sort requests when allocating resources.
Higher priority first, then earlier first_request_time first.
"""
if self.priority != other.priority:
return self.priority > other.priority
return self.first_request_time < other.first_request_time
class DefaultAutoscalingCoordinator(AutoscalingCoordinator):
"""Non-blocking client-side proxy for the _AutoscalingCoordinatorActor.
Not thread-safe; all methods must be called from a single thread.
Create one instance per requester. Multiple instances sharing the same
``requester_id`` will have diverging caches and break the FIFO ordering
guarantee that ``request_resources`` and ``get_allocated_resources`` rely on.
"""
def __init__(
self,
requester_id: str,
autoscaling_coordinator_actor=None, # For testing only: injects an actor instead of using the shared named singleton.
subcluster_selector: Optional[Dict[str, str]] = None,
):
self._requester_id = requester_id
# Label selector keyed by ``SUBCLUSTER_LABEL_KEY`` pinning this
# requester to a single subcluster.
self._subcluster_selector = subcluster_selector
self._cached_allocated_resources: List[ResourceDict] = []
# In-flight get_allocated_resources ref, or None if no request is pending.
self._pending_allocated_resources: Optional[ray.ObjectRef] = None
if autoscaling_coordinator_actor is not None:
# Bypass the cached_property by injecting the actor directly.
# Used in tests to avoid the shared named actor.
self.__dict__["_autoscaling_coordinator"] = autoscaling_coordinator_actor
@functools.cached_property
def _autoscaling_coordinator(self):
# Lazy: avoids creating the actor in __init__.
return get_or_create_autoscaling_coordinator()
def request_resources(
self,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
) -> None:
"""Fire-and-forget: submit a resource request to the coordinator actor.
Actor-side errors are not surfaced to the caller.
"""
self._autoscaling_coordinator.request_resources.remote(
requester_id=self._requester_id,
resources=resources,
expire_after_s=expire_after_s,
request_remaining=request_remaining,
priority=priority,
label_selectors=label_selectors,
subcluster_selector=self._subcluster_selector,
)
def cancel_request(self) -> None:
"""Fire-and-forget: cancel a resource request on the coordinator actor.
Also clears client-side state (pending ref and cached allocation) so
a subsequent ``get_allocated_resources`` call returns a fresh result
rather than stale data from a prior pipeline run.
"""
self._pending_allocated_resources = None
self._cached_allocated_resources = []
self._autoscaling_coordinator.cancel_request.remote(self._requester_id)
def get_allocated_resources(self) -> List[ResourceDict]:
"""Return allocated resources without blocking.
Submits an async RPC and immediately returns the last cached result.
The cache is updated the next time the pending RPC completes.
Because the actor processes calls in FIFO order, the result always
reflects state after all previously submitted ``request_resources`` calls
to the same actor.
On actor errors, returns the cached value and logs a warning; never raises.
"""
ref = self._pending_allocated_resources
if ref is not None:
ready, _ = ray.wait([ref], timeout=0)
if ready:
self._pending_allocated_resources = None
try:
self._cached_allocated_resources = ray.get(ref, timeout=0)
except ray.exceptions.RayError:
logger.warning(
f"Failed to get allocated resources for {self._requester_id};"
" falling back to the cached value."
" If this persists, file a GitHub issue.",
exc_info=RAY_DATA_AUTOSCALING_COORDINATOR_LOG_TRACEBACK,
)
# Submit a new request if none is currently in-flight
# (first call, or the previous request completed or errored).
if self._pending_allocated_resources is None:
self._pending_allocated_resources = (
self._autoscaling_coordinator.get_allocated_resources.remote(
self._requester_id,
)
)
return self._cached_allocated_resources
def _default_send_resources_request(
bundles: List[ResourceDict],
label_selectors: Optional[List[Dict[str, str]]] = None,
) -> None:
"""Default ``send_resources_request`` implementation for the actor."""
ray.autoscaler.sdk.request_resources(
bundles=bundles, bundle_label_selectors=label_selectors
)
class _AutoscalingCoordinatorActor:
"""An actor to coordinate autoscaling resource requests from different components.
This actor is responsible for:
* Merging received requests and dispatching them to Ray Autoscaler.
* Allocating cluster resources to the requesters.
"""
TICK_INTERVAL_S = 20
def __init__(
self,
get_current_time: Callable[[], float] = time.time,
send_resources_request: Callable[
[List[ResourceDict], Optional[List[Dict[str, str]]]], None
] = _default_send_resources_request,
get_cluster_nodes: Callable[[], List[Dict]] = ray.nodes,
):
self._get_current_time = get_current_time
self._send_resources_request = send_resources_request
self._get_cluster_nodes = get_cluster_nodes
self._ongoing_reqs: Dict[str, OngoingRequest] = {}
# Map from requester id to its subcluster selector.
self._subcluster_selectors: Dict[str, Optional[Dict[str, str]]] = {}
# Node resources bucketed by their ``SUBCLUSTER_LABEL_KEY`` value.
# Nodes without the key fall under ``DEFAULT_SUBCLUSTER``.
self._cluster_node_resources: Dict[Optional[str], List[ResourceDict]] = {}
# Lock for thread-safe access to shared state from the background
self._lock = threading.Lock()
self._update_cluster_node_resources()
# This is an actor, so the following check should always be True.
# It's only needed for unit tests.
if ray.is_initialized():
# Start a thread to perform periodical operations.
def tick_thread_run():
while True:
time.sleep(self.TICK_INTERVAL_S)
self._tick()
self._tick_thread = threading.Thread(target=tick_thread_run, daemon=True)
self._tick_thread.start()
def _tick(self):
"""Used to perform periodical operations, e.g., purge expired requests,
merge and send requests, check cluster resource updates, etc."""
with self._lock:
self._merge_and_send_requests()
self._update_cluster_node_resources()
self._reallocate_resources()
def request_resources(
self,
requester_id: str,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
subcluster_selector: Optional[Dict[str, str]] = None,
) -> None:
logger.debug(
"Received request from %s: %s "
"(label_selectors=%s, subcluster_selector=%s).",
requester_id,
resources,
label_selectors,
subcluster_selector,
)
if label_selectors is None:
label_selectors = [{} for _ in resources]
elif len(label_selectors) != len(resources):
raise ValueError(
f"label_selectors length ({len(label_selectors)}) must match "
f"resources length ({len(resources)})."
)
if subcluster_selector and label_selectors:
req_subcluster = subcluster_selector.get(SUBCLUSTER_LABEL_KEY)
for i, sel in enumerate(label_selectors):
bundle_subcluster = sel.get(SUBCLUSTER_LABEL_KEY)
if (
bundle_subcluster is not None
and bundle_subcluster != req_subcluster
):
raise ValueError(
f"Bundle {i} label_selector targets subcluster "
f"{bundle_subcluster!r}, but requester is registered to "
f"{req_subcluster!r}. Per-bundle cross-subcluster "
f"allocation is not supported."
)
with self._lock:
now = self._get_current_time()
request_updated = False
old_req = self._ongoing_reqs.get(requester_id)
if old_req is not None:
if request_remaining != old_req.request_remaining:
raise ValueError(
"Cannot change request_remaining flag of an ongoing request."
)
if priority.value != old_req.priority:
raise ValueError("Cannot change priority of an ongoing request.")
if (
requester_id in self._subcluster_selectors
and self._subcluster_selectors[requester_id] != subcluster_selector
):
raise ValueError(
"Cannot change subcluster_selector of an ongoing request "
f"from {self._subcluster_selectors[requester_id]!r} to "
f"{subcluster_selector!r}."
)
request_updated = (
resources != old_req.requested_resources
or label_selectors != old_req.requested_label_selectors
)
old_req.requested_resources = resources
old_req.requested_label_selectors = label_selectors
old_req.expiration_time = now + expire_after_s
else:
request_updated = True
self._ongoing_reqs[requester_id] = OngoingRequest(
first_request_time=now,
requested_resources=resources,
requested_label_selectors=label_selectors,
request_remaining=request_remaining,
priority=priority.value,
expiration_time=now + expire_after_s,
allocated_resources=[],
)
# Write subcluster after all validations so a rejected call
# never leaves the registry on a new subcluster.
self._subcluster_selectors[requester_id] = subcluster_selector
if request_updated:
# If the request has updated, immediately send
# a new request and reallocate resources.
self._merge_and_send_requests()
self._reallocate_resources()
def cancel_request(
self,
requester_id: str,
):
logger.debug("Canceling request for %s.", requester_id)
with self._lock:
if requester_id not in self._ongoing_reqs:
return
del self._ongoing_reqs[requester_id]
self._subcluster_selectors.pop(requester_id, None)
self._merge_and_send_requests()
self._reallocate_resources()
def _purge_expired_requests(self):
now = self._get_current_time()
live = {
requester_id: req
for requester_id, req in self._ongoing_reqs.items()
if req.expiration_time > now
}
for expired_id in self._ongoing_reqs.keys() - live.keys():
self._subcluster_selectors.pop(expired_id, None)
self._ongoing_reqs = live
def _merge_and_send_requests(self):
"""Merge requests and send them to Ray Autoscaler.
Each bundle's forwarded selector is the union of its per-bundle
``requested_label_selectors`` entry and the requester's
``subcluster_selector``. The subcluster pin wins on key conflict,
so the autoscaler always sees the correct subcluster regardless
of what the per-bundle selectors contain.
"""
self._purge_expired_requests()
merged_req: List[ResourceDict] = []
merged_selectors: List[Dict[str, str]] = []
for requester_id, req in self._ongoing_reqs.items():
merged_req.extend(req.requested_resources)
subcluster_selector = self._subcluster_selectors.get(requester_id) or {}
for per_bundle in req.requested_label_selectors:
merged_selectors.append({**per_bundle, **subcluster_selector})
if any(merged_selectors):
self._send_resources_request(merged_req, label_selectors=merged_selectors)
else:
self._send_resources_request(merged_req)
def get_allocated_resources(self, requester_id: str) -> List[ResourceDict]:
"""Get the allocated resources for the requester."""
with self._lock:
if requester_id not in self._ongoing_reqs:
return []
return self._ongoing_reqs[requester_id].allocated_resources
def _maybe_subtract_resources(self, res1: ResourceDict, res2: ResourceDict) -> bool:
"""If res2<=res1, subtract res2 from res1 in-place, and return True.
Otherwise return False."""
if any(res1.get(key, 0) < res2[key] for key in res2):
return False
for key in res2:
if key in res1:
res1[key] -= res2[key]
return True
def _update_cluster_node_resources(self) -> bool:
"""Update cluster resources bucketed by subcluster. Return True if changed."""
def _is_node_eligible(node):
# Exclude dead nodes.
if not node["Alive"]:
return False
resources = node["Resources"]
# Exclude the head node if it doesn't have CPUs and GPUs,
# because the object store is not usable.
if HEAD_NODE_RESOURCE_LABEL in resources and (
resources.get("CPU", 0) == 0 and resources.get("GPU", 0) == 0
):
return False
return True
nodes = list(filter(_is_node_eligible, self._get_cluster_nodes()))
nodes = sorted(nodes, key=lambda node: node.get("NodeID", ""))
cluster_node_resources: Dict[Optional[str], List[ResourceDict]] = {}
for node in nodes:
# Safeguard against case where the value of Labels is None.
labels = node.get("Labels") or {}
subcluster = labels.get(SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER)
cluster_node_resources.setdefault(subcluster, []).append(node["Resources"])
if cluster_node_resources == self._cluster_node_resources:
return False
logger.debug("Cluster resources updated: %s.", cluster_node_resources)
self._cluster_node_resources = cluster_node_resources
return True
def _reallocate_resources(self):
"""Reallocate cluster resources.
Each requester's subcluster comes from its ``subcluster_selector``.
A requester without one is eligible only for the ``None`` bucket.
"""
now = self._get_current_time()
cluster_node_resources: Dict[Optional[str], List[ResourceDict]] = copy.deepcopy(
self._cluster_node_resources
)
live_items = [
(req_id, req)
for req_id, req in self._ongoing_reqs.items()
if req.expiration_time >= now
]
live_items.sort(key=lambda item: item[1])
def _subcluster_of(requester_id: str) -> Optional[str]:
selector = self._subcluster_selectors.get(requester_id)
return (selector or {}).get(SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER)
# TODO(hchen): Optimize the following triple loop.
for requester_id, ongoing_req in live_items:
ongoing_req.allocated_resources = []
subcluster = _subcluster_of(requester_id)
for bundle in ongoing_req.requested_resources:
for node_resource in cluster_node_resources.get(subcluster, []):
if self._maybe_subtract_resources(node_resource, bundle):
ongoing_req.allocated_resources.append(bundle)
break
# Allocate remaining resources. Multiple concurrent requesters in
# the same subcluster split that subcluster's leftovers equally.
remaining_items = [
(req_id, req) for req_id, req in live_items if req.request_remaining
]
for subcluster, node_resources in cluster_node_resources.items():
eligible = [
req
for req_id, req in remaining_items
if _subcluster_of(req_id) == subcluster
]
if not eligible:
continue
for node_resource in node_resources:
# Integer division may leave some resources unallocated.
divided = {k: v // len(eligible) for k, v in node_resource.items()}
if not any(v > 0 for v in divided.values()):
continue
for r in eligible:
r.allocated_resources.append(divided)
if logger.isEnabledFor(logging.DEBUG):
msg = "Allocated resources:\n"
for requester_id, ongoing_req in self._ongoing_reqs.items():
allocated_resources_log_str = _format_resources_for_log(
ongoing_req.allocated_resources
)
msg += f"Requester {requester_id}: {allocated_resources_log_str}\n"
logger.debug(msg)
_get_or_create_lock = threading.Lock()
def get_or_create_autoscaling_coordinator():
"""Get or create the AutoscalingCoordinator actor."""
# Create the actor on the local node,
# to reduce network overhead.
scheduling_strategy = NodeAffinitySchedulingStrategy(
ray.get_runtime_context().get_node_id(),
soft=False,
)
actor_cls = ray.remote(num_cpus=0, max_restarts=-1, max_task_retries=-1)(
_AutoscalingCoordinatorActor
).options(
name="AutoscalingCoordinator",
namespace="AutoscalingCoordinator",
get_if_exists=True,
lifetime="detached",
scheduling_strategy=scheduling_strategy,
)
# NOTE: Need the following lock, because Ray Core doesn't allow creating the same
# actor from multiple threads simultaneously.
with _get_or_create_lock:
return actor_cls.remote()
@@ -0,0 +1,428 @@
import logging
import math
import time
from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
import ray
from .base_autoscaling_coordinator import AutoscalingCoordinator, ResourceDict
from .default_autoscaling_coordinator import (
DEFAULT_SUBCLUSTER,
SUBCLUSTER_LABEL_KEY,
DefaultAutoscalingCoordinator,
)
from .resource_utilization_gauge import (
ResourceUtilizationGauge,
RollingLogicalUtilizationGauge,
)
from .util import cap_resource_request_to_limits, is_autoscaling_enabled
from ray._common.utils import env_bool, env_float, env_integer
from ray.data._internal.cluster_autoscaler import ClusterAutoscaler
from ray.data._internal.execution.interfaces.execution_options import ExecutionResources
from ray.data._internal.execution.util import memory_string
from ray.data._internal.util import GiB
if TYPE_CHECKING:
from ray.data._internal.execution.resource_manager import ResourceManager
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _NodeResourceSpec:
cpu: int
gpu: int
mem: int
def __post_init__(self):
assert isinstance(self.cpu, int)
assert self.cpu >= 0
assert isinstance(self.gpu, int)
assert self.gpu >= 0
assert isinstance(self.mem, int)
assert self.mem >= 0
def __str__(self):
return (
"{"
+ f"CPU: {self.cpu}, GPU: {self.gpu}, memory: {memory_string(self.mem)}"
+ "}"
)
@classmethod
def of(cls, *, cpu=0, gpu=0, mem=0):
cpu = math.floor(cpu)
gpu = math.floor(gpu)
# Round memory to the nearest 0.1 GiB so that nodes of the same type
# with slightly different reported physical memory are grouped together.
mem = int(round(mem / GiB, 1) * GiB) if mem > 0 else 0
return cls(cpu=cpu, gpu=gpu, mem=mem)
@classmethod
def from_bundle(cls, bundle: Dict[str, Any]) -> "_NodeResourceSpec":
return _NodeResourceSpec.of(
cpu=bundle.get("CPU", 0),
gpu=bundle.get("GPU", 0),
mem=bundle.get("memory", 0),
)
def to_bundle(self):
return {"CPU": self.cpu, "GPU": self.gpu, "memory": self.mem}
def _get_node_resource_spec_and_count(
subcluster: Optional[str] = DEFAULT_SUBCLUSTER,
) -> Dict[_NodeResourceSpec, int]:
"""Get the unique node resource specs and their count in the cluster,
scoped to a single subcluster.
The returned specs are the scalable worker shapes used to build scale-up
requests, so the head node is deliberately excluded:
* Head node *instances* are not counted (they can't be used to schedule tasks).
* A node group *config* dedicated to the head node is dropped as well
(``max_count == 1`` and a shape matching the running head node).
Otherwise its shape would be requested as an extra node even though the
head can't be scaled. Groups that can also host workers
(``max_count > 1``) or that have a non-head shape are kept, including
worker types with zero running instances (scale-from-zero).
Quirk: the returned dict also contains a ``node_type: 0`` (ex: `"m5.xlarge": 0`) entry for every
node type registered in ``cluster_config.node_group_configs`` that
isn't included in this subcluster. ``get_cluster_config()``
reports node types but not labels, so the only way to know a
shape's subcluster is to inspect live nodes. Harmless: for example,
if m5.xlarge nodes only exist in the training subcluster, the validation
dataset will emit pending-bundle scale-up demand for foo nodes
stamped with the validation label, which the autoscaler can never
satisfy.
TODO: get labels from cluster config so the catalog can be filtered.
Args:
subcluster: The value at ``SUBCLUSTER_LABEL_KEY`` to match against.
The default ``DEFAULT_SUBCLUSTER`` (None) selects nodes with no
subcluster label.
Returns:
A mapping from each scalable worker node shape to its current count of
running instances (0 for shapes discovered only from the cluster
config).
"""
nodes_resource_spec_count = defaultdict(int)
# Split nodes into the head node and worker nodes. There is exactly one head
# node, and it can't be scaled up, so it's excluded from the counts and used
# below to identify a node group dedicated to the head. Worker nodes are
# further scoped to the requester's subcluster, so foreign subclusters'
# shapes and counts don't leak into this requester's active / pending
# bundles. Head detection is intentionally not subcluster-scoped: the head
# node group is global.
head_node_spec = None
worker_node_resources = []
for node in ray.nodes():
if not node["Alive"]:
continue
if "node:__internal_head__" in node["Resources"]:
head_node_spec = _NodeResourceSpec.from_bundle(node["Resources"])
continue
if (node.get("Labels") or {}).get(
SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER
) == subcluster:
worker_node_resources.append(node["Resources"])
cluster_config = ray._private.state.state.get_cluster_config()
if cluster_config and cluster_config.node_group_configs:
for node_group_config in cluster_config.node_group_configs:
if not node_group_config.resources or node_group_config.max_count == 0:
continue
node_resource_spec = _NodeResourceSpec.from_bundle(
node_group_config.resources
)
# Skip a node group dedicated to the head node, since it can't be scaled up and thus shouldn't be counted towards the current cluster capacity or used as a template for scaling up.
if (
node_group_config.max_count == 1
and node_resource_spec == head_node_spec
):
continue
nodes_resource_spec_count[node_resource_spec] = 0
for r in worker_node_resources:
node_resource_spec = _NodeResourceSpec.from_bundle(r)
nodes_resource_spec_count[node_resource_spec] += 1
return nodes_resource_spec_count
class DefaultClusterAutoscalerV2(ClusterAutoscaler):
"""Ray Data's second cluster autoscaler implementation.
It works in the following way:
* Check the average cluster utilization (CPU and memory)
in a time window (by default 10s). If the utilization is above a threshold (by
default 0.75), send a request to Ray's autoscaler to scale up the cluster.
* Unlike previous implementation, each resource bundle in the request is a node
resource spec, rather than an `incremental_resource_usage()`. This allows us
to directly scale up nodes.
* Cluster scaling down isn't handled here. It depends on the idle node
termination.
"""
# Default cluster utilization threshold to trigger scaling up.
DEFAULT_CLUSTER_SCALING_UP_UTIL_THRESHOLD: float = env_float(
"RAY_DATA_CLUSTER_SCALING_UP_UTIL_THRESHOLD",
0.75,
)
# Default time window in seconds to calculate the average of cluster utilization.
DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S: int = env_integer(
"RAY_DATA_CLUSTER_UTIL_AVG_WINDOW_S",
10,
)
# Default number of nodes to add per node type.
DEFAULT_CLUSTER_SCALING_UP_DELTA: int = env_integer(
"RAY_DATA_CLUSTER_SCALING_UP_DELTA",
1,
)
# Min number of seconds between two autoscaling requests.
MIN_GAP_BETWEEN_AUTOSCALING_REQUESTS: int = env_integer(
"RAY_DATA_MIN_GAP_BETWEEN_AUTOSCALING_REQUESTS",
10,
)
# The time in seconds after which an autoscaling request will expire.
AUTOSCALING_REQUEST_EXPIRE_TIME_S: int = env_integer(
"RAY_DATA_AUTOSCALING_REQUEST_EXPIRE_TIME_S",
180,
)
# When utilization drops below the scale-up threshold, keep renewing the last
# explicit request for a short time before releasing it.
DEFAULT_LOW_UTIL_REQUEST_RELEASE_DELAY_S: float = env_float(
"RAY_DATA_LOW_UTIL_REQUEST_RELEASE_DELAY_S",
180,
)
# Whether to disable INFO-level logs.
RAY_DATA_DISABLE_AUTOSCALER_LOGGING = env_bool(
"RAY_DATA_DISABLE_AUTOSCALER_LOGGING", False
)
def __init__(
self,
resource_manager: "ResourceManager",
execution_id: str,
resource_limits: ExecutionResources = ExecutionResources.inf(),
resource_utilization_calculator: Optional[ResourceUtilizationGauge] = None,
cluster_scaling_up_util_threshold: float = DEFAULT_CLUSTER_SCALING_UP_UTIL_THRESHOLD, # noqa: E501
cluster_scaling_up_delta: float = DEFAULT_CLUSTER_SCALING_UP_DELTA,
cluster_util_avg_window_s: float = DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S,
min_gap_between_autoscaling_requests_s: float = MIN_GAP_BETWEEN_AUTOSCALING_REQUESTS, # noqa: E501
low_util_request_release_delay_s: float = DEFAULT_LOW_UTIL_REQUEST_RELEASE_DELAY_S, # noqa: E501
autoscaling_coordinator: Optional[AutoscalingCoordinator] = None,
get_node_counts: Optional[Callable[[], Dict[_NodeResourceSpec, int]]] = None,
get_time: Callable[[], float] = time.time,
label_selector: Optional[Dict[str, str]] = None,
):
assert cluster_scaling_up_delta > 0
assert cluster_util_avg_window_s > 0
assert min_gap_between_autoscaling_requests_s >= 0
assert low_util_request_release_delay_s >= 0
if resource_utilization_calculator is None:
resource_utilization_calculator = RollingLogicalUtilizationGauge(
resource_manager,
cluster_util_avg_window_s=cluster_util_avg_window_s,
execution_id=execution_id,
)
self._resource_limits = resource_limits
self._label_selector = label_selector or {}
self._resource_utilization_calculator = resource_utilization_calculator
# Threshold of cluster utilization to trigger scaling up.
self._cluster_scaling_up_util_threshold = cluster_scaling_up_util_threshold
self._cluster_scaling_up_delta = int(math.ceil(cluster_scaling_up_delta))
self._min_gap_between_autoscaling_requests_s = (
min_gap_between_autoscaling_requests_s
)
self._low_util_request_release_delay_s = low_util_request_release_delay_s
# Last time when a request was sent to Ray's autoscaler.
self._last_request_time = 0
# Track the last non-empty explicit request so low-utilization heartbeats
# can keep it alive briefly without turning allocated remaining-share
# resources into explicit autoscaler demand.
self._last_non_empty_resource_request: List[ResourceDict] = []
self._last_non_empty_request_time: Optional[float] = None
# Unique identifier for the cluster autoscaler as a requester for
# the autoscaling coordinator.
self._requester_id = f"data-{execution_id}"
if autoscaling_coordinator is None:
autoscaling_coordinator = DefaultAutoscalingCoordinator(
requester_id=self._requester_id,
subcluster_selector=label_selector,
)
self._autoscaling_coordinator = autoscaling_coordinator
if get_node_counts is None:
# Scope node-shape/count discovery to this requester's subcluster
# so try_trigger_scaling doesn't pull node shapes / counts from
# other subclusters into ``active_bundles`` / ``pending_bundles``.
subcluster = self._label_selector.get(
SUBCLUSTER_LABEL_KEY, DEFAULT_SUBCLUSTER
)
get_node_counts = lambda: _get_node_resource_spec_and_count( # noqa: E731
subcluster=subcluster
)
self._get_node_counts = get_node_counts
self._get_time = get_time
self._autoscaling_enabled = is_autoscaling_enabled()
# Register with the coordinator immediately so the actor knows about this
# requester before the first ``get_allocated_resources call``. The cached value
# returned by ``get_allocated_resources`` (and thus ``get_total_resources``) will
# be empty until the actor responds with the first allocation (cold-start).
self._send_resource_request([])
def try_trigger_scaling(self):
# Note, should call this method before checking `_last_request_time`,
# in order to update the average cluster utilization.
self._resource_utilization_calculator.observe()
# Limit the frequency of autoscaling requests.
now = self._get_time()
if now - self._last_request_time < self._min_gap_between_autoscaling_requests_s:
return
util = self._resource_utilization_calculator.get()
if (
util.cpu < self._cluster_scaling_up_util_threshold
and util.gpu < self._cluster_scaling_up_util_threshold
and util.memory < self._cluster_scaling_up_util_threshold
and util.object_store_memory < self._cluster_scaling_up_util_threshold
):
logger.debug(
"Cluster utilization is below threshold: "
f"CPU={util.cpu:.2f}, GPU={util.gpu:.2f}, memory={util.memory:.2f}, "
f"object_store_memory={util.object_store_memory:.2f}."
)
self._send_resource_request(None)
return
# We separate active bundles (existing nodes) from pending bundles (scale-up delta)
# to ensure existing nodes' resources are never crowded out by scale-up requests.
# TODO(hchen): We scale up all nodes by the same delta for now.
# We may want to distinguish different node types based on their individual
# utilization.
active_bundles = []
pending_bundles = []
node_resource_spec_count = self._get_node_counts()
for node_resource_spec, count in node_resource_spec_count.items():
bundle = node_resource_spec.to_bundle()
# Bundles for existing nodes -> active (must include)
active_bundles.extend([bundle] * count)
# Bundles for scale-up delta -> pending (best-effort)
pending_bundles.extend([bundle] * self._cluster_scaling_up_delta)
# Cap the resource request to respect user-configured limits.
# Active bundles (existing nodes) are always included; pending bundles
# (scale-up requests) are best-effort.
resource_request = cap_resource_request_to_limits(
active_bundles, pending_bundles, self._resource_limits
)
if resource_request != active_bundles:
self._log_resource_request(util, active_bundles, resource_request)
self._send_resource_request(resource_request)
def _log_resource_request(
self,
current_utilization: ExecutionResources,
active_bundles: List[Dict[str, float]],
resource_request: List[Dict[str, float]],
) -> None:
message = (
"The utilization of one or more logical resource is higher than the "
f"specified threshold of {self._cluster_scaling_up_util_threshold:.0%}: "
f"CPU={current_utilization.cpu:.0%}, GPU={current_utilization.gpu:.0%}, "
f"memory={current_utilization.memory:.0%}, "
f"object_store_memory={current_utilization.object_store_memory:.0%}. "
f"Requesting {self._cluster_scaling_up_delta} node(s) of each shape:"
)
current_node_counts = Counter(
[_NodeResourceSpec.from_bundle(bundle) for bundle in active_bundles]
)
requested_node_counts = Counter(
[_NodeResourceSpec.from_bundle(bundle) for bundle in resource_request]
)
for node_spec, requested_count in requested_node_counts.items():
current_count = current_node_counts.get(node_spec, 0)
message += f" [{node_spec}: {current_count} -> {requested_count}]"
if self.RAY_DATA_DISABLE_AUTOSCALER_LOGGING or not self._autoscaling_enabled:
level = logging.DEBUG
else:
level = logging.INFO
logger.log(level, message)
def _should_keep_non_empty_request(self, now: float) -> bool:
return (
self._last_non_empty_request_time is not None
and now - self._last_non_empty_request_time
< self._low_util_request_release_delay_s
)
def _send_resource_request(
self,
resource_request: Optional[List[ResourceDict]],
):
now = self._get_time()
update_non_empty_request_state = True
if resource_request is None:
if self._should_keep_non_empty_request(now):
resource_request = self._last_non_empty_resource_request
update_non_empty_request_state = False
else:
# Renew our registration on AutoscalingCoordinator without
# keeping explicit autoscaler demand alive.
resource_request = []
# Make autoscaler resource request.
self._autoscaling_coordinator.request_resources(
resources=resource_request,
expire_after_s=self.AUTOSCALING_REQUEST_EXPIRE_TIME_S,
request_remaining=True,
)
if resource_request and update_non_empty_request_state:
self._last_non_empty_resource_request = [
bundle.copy() for bundle in resource_request
]
self._last_non_empty_request_time = now
elif not resource_request:
self._last_non_empty_resource_request = []
self._last_non_empty_request_time = None
self._last_request_time = now
def on_executor_shutdown(self):
# Cancel the resource request when the executor is shutting down.
try:
self._autoscaling_coordinator.cancel_request()
except Exception:
# cancel_request is fire-and-forget and shouldn't raise, but guard
# against unexpected Ray Core errors at submit time. At shutdown
# there's nothing useful to do except log and let the request expire.
msg = (
f"Failed to cancel resource request for {self._requester_id}."
" The request will still expire after the timeout of"
f" {self._min_gap_between_autoscaling_requests_s} seconds."
)
logger.warning(msg, exc_info=True)
def get_total_resources(self) -> ExecutionResources:
"""Get total resources available from the autoscaling coordinator."""
resources = self._autoscaling_coordinator.get_allocated_resources()
total = ExecutionResources.zero()
for res in resources:
total = total.add(ExecutionResources.from_resource_dict(res))
return total
@@ -0,0 +1,83 @@
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
from .base_autoscaling_coordinator import (
AutoscalingCoordinator,
ResourceDict,
ResourceRequestPriority,
)
class FakeAutoscalingCoordinator(AutoscalingCoordinator):
"""A lightweight implementation for testing.
This implementation always allocates the requested resources to the
requester. It doesn't support the `priority` parameter.
"""
@dataclass
class Allocation:
resources: List[ResourceDict]
expiration_time_s: float
request_remaining: bool
def __init__(
self,
get_time: Callable[[], float] = time.time,
initial_cluster_resources: Optional[List[ResourceDict]] = None,
):
"""Initialize the coordinator.
Args:
get_time: A function that returns the current time in seconds. This is a
seam for testing.
initial_cluster_resources: If the requester sends an empty request and
``request_remaining`` is True, the coordinator allocates these resources
to the requester. Otherwise, the coordinator allocates the requested
resources.
"""
if initial_cluster_resources is None:
initial_cluster_resources = []
self._get_time = get_time
self._initial_cluster_resources = initial_cluster_resources
self._allocation: Optional[FakeAutoscalingCoordinator.Allocation] = None
def request_resources(
self,
resources: List[ResourceDict],
expire_after_s: float,
request_remaining: bool = False,
priority: ResourceRequestPriority = ResourceRequestPriority.MEDIUM,
label_selectors: Optional[List[Dict[str, str]]] = None,
subcluster_selector: Optional[Dict[str, str]] = None,
) -> None:
if priority != ResourceRequestPriority.MEDIUM:
raise NotImplementedError(
"This fake implementation doesn't support the `priority` parameter."
)
if not resources and request_remaining:
resources = [r.copy() for r in self._initial_cluster_resources]
# Always accept the request and record it.
self._allocation = self.Allocation(
resources=resources,
expiration_time_s=self._get_time() + expire_after_s,
request_remaining=request_remaining,
)
def cancel_request(self) -> None:
self._allocation = None
def get_allocated_resources(self) -> List[ResourceDict]:
"""Return the allocated resources if they haven't expired."""
if self._allocation is None:
return []
if self._allocation.expiration_time_s < self._get_time():
self._allocation = None
return []
return [r.copy() for r in self._allocation.resources]
@@ -0,0 +1,141 @@
import abc
import math
from dataclasses import dataclass
from typing import Optional
from ray.data._internal.average_calculator import TimeWindowAverageCalculator
from ray.data._internal.execution.resource_manager import ResourceManager
from ray.util.metrics import Gauge
@dataclass(frozen=True)
class ClusterUtil:
cpu: float = 0.0
gpu: float = 0.0
memory: float = 0.0
object_store_memory: float = 0.0
def __post_init__(self):
# If we overcommit tasks, the logical utilization can exceed 1.0.
assert math.isfinite(self.cpu) and 0 <= self.cpu, self.cpu
assert math.isfinite(self.gpu) and 0 <= self.gpu, self.gpu
assert math.isfinite(self.memory) and 0 <= self.memory, self.memory
assert (
math.isfinite(self.object_store_memory) and 0 <= self.object_store_memory
), self.object_store_memory
class ResourceUtilizationGauge(abc.ABC):
@abc.abstractmethod
def observe(self):
"""Observe the cluster utilization."""
...
@abc.abstractmethod
def get(self) -> ClusterUtil:
"""Get the resource cluster utilization."""
...
class RollingLogicalUtilizationGauge(ResourceUtilizationGauge):
# Default time window in seconds to calculate the average of cluster utilization.
DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S: int = 10
def __init__(
self,
resource_manager: ResourceManager,
*,
cluster_util_avg_window_s: float = DEFAULT_CLUSTER_UTIL_AVG_WINDOW_S,
execution_id: Optional[str] = None,
):
self._resource_manager = resource_manager
self._execution_id = execution_id
self._cluster_cpu_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_gpu_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_mem_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_obj_mem_util_calculator = TimeWindowAverageCalculator(
cluster_util_avg_window_s
)
self._cluster_cpu_utilization_gauge = None
self._cluster_gpu_utilization_gauge = None
self._cluster_mem_utilization_gauge = None
self._cluster_object_store_memory_utilization_gauge = None
if self._execution_id is not None:
self._cluster_cpu_utilization_gauge = Gauge(
"data_cluster_cpu_utilization",
description="Cluster utilization % (CPU)",
tag_keys=("dataset",),
)
self._cluster_gpu_utilization_gauge = Gauge(
"data_cluster_gpu_utilization",
description="Cluster utilization % (GPU)",
tag_keys=("dataset",),
)
self._cluster_mem_utilization_gauge = Gauge(
"data_cluster_mem_utilization",
description="Cluster utilization % (Memory)",
tag_keys=("dataset",),
)
self._cluster_object_store_memory_utilization_gauge = Gauge(
"data_cluster_object_store_memory_utilization",
description="Cluster utilization % (Object Store Memory)",
tag_keys=("dataset",),
)
def observe(self):
"""Report the cluster utilization based on global usage / global limits."""
def save_div(numerator, denominator):
if not denominator:
return 0
else:
return numerator / denominator
global_usage = self._resource_manager.get_global_usage()
global_limits = self._resource_manager.get_global_limits()
cpu_util = save_div(global_usage.cpu, global_limits.cpu)
gpu_util = save_div(global_usage.gpu, global_limits.gpu)
mem_util = save_div(global_usage.memory, global_limits.memory)
obj_store_mem_util = save_div(
global_usage.object_store_memory, global_limits.object_store_memory
)
self._cluster_cpu_util_calculator.report(cpu_util)
self._cluster_gpu_util_calculator.report(gpu_util)
self._cluster_mem_util_calculator.report(mem_util)
self._cluster_obj_mem_util_calculator.report(obj_store_mem_util)
if self._execution_id is not None:
tags = {"dataset": self._execution_id}
if self._cluster_cpu_utilization_gauge is not None:
self._cluster_cpu_utilization_gauge.set(cpu_util * 100, tags=tags)
if self._cluster_gpu_utilization_gauge is not None:
self._cluster_gpu_utilization_gauge.set(gpu_util * 100, tags=tags)
if self._cluster_mem_utilization_gauge is not None:
self._cluster_mem_utilization_gauge.set(mem_util * 100, tags=tags)
if self._cluster_object_store_memory_utilization_gauge is not None:
self._cluster_object_store_memory_utilization_gauge.set(
obj_store_mem_util * 100, tags=tags
)
def get(self) -> ClusterUtil:
"""Get the average cluster utilization based on global usage / global limits."""
# Clamp to 0 to handle floating-point drift in the rolling average.
return ClusterUtil(
cpu=max(0, self._cluster_cpu_util_calculator.get_average() or 0),
gpu=max(0, self._cluster_gpu_util_calculator.get_average() or 0),
memory=max(0, self._cluster_mem_util_calculator.get_average() or 0),
object_store_memory=max(
0, self._cluster_obj_mem_util_calculator.get_average() or 0
),
)
@@ -0,0 +1,127 @@
from typing import Dict, TypeVar
from ray.data._internal.execution.interfaces import ExecutionResources
# The math functions defined in this module use a generic type rather than
# `PhysicalOperator` so it's easier to test. We already pass in all of the necessary
# inputs, so the actual type doesn't matter.
T = TypeVar("T")
_SCHEDULABLE_RESOURCE_NAMES = ("cpu", "gpu", "memory")
def allocate_resources(
throughput: float,
*,
rates: Dict[T, float],
resource_requirements: Dict[T, ExecutionResources],
) -> Dict[T, ExecutionResources]:
"""Allocate resources for a pipeline to sustain the given throughput.
Key insight: in a pipeline, all operators must sustain the same throughput T.
Operator i with per-task rate r_i needs T/r_i tasks to sustain T. So maximizing
throughput is equivalent to finding the largest feasible T, then deriving task
counts from it.
Args:
throughput: The throughput for the pipeline in the same units as the rates.
rates: The rate at which a task or actor produces outputs for each operator.
resource_requirements: The logical resources required to schedule a task or
actor for each operator.
Returns:
A dictionary mapping operators to the allocated resources.
"""
assert throughput >= 0, "Throughput must be non-negative"
assert all(rate > 0 for rate in rates.values()), "Rates must be positive"
if not rates:
return {}
if throughput == 0:
return {op: ExecutionResources.zero() for op in rates}
# NOTE: This implementation computes fractional task counts. In practice, you
# can't schedule a fractional task or actor, so the allocations might be infeasible.
task_counts = {op: throughput / rate for op, rate in rates.items()}
return {op: resource_requirements[op].scale(task_counts[op]) for op in rates}
def compute_optimal_throughput(
*,
rates: Dict[T, float],
resource_requirements: Dict[T, ExecutionResources],
resource_limits: ExecutionResources,
concurrency_limits: Dict[T, int | None],
) -> float:
"""Compute the optimal throughput for a pipeline.
The optimal throughput is bounded by two constraints (we take the tightest):
1. Resource limits — total resource usage across all operators must fit the
budget.
2. Concurrency limits — each operator's task count cannot exceed its limit.
Args:
rates: The rate at which a task or actor produces outputs for each operator.
resource_requirements: The logical resources required to schedule a task or
actor for each operator.
resource_limits: The resource limits for the cluster.
concurrency_limits: The maximum number of tasks or actors that can be scheduled
concurrently for each operator.
Returns:
The optimal throughput for the pipeline in the same units as the rates.
"""
assert rates, "Rates must be non-empty"
return min(
_max_throughput_from_resources(rates, resource_requirements, resource_limits),
_max_throughput_from_concurrency(rates, concurrency_limits),
)
def _max_throughput_from_resources(
rates: Dict[T, float],
resource_requirements: Dict[T, ExecutionResources],
resource_limits: ExecutionResources,
) -> float:
"""For each resource type, compute the max throughput the resource budget allows."""
assert rates, "Rates must be non-empty"
assert all(rate > 0 for rate in rates.values()), "Rates must be positive"
assert (
rates.keys() <= resource_requirements.keys()
), "You must provide a resource requirement for each operator with a rate."
max_throughput = float("inf")
for resource_name in _SCHEDULABLE_RESOURCE_NAMES:
resource_limit = getattr(resource_limits, resource_name)
resource_cost_per_unit_throughput = sum(
getattr(resource_requirements[op], resource_name) / rates[op]
for op in rates
)
if resource_cost_per_unit_throughput > 0:
max_throughput = min(
max_throughput, resource_limit / resource_cost_per_unit_throughput
)
assert max_throughput >= 0, "Max throughput must be non-negative"
return max_throughput
def _max_throughput_from_concurrency(
rates: Dict[T, float],
concurrency_limits: Dict[T, int | None],
) -> float:
"""Each operator's throughput is capped at rate * concurrency_limit."""
assert rates, "Rates must be non-empty"
assert (
rates.keys() <= concurrency_limits.keys()
), "You must provide a concurrency limit for each operator with a rate."
# Convert `None` to float("inf") for operators with no concurrency limit
normalized_concurrency_limits: Dict[T, float] = {
op: limit if limit is not None else float("inf")
for op, limit in concurrency_limits.items()
}
return min(rates[op] * normalized_concurrency_limits[op] for op in rates)
@@ -0,0 +1,97 @@
import logging
from typing import Dict, List
from ray.data._internal.execution.interfaces import ExecutionResources
logger = logging.getLogger(__name__)
def is_autoscaling_enabled() -> bool:
"""Check if any node type has autoscaling enabled (can scale up).
A node type is autoscalable if max_count == -1 (unlimited) or
max_count > min_count. If no cluster config is available or no node type
is autoscalable, returns False.
"""
import ray._private.state
cluster_config = ray._private.state.state.get_cluster_config()
if not cluster_config or not cluster_config.node_group_configs:
return False
return any(
ngc.max_count == -1 or ngc.max_count > ngc.min_count
for ngc in cluster_config.node_group_configs
if ngc.resources and ngc.max_count != 0
)
def cap_resource_request_to_limits(
active_bundles: List[Dict],
pending_bundles: List[Dict],
resource_limits: ExecutionResources,
) -> List[Dict]:
"""Cap the resource request to not exceed user-configured resource limits.
Active bundles (for running tasks or existing nodes) are always included first
since they represent resources already in use. Pending bundles (for future work
or scale-up requests) are then added best-effort, sorted smallest-first to
maximize packing within limits.
This ensures that resources for already-running tasks are never crowded out
by pending work from smaller operators.
Args:
active_bundles: Bundles for already-running tasks or existing nodes
(must include - these represent current resource usage).
pending_bundles: Bundles for pending work or scale-up requests
(best-effort - only added if within limits).
resource_limits: The user-configured resource limits.
Returns:
A list of resource bundles that respects user limits, with active bundles
always included first.
"""
# If no explicit limits are set (all infinite), return everything
if resource_limits == ExecutionResources.inf():
return active_bundles + pending_bundles
# Always include active bundles first - they're already running/allocated
capped_request = list(active_bundles)
total = ExecutionResources.zero()
for bundle in active_bundles:
total = total.add(ExecutionResources.from_resource_dict(bundle))
# Sort pending bundles by size (smallest first) to maximize packing.
# This ensures smaller bundles aren't excluded due to larger bundles
# appearing earlier in arbitrary iteration order.
def bundle_sort_key(bundle: Dict) -> tuple:
return (
bundle.get("CPU", 0),
bundle.get("GPU", 0),
bundle.get("memory", 0),
)
sorted_pending = sorted(pending_bundles, key=bundle_sort_key)
for bundle in sorted_pending:
new_total = total.add(ExecutionResources.from_resource_dict(bundle))
# Skip bundles that don't fit, continue checking smaller ones
if not new_total.satisfies_limit(resource_limits):
continue
capped_request.append(bundle)
total = new_total
total_input = len(active_bundles) + len(pending_bundles)
if len(capped_request) < total_input:
logger.debug(
f"Capped autoscaling resource request from {total_input} "
f"bundles to {len(capped_request)} bundles to respect "
f"user-configured resource limits: {resource_limits}. "
f"({len(active_bundles)} active bundles kept, "
f"{len(capped_request) - len(active_bundles)}/{len(pending_bundles)} "
f"pending bundles included)."
)
return capped_request
+49
View File
@@ -0,0 +1,49 @@
from typing import Dict, TypeVar
K = TypeVar("K")
def collapse_transitive_map(d: Dict[K, K]) -> Dict[K, K]:
"""Collapse transitive mappings in a dictionary. Given a mapping like
{a: b, b: c, c: d}, returns {a: d}, removing intermediate b -> c, c -> d.
Only keeps mappings where the key is NOT a value in another mapping (i.e., chain starting points).
Args:
d: Dictionary representing a mapping
Returns:
Dictionary with all transitive mappings collapsed, keeping only KV-pairs,
such that K and V are starting and terminal point of a chain
Examples:
>>> collapse_transitive_map({"a": "b", "b": "c", "c": "d"})
{'a': 'd'}
>>> collapse_transitive_map({"a": "b", "x": "y"})
{'a': 'b', 'x': 'y'}
"""
if not d:
return {}
collapsed = {}
values_set = set(d.values())
for k in d:
# Skip mappings that are in the value-set, meaning that they are
# part of the mapping chain (for ex, {a -> b, b -> c})
if k in values_set:
continue
cur = k
visited = {cur}
# Follow the chain until we reach a key that's not in the mapping
while cur in d:
next = d[cur]
if next in visited:
raise ValueError(f"Detected a cycle in the mapping {d}")
visited.add(next)
cur = next
collapsed[k] = cur
return collapsed
+218
View File
@@ -0,0 +1,218 @@
import logging
from typing import Any, Callable, Iterable, Optional, TypeVar, Union
from ray.data._internal.execution.interfaces import TaskContext
from ray.data.block import Block, UserDefinedFunction
from ray.util.annotations import DeveloperAPI, PublicAPI
logger = logging.getLogger(__name__)
T = TypeVar("T")
U = TypeVar("U")
# Block transform function applied by task and actor pools.
BlockTransform = Union[
# TODO(Clark): Once Ray only supports Python 3.8+, use protocol to constrain block
# transform type.
# Callable[[Block, ...], Iterable[Block]]
# Callable[[Block, UserDefinedFunction, ...], Iterable[Block]],
Callable[[Iterable[Block], TaskContext], Iterable[Block]],
Callable[[Iterable[Block], TaskContext, UserDefinedFunction], Iterable[Block]],
Callable[..., Iterable[Block]],
]
@DeveloperAPI
class ComputeStrategy:
pass
@PublicAPI
class TaskPoolStrategy(ComputeStrategy):
"""Specify the task-based compute strategy for a Dataset transform.
TaskPoolStrategy executes dataset transformations using Ray tasks that are
scheduled through a pool. Provide ``size`` to cap the number of concurrent
tasks; leave it unset to allow Ray Data to scale the task count
automatically.
"""
def __init__(
self,
size: Optional[int] = None,
):
"""Construct TaskPoolStrategy for a Dataset transform.
Args:
size: Specify the maximum size of the task pool.
"""
if size is not None and size < 1:
raise ValueError("`size` must be >= 1", size)
self.size = size
def __eq__(self, other: Any) -> bool:
return (isinstance(other, TaskPoolStrategy) and self.size == other.size) or (
other == "tasks" and self.size is None
)
def __repr__(self) -> str:
return f"TaskPoolStrategy(size={self.size})"
@PublicAPI
class ActorPoolStrategy(ComputeStrategy):
"""Specify the actor-based compute strategy for a Dataset transform.
ActorPoolStrategy specifies that an autoscaling pool of actors should be used
for a given Dataset transform. This is useful for stateful setup of callable
classes.
For a fixed-sized pool of size ``n``, use ``ActorPoolStrategy(size=n)``.
To autoscale from ``m`` to ``n`` actors, use
``ActorPoolStrategy(min_size=m, max_size=n)``.
To autoscale from ``m`` to ``n`` actors, with an initial size of ``initial``, use
``ActorPoolStrategy(min_size=m, max_size=n, initial_size=initial)``.
To increase opportunities for pipelining task dependency prefetching with
computation and avoiding actor startup delays, set max_tasks_in_flight_per_actor
to 2 or greater; to try to decrease the delay due to queueing of tasks on the worker
actors, set max_tasks_in_flight_per_actor to 1.
The `enable_true_multi_threading` argument primarily exists to prevent GPU OOM issues with multi-threaded actors.
The life cycle of an actor task involves 3 main steps:
1. Batching Inputs
2. Running actor UDF
3. Batching Outputs
The `enable_true_multi_threading` flag affects step 2. If set to `True`, then the UDF can be run concurrently.
By default, it is set to `False`, so at most 1 actor UDF is running at a time per actor. The `max_concurrency`
flag on `ray.remote` affects steps 1 and 3. Below is a matrix summary:
- [`enable_true_multi_threading=False or True`, `max_concurrency=1`] = 1 actor task running per actor. So at most 1
of steps 1, 2, or 3 is running at any point in time.
- [`enable_true_multi_threading=False`, `max_concurrency>1`] = multiple tasks running per actor
(respecting GIL) but UDF runs 1 at a time. This is useful for doing CPU and GPU work,
where you want to use a large batch size but want to hide the overhead of *batching*
the inputs. In this case, CPU *batching* is done concurrently, while GPU *inference*
is done 1 at a time. Concretely, steps 1 and 3 can have multiple threads, while step 2 is done serially.
- [`enable_true_multi_threading=True`, `max_concurrency>1`] = multiple tasks running per actor.
Unlike bullet #3 ^, the UDF runs concurrently (respecting GIL). No restrictions on steps 1, 2, or 3
NOTE: `enable_true_multi_threading` does not apply to async actors
"""
def __init__(
self,
*,
size: Optional[int] = None,
min_size: Optional[int] = None,
max_size: Optional[int] = None,
initial_size: Optional[int] = None,
max_tasks_in_flight_per_actor: Optional[int] = None,
enable_true_multi_threading: bool = False,
):
"""Construct ActorPoolStrategy for a Dataset transform.
Args:
size: Specify a fixed size actor pool of this size. It is an error to
specify both `size` and `min_size` or `max_size`.
min_size: The minimum size of the actor pool.
max_size: The maximum size of the actor pool.
initial_size: The initial number of actors to start with. If not specified,
defaults to min_size. Must be between min_size and max_size.
max_tasks_in_flight_per_actor: The maximum number of tasks to concurrently
send to a single actor worker. Increasing this will increase
opportunities for pipelining task dependency prefetching with
computation and avoiding actor startup delays, but will also increase
queueing delay.
enable_true_multi_threading: If enable_true_multi_threading=False, no more than 1 UDF
runs per actor. Otherwise, respects the `max_concurrency` argument. For more details, see
the `ActorPoolStrategy` class docstring.
"""
if size is not None:
if size < 1:
raise ValueError("size must be >= 1", size)
if max_size is not None or min_size is not None or initial_size is not None:
raise ValueError(
"min_size, max_size, and initial_size cannot be set at the same time as `size`"
)
min_size = size
max_size = size
initial_size = size
if min_size is not None and min_size < 1:
raise ValueError("min_size must be >= 1", min_size)
if max_size is not None:
if min_size is None:
min_size = 1 # Legacy default.
if min_size > max_size:
raise ValueError("min_size must be <= max_size", min_size, max_size)
if (
max_tasks_in_flight_per_actor is not None
and max_tasks_in_flight_per_actor < 1
):
raise ValueError(
"max_tasks_in_flight_per_actor must be >= 1, got: ",
max_tasks_in_flight_per_actor,
)
self.min_size = min_size or 1
self.max_size = max_size or float("inf")
# Validate and set initial_size
if initial_size is not None:
if initial_size < self.min_size:
raise ValueError(
f"initial_size ({initial_size}) must be >= min_size ({self.min_size})"
)
if self.max_size != float("inf") and initial_size > self.max_size:
raise ValueError(
f"initial_size ({initial_size}) must be <= max_size ({self.max_size})"
)
self.initial_size = initial_size or self.min_size
self.max_tasks_in_flight_per_actor = max_tasks_in_flight_per_actor
self.num_workers = 0
self.ready_to_total_workers_ratio = 0.8
self.enable_true_multi_threading = enable_true_multi_threading
def __eq__(self, other: Any) -> bool:
return isinstance(other, ActorPoolStrategy) and (
self.min_size == other.min_size
and self.max_size == other.max_size
and self.initial_size == other.initial_size
and self.enable_true_multi_threading == other.enable_true_multi_threading
and self.max_tasks_in_flight_per_actor
== other.max_tasks_in_flight_per_actor
)
def __repr__(self) -> str:
return (
f"ActorPoolStrategy(min_size={self.min_size}, "
f"max_size={self.max_size}, "
f"initial_size={self.initial_size}, "
f"max_tasks_in_flight_per_actor={self.max_tasks_in_flight_per_actor})"
f"num_workers={self.num_workers}, "
f"enable_true_multi_threading={self.enable_true_multi_threading}, "
f"ready_to_total_workers_ratio={self.ready_to_total_workers_ratio})"
)
def get_compute(compute_spec: Union[str, ComputeStrategy]) -> ComputeStrategy:
if not isinstance(compute_spec, (TaskPoolStrategy, ActorPoolStrategy)):
raise ValueError(
"In Ray 2.5, the compute spec must be either "
f"TaskPoolStrategy or ActorPoolStrategy, was: {compute_spec}."
)
elif not compute_spec or compute_spec == "tasks":
return TaskPoolStrategy()
elif compute_spec == "actors":
return ActorPoolStrategy()
elif isinstance(compute_spec, ComputeStrategy):
return compute_spec
else:
raise ValueError("compute must be one of [`tasks`, `actors`, ComputeStrategy]")
+483
View File
@@ -0,0 +1,483 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import numpy as np
import ray
from ray.data._internal.logical.interfaces import SourceOperator
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.exceptions import RayError
from ray.types import ObjectRef
if TYPE_CHECKING:
from ray.data._internal.logical.interfaces.operator import Operator
from ray.data.dataset import Dataset, Schema
_DATASET_REPR_ELLIPSIS = "" # Ellipsis marker for truncated cells/rows.
_DATASET_REPR_MAX_ROWS = 10 # Total preview row budget when materialized.
_DATASET_REPR_HEAD_ROWS = 5 # Number of head rows to show before the gap.
_DATASET_REPR_MAX_COLUMN_WIDTH = 40 # Max width per column cell in the table.
_DATASET_REPR_GET_TIMEOUT_S = 30.0 # Timeout for fetching preview blocks.
__all__ = [
"build_dataset_ascii_repr",
"build_dataset_summary_repr",
]
def build_dataset_ascii_repr(
dataset: "Dataset",
schema: "Schema",
is_materialized: bool,
) -> str:
"""Render the dataset as a multi-line tabular string."""
columns = list(schema.names)
if not columns:
return build_dataset_summary_repr(dataset)
num_rows = dataset._meta_count()
head_rows: List[List[str]] = []
tail_rows: List[List[str]] = []
if is_materialized:
try:
head_data, tail_data, _ = _collect_materialized_rows_for_repr(
dataset, num_rows
)
head_rows = _format_rows_for_repr(head_data, columns)
tail_rows = _format_rows_for_repr(tail_data, columns)
except RayError:
head_rows = []
tail_rows = []
return _build_dataset_ascii_repr_from_rows(
schema=schema,
num_rows=num_rows,
dataset_name=dataset.name,
is_materialized=is_materialized,
head_rows=head_rows,
tail_rows=tail_rows,
)
def _build_dataset_ascii_repr_from_rows(
*,
schema: "Schema",
num_rows: Optional[int],
dataset_name: Optional[str],
is_materialized: bool,
head_rows: List[List[str]],
tail_rows: List[List[str]],
) -> str:
"""Render the dataset repr given schema metadata and preview rows."""
columns = list(schema.names)
num_cols = len(columns)
shape_line = f"shape: ({num_rows if num_rows is not None else '?'}, {num_cols})"
# Build header rows from schema.
dtype_strings = [_repr_format_dtype(t) for t in schema.types]
column_headers = [
_truncate_to_cell_width(str(col), _DATASET_REPR_MAX_COLUMN_WIDTH)
for col in columns
]
dtype_headers = [
_truncate_to_cell_width(dtype, _DATASET_REPR_MAX_COLUMN_WIDTH)
for dtype in dtype_strings
]
separator_row = ["---"] * len(columns)
# Assemble rows, including an ellipsis gap if needed.
show_gap = bool(head_rows) and bool(tail_rows)
display_rows: List[List[str]] = []
display_rows.extend(head_rows)
if show_gap:
display_rows.append([_DATASET_REPR_ELLIPSIS] * len(columns))
display_rows.extend(tail_rows)
# Render the table with computed column widths.
column_widths = _compute_column_widths(
column_headers, dtype_headers, separator_row, display_rows
)
table_lines = _render_table_lines(
column_headers,
dtype_headers,
separator_row,
display_rows,
column_widths,
)
# Append a summary line describing row coverage.
num_rows_shown = len(head_rows) + len(tail_rows)
summary_line = (
f"(Showing {num_rows_shown} of {num_rows} rows)"
if is_materialized
else "(Dataset isn't materialized)"
)
if is_materialized and num_rows is None:
summary_line = f"(Showing {num_rows_shown} of ? rows)"
components = []
if dataset_name is not None:
components.append(f"name: {dataset_name}")
components.extend([shape_line, "\n".join(table_lines), summary_line])
return "\n".join(components)
def _repr_format_dtype(dtype: object) -> str:
"""Format a dtype into a compact string for the schema row.
Dtypes may come from PyArrow, pandas/NumPy, or be plain Python types.
"""
if isinstance(dtype, type):
return dtype.__name__
name = getattr(dtype, "name", None)
if isinstance(name, str):
return name
return str(dtype)
def _collect_materialized_rows_for_repr(
dataset: "Dataset",
num_rows: Optional[int],
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], bool]:
"""Collect head/tail rows for preview and whether to show a gap row."""
block_entries: List[Tuple[ObjectRef, BlockMetadata]] = []
for ref_bundle in dataset.iter_internal_ref_bundles():
block_entries.extend(zip(ref_bundle.block_refs, ref_bundle.metadata))
if not block_entries:
return [], [], False
# Compute how many head/tail rows to show within the preview budget.
head_row_limit, tail_row_limit = _determine_preview_row_targets(num_rows)
block_cache: Dict[ObjectRef, Block] = {}
def _resolve_block(block_ref: ObjectRef) -> Block:
if block_ref not in block_cache:
block_cache[block_ref] = ray.get(
block_ref, timeout=_DATASET_REPR_GET_TIMEOUT_S
)
return block_cache[block_ref]
head_rows: List[Dict[str, Any]] = []
head_remaining = head_row_limit
for block_ref, _ in block_entries:
if head_remaining <= 0:
break
block = _resolve_block(block_ref)
accessor = BlockAccessor.for_block(block)
for row in accessor.iter_rows(public_row_format=True):
head_rows.append(row)
head_remaining -= 1
if head_remaining <= 0:
break
tail_rows: List[Dict[str, Any]] = []
tail_remaining = tail_row_limit
tail_parts: List[List[Dict[str, Any]]] = []
if tail_remaining > 0:
for block_ref, metadata in reversed(block_entries):
if tail_remaining <= 0:
break
block = _resolve_block(block_ref)
accessor = BlockAccessor.for_block(block)
total_rows = metadata.num_rows
if total_rows is None:
total_rows = accessor.num_rows()
if total_rows == 0:
continue
start = max(0, total_rows - tail_remaining)
sliced_block = accessor.slice(start, total_rows, copy=False)
slice_accessor = BlockAccessor.for_block(sliced_block)
block_rows = list(slice_accessor.iter_rows(public_row_format=True))
tail_parts.append(block_rows)
tail_remaining -= len(block_rows)
if tail_remaining <= 0:
break
for part in reversed(tail_parts):
tail_rows.extend(part)
show_gap = bool(head_rows) and bool(tail_rows)
return head_rows, tail_rows, show_gap
def _determine_preview_row_targets(num_rows: Optional[int]) -> Tuple[int, int]:
"""Compute how many head and tail rows to preview."""
max_rows = _DATASET_REPR_MAX_ROWS
if num_rows is None or num_rows <= max_rows:
head = num_rows if num_rows is not None else max_rows
return head, 0
head = min(_DATASET_REPR_HEAD_ROWS, max_rows)
tail = max_rows - head
return head, tail
def _format_rows_for_repr(
rows: List[Dict[str, Any]],
column_names: List[str],
) -> List[List[str]]:
"""Format row dicts into string cell rows for table rendering."""
formatted_rows: List[List[str]] = []
for row in rows:
formatted_row = []
for column in column_names:
value = row.get(column)
formatted_value = _format_value(value)
formatted_row.append(
_truncate_to_cell_width(formatted_value, _DATASET_REPR_MAX_COLUMN_WIDTH)
)
formatted_rows.append(formatted_row)
return formatted_rows
def _format_value(value: Any) -> str:
if isinstance(value, np.generic):
value = value.item()
return str(value).replace("\n", " ").replace("\r", " ")
def _truncate_to_cell_width(value: str, max_width: int) -> str:
"""Truncate a single cell to the configured max width."""
if max_width is None:
return value
if max_width <= 0:
return _DATASET_REPR_ELLIPSIS if value else ""
if len(value) <= max_width:
return value
if max_width == 1:
return _DATASET_REPR_ELLIPSIS
return value[: max_width - 1] + _DATASET_REPR_ELLIPSIS
def _compute_column_widths(
headers: List[str],
dtype_headers: List[str],
separator_row: List[str],
data_rows: List[List[str]],
) -> List[int]:
"""Compute per-column widths for table rendering."""
column_widths: List[int] = []
for idx in range(len(headers)):
widths = [
len(headers[idx]),
len(dtype_headers[idx]),
len(separator_row[idx]),
]
for row in data_rows:
widths.append(len(row[idx]))
column_widths.append(max(widths))
return column_widths
def _render_table_lines(
headers: List[str],
dtype_headers: List[str],
separator_row: List[str],
data_rows: List[List[str]],
column_widths: List[int],
) -> List[str]:
"""Render the full table (borders, headers, data) as lines."""
lines: List[str] = []
top = _render_border("", "", "", "", column_widths)
header_row = _render_row(headers, column_widths)
separator_line = _render_row(separator_row, column_widths)
dtype_row = _render_row(dtype_headers, column_widths)
lines.extend([top, header_row, separator_line, dtype_row])
if data_rows:
middle = _render_border("", "", "", "", column_widths)
lines.append(middle)
for row in data_rows:
lines.append(_render_row(row, column_widths))
bottom = _render_border("", "", "", "", column_widths)
lines.append(bottom)
return lines
def _render_border(
left: str, middle: str, right: str, fill: str, column_widths: List[int]
) -> str:
"""Render a table border line given column widths."""
segments = [fill * (width + 2) for width in column_widths]
return f"{left}{middle.join(segments)}{right}"
def _render_row(values: List[str], column_widths: List[int]) -> str:
"""Render a single table row with padding."""
cells = []
for idx, value in enumerate(values):
padded = value.ljust(column_widths[idx])
cells.append(f" {padded} ")
return f"{''.join(cells)}"
def _format_operator_dag(
op: "Operator",
curr_str: str = "",
depth: int = 0,
including_source: bool = True,
show_op_repr: bool = False,
) -> Tuple[str, int]:
"""Traverse (DFS) the Plan DAG and
return a string representation of the operators."""
if not including_source and isinstance(op, SourceOperator):
return curr_str, depth
curr_max_depth = depth
# For logical plan, only show the operator name like "Aggregate".
# But for physical plan, show the operator class name as well like
# "AllToAllOperator[Aggregate]".
op_str = repr(op) if show_op_repr else op.name
if depth == 0:
curr_str += f"{op_str}\n"
else:
trailing_space = " " * ((depth - 1) * 3)
curr_str += f"{trailing_space}+- {op_str}\n"
for input in op.input_dependencies:
curr_str, input_max_depth = _format_operator_dag(
input, curr_str, depth + 1, including_source, show_op_repr
)
curr_max_depth = max(curr_max_depth, input_max_depth)
return curr_str, curr_max_depth
def build_dataset_summary_repr(dataset: "Dataset") -> str:
"""Create a cosmetic string representation of a dataset.
This is used for Dataset.__repr__ when no tabular preview is available.
Must be very cheap — never forces execution.
"""
from ray.data.dataset import MaterializedDataset
dataset_cls = type(dataset)
logical_plan = dataset._logical_plan
dataset_name = dataset._dataset_name
plan_str = ""
plan_max_depth = 0
if not dataset._has_computed_output():
plan_str, plan_max_depth = _format_operator_dag(
logical_plan.dag, including_source=False
)
schema = dataset._base_schema(fetch_if_missing=False)
count = dataset._cache.get_num_rows(logical_plan.dag)
if schema is None or count is None:
has_n_ary_operator = False
dag = logical_plan.dag
while not isinstance(dag, SourceOperator):
if len(dag.input_dependencies) > 1:
has_n_ary_operator = True
break
dag = dag.input_dependencies[0]
# TODO(@bveeramani): Handle schemas for n-ary operators like `Union`.
if not has_n_ary_operator:
assert isinstance(dag, SourceOperator), dag
# We infer from logical plan's dag directly as we know that
# we don't have any cached values, so inferring is the only
# option left.
if schema is None:
schema = dag.infer_schema()
if count is None:
count = dag.infer_metadata().num_rows
if schema is None:
schema_str = "Unknown schema"
elif isinstance(schema, type):
schema_str = str(schema)
else:
schema_str = []
for n, t in zip(schema.names, schema.types):
if hasattr(t, "__name__"):
t = t.__name__
schema_str.append(f"{n}: {t}")
schema_str = ", ".join(schema_str)
schema_str = "{" + schema_str + "}"
if count is None:
count = "?"
num_blocks = None
if dataset_cls == MaterializedDataset:
num_blocks = logical_plan.initial_num_blocks()
assert num_blocks is not None
name_str = "name={}, ".format(dataset_name) if dataset_name is not None else ""
num_blocks_str = f"num_blocks={num_blocks}, " if num_blocks else ""
dataset_str = "{}({}{}num_rows={}, schema={})".format(
dataset_cls.__name__,
name_str,
num_blocks_str,
count,
schema_str,
)
# If the resulting string representation fits in one line, use it directly.
SCHEMA_LINE_CHAR_LIMIT = 80
MIN_FIELD_LENGTH = 10
INDENT_STR = " " * 3
trailing_space = INDENT_STR * plan_max_depth
if len(dataset_str) > SCHEMA_LINE_CHAR_LIMIT:
# If the resulting string representation exceeds the line char limit,
# first try breaking up each `Dataset` parameter into its own line
# and check if each line fits within the line limit. We check the
# `schema` param's length, since this is likely the longest string.
schema_str_on_new_line = f"{trailing_space}{INDENT_STR}schema={schema_str}"
if len(schema_str_on_new_line) > SCHEMA_LINE_CHAR_LIMIT:
# If the schema cannot fit on a single line, break up each field
# into its own line.
schema_str = []
for n, t in zip(schema.names, schema.types):
if hasattr(t, "__name__"):
t = t.__name__
col_str = f"{trailing_space}{INDENT_STR * 2}{n}: {t}"
# If the field line exceeds the char limit, abbreviate
# the field name to fit while maintaining the full type
if len(col_str) > SCHEMA_LINE_CHAR_LIMIT:
shortened_suffix = f"...: {str(t)}"
# Show at least 10 characters of the field name, even if
# we have already hit the line limit with the type.
chars_left_for_col_name = max(
SCHEMA_LINE_CHAR_LIMIT - len(shortened_suffix),
MIN_FIELD_LENGTH,
)
col_str = f"{col_str[:chars_left_for_col_name]}{shortened_suffix}"
schema_str.append(col_str)
schema_str = ",\n".join(schema_str)
schema_str = "{\n" + schema_str + f"\n{trailing_space}{INDENT_STR}" + "}"
name_str = (
f"\n{trailing_space}{INDENT_STR}name={dataset_name},"
if dataset_name is not None
else ""
)
num_blocks_str = (
f"\n{trailing_space}{INDENT_STR}num_blocks={num_blocks},"
if num_blocks
else ""
)
dataset_str = (
f"{dataset_cls.__name__}("
f"{name_str}"
f"{num_blocks_str}"
f"\n{trailing_space}{INDENT_STR}num_rows={count},"
f"\n{trailing_space}{INDENT_STR}schema={schema_str}"
f"\n{trailing_space})"
)
if plan_max_depth == 0:
plan_str += dataset_str
else:
plan_str += f"{INDENT_STR * (plan_max_depth - 1)}+- {dataset_str}"
return plan_str
@@ -0,0 +1,57 @@
import io
from typing import TYPE_CHECKING, Iterator, List, Union
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.util import _check_import
from ray.data.block import Block
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
class AudioDatasource(FileBasedDatasource):
_FILE_EXTENSIONS = [
"mp3",
"wav",
"aac",
"flac",
"ogg",
"m4a",
"wma",
"alac",
"aiff",
"pcm",
"amr",
"opus",
"ra",
"rm",
"au",
"mid",
"midi",
"caf",
]
def __init__(
self,
paths: Union[str, List[str]],
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
_check_import(self, module="soundfile", package="soundfile")
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
import soundfile
# `soundfile` doesn't support reading from a `pyarrow.NativeFile` directly, so
# we need to read the file into memory first.
stream = io.BytesIO(f.read())
amplitude, sample_rate = soundfile.read(stream, always_2d=True, dtype="float32")
# (amplitude, channels) -> (channels, amplitude)
amplitude = amplitude.transpose((1, 0))
builder = DelegatingBlockBuilder()
builder.add({"amplitude": amplitude, "sample_rate": sample_rate})
yield builder.build()
@@ -0,0 +1,43 @@
from typing import TYPE_CHECKING, Iterator, List, Union
from ray.data._internal.output_buffer import BlockOutputBuffer, OutputBlockSizeOption
from ray.data._internal.util import _check_import
from ray.data.block import Block
from ray.data.context import DataContext
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
class AvroDatasource(FileBasedDatasource):
"""A datasource that reads Avro files."""
_FILE_EXTENSIONS = ["avro"]
def __init__(
self,
paths: Union[str, List[str]],
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
_check_import(self, module="fastavro", package="fastavro")
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
import fastavro
# Read the Avro file. This assumes the Avro file includes its schema.
reader = fastavro.reader(f)
ctx = DataContext.get_current()
output_block_size_option = OutputBlockSizeOption.of(
target_max_block_size=ctx.target_max_block_size
)
output_buffer = BlockOutputBuffer(output_block_size_option)
for record in reader:
output_buffer.add(record)
yield from output_buffer.iter_ready_blocks()
output_buffer.finalize()
yield from output_buffer.iter_ready_blocks()
@@ -0,0 +1,133 @@
import logging
import os
import tempfile
import time
import uuid
from typing import TYPE_CHECKING, Iterable, Optional
import pyarrow.parquet as pq
if TYPE_CHECKING:
import pyarrow as pa
import ray
from ray.data._internal.datasource import bigquery_datasource
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.remote_fn import cached_remote_fn
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink
logger = logging.getLogger(__name__)
DEFAULT_MAX_RETRY_CNT = 10
RATE_LIMIT_EXCEEDED_SLEEP_TIME = 11
class BigQueryDatasink(Datasink[None]):
def __init__(
self,
project_id: str,
dataset: str,
max_retry_cnt: int = DEFAULT_MAX_RETRY_CNT,
overwrite_table: Optional[bool] = True,
) -> None:
_check_import(self, module="google.cloud", package="bigquery")
_check_import(self, module="google.cloud", package="bigquery_storage")
_check_import(self, module="google.api_core", package="exceptions")
self.project_id = project_id
self.dataset = dataset
self.max_retry_cnt = max_retry_cnt
self.overwrite_table = overwrite_table
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
from google.api_core import exceptions
if self.project_id is None or self.dataset is None:
raise ValueError("project_id and dataset are required args")
# Set up datasets to write
client = bigquery_datasource._create_client(project_id=self.project_id)
dataset_id = self.dataset.split(".", 1)[0]
try:
client.get_dataset(dataset_id)
except exceptions.NotFound:
client.create_dataset(f"{self.project_id}.{dataset_id}", timeout=30)
logger.info("Created dataset " + dataset_id)
# Delete table if overwrite_table is True
if self.overwrite_table:
logger.info(
f"Attempting to delete table {self.dataset}"
+ " if it already exists since kwarg overwrite_table = True."
)
client.delete_table(f"{self.project_id}.{self.dataset}", not_found_ok=True)
else:
logger.info(
f"The write will append to table {self.dataset}"
+ " if it already exists since kwarg overwrite_table = False."
)
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> None:
def _write_single_block(block: Block, project_id: str, dataset: str) -> None:
from google.api_core import exceptions
from google.cloud import bigquery
block = BlockAccessor.for_block(block).to_arrow()
client = bigquery_datasource._create_client(project_id=project_id)
job_config = bigquery.LoadJobConfig(autodetect=True)
job_config.source_format = bigquery.SourceFormat.PARQUET
job_config.write_disposition = bigquery.WriteDisposition.WRITE_APPEND
with tempfile.TemporaryDirectory() as temp_dir:
fp = os.path.join(temp_dir, f"block_{uuid.uuid4()}.parquet")
pq.write_table(block, fp, compression="SNAPPY")
retry_cnt = 0
while retry_cnt <= self.max_retry_cnt:
with open(fp, "rb") as source_file:
job = client.load_table_from_file(
source_file, dataset, job_config=job_config
)
try:
logger.info(job.result())
break
except (exceptions.Forbidden, exceptions.TooManyRequests) as e:
retry_cnt += 1
if retry_cnt > self.max_retry_cnt:
break
logger.info(
"A block write encountered a rate limit exceeded error"
+ f" {retry_cnt} time(s). Sleeping to try again."
)
logging.debug(e)
time.sleep(RATE_LIMIT_EXCEEDED_SLEEP_TIME)
# Raise exception if retry_cnt exceeds max_retry_cnt
if retry_cnt > self.max_retry_cnt:
logger.info(
f"Maximum ({self.max_retry_cnt}) retry count exceeded. Ray"
" will attempt to retry the block write via fault tolerance."
)
raise RuntimeError(
f"Write failed due to {retry_cnt}"
" repeated API rate limit exceeded responses. Consider"
" specifying the max_retry_cnt kwarg with a higher value."
)
_write_single_block = cached_remote_fn(_write_single_block)
# Launch a remote task for each block within this write task
ray.get(
[
_write_single_block.remote(block, self.project_id, self.dataset)
for block in blocks
if BlockAccessor.for_block(block).num_rows() > 0
]
)
@@ -0,0 +1,164 @@
import logging
from typing import TYPE_CHECKING, List, Optional
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
def _create_user_agent() -> str:
import ray
return f"ray/{ray.__version__}"
def _create_client_info():
from google.api_core.client_info import ClientInfo
return ClientInfo(
user_agent=_create_user_agent(),
)
def _create_client_info_gapic():
from google.api_core.gapic_v1.client_info import ClientInfo
return ClientInfo(
user_agent=_create_user_agent(),
)
def _create_client(project_id: str):
from google.cloud import bigquery
return bigquery.Client(
project=project_id,
client_info=_create_client_info(),
)
def _create_read_client():
from google.cloud import bigquery_storage
return bigquery_storage.BigQueryReadClient(
client_info=_create_client_info_gapic(),
)
class BigQueryDatasource(Datasource):
def __init__(
self,
project_id: str,
dataset: Optional[str] = None,
query: Optional[str] = None,
):
_check_import(self, module="google.cloud", package="bigquery")
_check_import(self, module="google.cloud", package="bigquery_storage")
_check_import(self, module="google.api_core", package="exceptions")
self._project_id = project_id
self._dataset = dataset
self._query = query
if query is not None and dataset is not None:
raise ValueError(
"Query and dataset kwargs cannot both be provided "
+ "(must be mutually exclusive)."
)
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
from google.cloud import bigquery_storage
def _read_single_partition(stream) -> Block:
client = _create_read_client()
reader = client.read_rows(stream.name)
return reader.to_arrow()
if self._query:
query_client = _create_client(project_id=self._project_id)
query_job = query_client.query(self._query)
query_job.result()
destination = str(query_job.destination)
dataset_id = destination.split(".")[-2]
table_id = destination.split(".")[-1]
else:
self._validate_dataset_table_exist(self._project_id, self._dataset)
dataset_id = self._dataset.split(".")[0]
table_id = self._dataset.split(".")[1]
bqs_client = _create_read_client()
table = f"projects/{self._project_id}/datasets/{dataset_id}/tables/{table_id}"
if parallelism == -1:
parallelism = None
requested_session = bigquery_storage.types.ReadSession(
table=table,
data_format=bigquery_storage.types.DataFormat.ARROW,
)
read_session = bqs_client.create_read_session(
parent=f"projects/{self._project_id}",
read_session=requested_session,
max_stream_count=parallelism,
)
read_tasks = []
logger.info("Created streams: " + str(len(read_session.streams)))
if len(read_session.streams) < parallelism:
logger.info(
"The number of streams created by the "
+ "BigQuery Storage Read API is less than the requested "
+ "parallelism due to the size of the dataset."
)
for stream in read_session.streams:
# Create a metadata block object to store schema, etc.
metadata = BlockMetadata(
num_rows=None,
size_bytes=None,
input_files=None,
exec_stats=None,
)
# Create the read task and pass the no-arg wrapper and metadata in
read_task = ReadTask(
lambda stream=stream: [_read_single_partition(stream)],
metadata,
per_task_row_limit=per_task_row_limit,
)
read_tasks.append(read_task)
return read_tasks
def estimate_inmemory_data_size(self) -> Optional[int]:
return None
def _validate_dataset_table_exist(self, project_id: str, dataset: str) -> None:
from google.api_core import exceptions
client = _create_client(project_id=project_id)
dataset_id = dataset.split(".")[0]
try:
client.get_dataset(dataset_id)
except exceptions.NotFound:
raise ValueError(
"Dataset {} is not found. Please ensure that it exists.".format(
dataset_id
)
)
try:
client.get_table(dataset)
except exceptions.NotFound:
raise ValueError(
"Table {} is not found. Please ensure that it exists.".format(dataset)
)
@@ -0,0 +1,24 @@
from typing import TYPE_CHECKING
from ray.data._internal.arrow_block import ArrowBlockBuilder
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
class BinaryDatasource(FileBasedDatasource):
"""Binary datasource, for reading and writing binary files."""
_COLUMN_NAME = "bytes"
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
data = f.readall()
builder = ArrowBlockBuilder()
item = {self._COLUMN_NAME: data}
builder.add(item)
yield builder.build()
def _rows_per_file(self):
return 1
@@ -0,0 +1,435 @@
import logging
import re
from dataclasses import dataclass
from enum import IntEnum
from typing import (
Any,
Dict,
Iterable,
Optional,
)
import pyarrow
import pyarrow as pa
import pyarrow.types as pat
from ray.data._internal.arrow_ops.transform_pyarrow import (
reorder_columns_by_schema,
)
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink, WriteReturnType
from ray.util.annotations import DeveloperAPI, PublicAPI
logger = logging.getLogger(__name__)
DEFAULT_DECIMAL_PRECISION = 38
DEFAULT_DECIMAL_SCALE = 10
def _pick_best_arrow_field_for_order_by(schema: pyarrow.Schema) -> str:
if len(schema) == 0:
return "tuple()"
# Prefer a timestamp column if available
for f in schema:
if pat.is_timestamp(f.type):
return f.name
# Next prefer a non-string column
for f in schema:
if not (pat.is_string(f.type) or pat.is_large_string(f.type)):
return f.name
# Otherwise pick the first column
return schema[0].name
def _arrow_to_clickhouse_type(field: pyarrow.Field) -> str:
"""Convert a PyArrow field to an appropriate ClickHouse column type."""
t = field.type
if pat.is_decimal(t):
precision = t.precision or DEFAULT_DECIMAL_PRECISION
scale = t.scale or DEFAULT_DECIMAL_SCALE
return f"Decimal({precision}, {scale})"
if pat.is_boolean(t):
return "UInt8"
if pat.is_int8(t):
return "Int8"
if pat.is_int16(t):
return "Int16"
if pat.is_int32(t):
return "Int32"
if pat.is_int64(t):
return "Int64"
if pat.is_uint8(t):
return "UInt8"
if pat.is_uint16(t):
return "UInt16"
if pat.is_uint32(t):
return "UInt32"
if pat.is_uint64(t):
return "UInt64"
if pat.is_float16(t):
return "Float32"
if pat.is_float32(t):
return "Float32"
if pat.is_float64(t):
return "Float64"
if pat.is_timestamp(t):
return "DateTime64(3)"
return "String"
@dataclass
class ClickHouseTableSettings:
"""
Additional table creation instructions for ClickHouse.
Attributes:
engine: The engine definition for the created table. Defaults
to "MergeTree()".
order_by: The ORDER BY clause for the table.
partition_by: The PARTITION BY clause for the table.
primary_key: The PRIMARY KEY clause for the table.
settings: Additional SETTINGS clause for the table
(comma-separated or any valid string).
"""
engine: str = "MergeTree()"
order_by: Optional[str] = None
partition_by: Optional[str] = None
primary_key: Optional[str] = None
settings: Optional[str] = None
@PublicAPI(stability="alpha")
class SinkMode(IntEnum):
"""
Enum of possible modes for sinking data
Attributes:
CREATE: Create a new table; fail if that table already exists.
APPEND: Use an existing table if present, otherwise create one; then append data.
OVERWRITE: Drop the table if it already exists, then re-create it and write.
"""
# Create a new table and fail if that table already exists.
CREATE = 1
# Append data to an existing table, or create one if it does not exist.
APPEND = 2
# Drop the table if it already exists, then re-create it and write.
OVERWRITE = 3
@DeveloperAPI
class ClickHouseDatasink(Datasink):
"""ClickHouse Ray Datasink.
A Ray Datasink for writing data into ClickHouse, with support for distributed
writes and mode-based table management (create, append, or overwrite).
Args:
table: Fully qualified table identifier (e.g., "default.my_table").
dsn: A string in DSN (Data Source Name) HTTP format
(e.g., "clickhouse+http://username:password@host:8123/default").
For more information, see `ClickHouse Connection String doc
<https://clickhouse.com/docs/en/integrations/sql-clients/cli#connection_string>`_.
mode: One of SinkMode.CREATE, SinkMode.APPEND,
or SinkMode.OVERWRITE.
- **CREATE**: Create a new table; fail if that table already exists.
Requires a user-supplied schema if the table doesnt already exist.
- **APPEND**: Use an existing table if present, otherwise create one.
If the table does not exist, the user must supply a schema. Data
is then appended to the table.
- **OVERWRITE**: Drop the table if it exists, then re-create it.
**Always requires** a user-supplied schema to define the new table.
schema: An optional PyArrow schema object that, if provided, will
override any inferred schema for table creation.
- If you are creating a new table (CREATE or APPEND when the table
doesnt exist) or overwriting an existing table, you **must**
provide a schema.
- If youre appending to an already-existing table, the schema is
not strictly required unless you want to cast data or enforce
column types. If omitted, the existing table definition is used.
client_settings: Optional ClickHouse server settings to be used with the
session/every request.
client_kwargs: Additional keyword arguments to pass to the
ClickHouse client.
table_settings: An optional dataclass with additional table creation
instructions (e.g., engine, order_by, partition_by, primary_key, settings).
max_insert_block_rows: If you have extremely large blocks, specifying
a limit here will chunk the insert into multiple smaller insert calls.
Defaults to None (no chunking).
"""
NAME = "ClickHouse"
_CREATE_TABLE_TEMPLATE = """
CREATE TABLE IF NOT EXISTS {table_name} (
{columns}
)
ENGINE = {engine}
ORDER BY {order_by}
{additional_props}
"""
_DROP_TABLE_TEMPLATE = """DROP TABLE IF EXISTS {table_name}"""
_CHECK_TABLE_EXISTS_TEMPLATE = """EXISTS {table_name}"""
_SHOW_CREATE_TABLE_TEMPLATE = """SHOW CREATE TABLE {table_name}"""
def __init__(
self,
table: str,
dsn: str,
mode: SinkMode = SinkMode.CREATE,
schema: Optional[pyarrow.Schema] = None,
client_settings: Optional[Dict[str, Any]] = None,
client_kwargs: Optional[Dict[str, Any]] = None,
table_settings: Optional[ClickHouseTableSettings] = None,
max_insert_block_rows: Optional[int] = None,
) -> None:
self._table = table
self._dsn = dsn
self._mode = mode
self._schema = schema
self._client_settings = client_settings or {}
self._client_kwargs = client_kwargs or {}
self._table_settings = table_settings or ClickHouseTableSettings()
self._max_insert_block_rows = max_insert_block_rows
self._table_dropped = False
def _init_client(self):
_check_import(self, module="clickhouse_connect", package="clickhouse-connect")
import clickhouse_connect
return clickhouse_connect.get_client(
dsn=self._dsn,
settings=self._client_settings,
**self._client_kwargs,
)
def _generate_create_table_sql(
self,
schema: pyarrow.Schema,
) -> str:
engine = self._table_settings.engine
if self._table_settings.order_by is not None:
order_by = self._table_settings.order_by
else:
order_by = _pick_best_arrow_field_for_order_by(schema)
additional_clauses = []
if self._table_settings.partition_by is not None:
additional_clauses.append(
f"PARTITION BY {self._table_settings.partition_by}"
)
if self._table_settings.primary_key is not None:
additional_clauses.append(
f"PRIMARY KEY ({self._table_settings.primary_key})"
)
if self._table_settings.settings is not None:
additional_clauses.append(f"SETTINGS {self._table_settings.settings}")
additional_props = ""
if additional_clauses:
additional_props = "\n" + "\n".join(additional_clauses)
columns_def = []
for field in schema:
ch_type = _arrow_to_clickhouse_type(field)
columns_def.append(f"`{field.name}` {ch_type}")
columns_str = ",\n ".join(columns_def)
return self._CREATE_TABLE_TEMPLATE.format(
table_name=self._table,
columns=columns_str,
engine=engine,
order_by=order_by,
additional_props=additional_props,
)
def _table_exists(self, client) -> bool:
try:
result = client.query(
self._CHECK_TABLE_EXISTS_TEMPLATE.format(table_name=self._table)
)
if result is None:
return False
for helper in ("scalar", "first_item", "first_value"):
_check_import(
self, module="clickhouse_connect", package="clickhouse-connect"
)
from clickhouse_connect.driver.exceptions import Error as CHError
if hasattr(result, helper):
try:
return bool(getattr(result, helper)())
except (TypeError, ValueError, CHError) as exc:
# Helper exists but failed continue probing.
logger.debug(
"Helper %s failed: %s; will try fallbacks", helper, exc
)
# Fallback to inspecting common container attributes.
for attr in ("result_rows", "rows", "data"):
rows = getattr(result, attr, None)
if rows:
first = rows[0]
# Unwrap an extra layer if present (i.e. [[1]] or [(1,)])
if isinstance(first, (list, tuple)):
first = first[0] if first else 0
return bool(first)
return False
except Exception as e:
logger.warning(f"Could not verify if table {self._table} exists: {e}")
return False
def _get_existing_order_by(self, client) -> Optional[str]:
logger.debug(
f"Retrieving ORDER BY clause from SHOW CREATE TABLE for {self._table}"
)
try:
show_create_sql = self._SHOW_CREATE_TABLE_TEMPLATE.format(
table_name=self._table
)
result = client.command(show_create_sql)
ddl_str = str(result)
pattern = r"(?is)\border\s+by\s+(.*?)(?=\bengine\b|$)"
match = re.search(pattern, ddl_str)
if match:
return match.group(1).strip()
return None
except Exception as e:
logger.warning(
f"Could not retrieve SHOW CREATE TABLE for {self._table}: {e}"
)
return None
@property
def supports_distributed_writes(self) -> bool:
return True
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
client = None
try:
client = self._init_client()
table_exists = self._table_exists(client)
schema_required = (
# Overwrite always needs a schema because it recreates the table
self._mode == SinkMode.OVERWRITE
# For CREATE or APPEND we need a schema only when the table is
# absent and will therefore be created in this call.
or (
self._mode in (SinkMode.CREATE, SinkMode.APPEND)
and not table_exists
)
)
if schema_required and self._schema is None:
if self._mode == SinkMode.OVERWRITE:
raise ValueError(
f"Overwriting table {self._table} requires a userprovided schema."
)
else:
raise ValueError(
f"Table {self._table} does not exist in mode='{self._mode.name}' and "
"no schema was provided. Cannot create the table without a schema."
)
# OVERWRITE MODE
if self._mode == SinkMode.OVERWRITE:
# If we plan to overwrite, drop the table if it exists,
# then re-create it using the user-provided schema.
if table_exists and self._table_settings.order_by is None:
# Collect existing ORDER BY. This lets us preserve it if the user
# hasn't specified one explicitly.
existing_order_by = self._get_existing_order_by(client)
if existing_order_by is not None:
self._table_settings.order_by = existing_order_by
logger.info(
f"Reusing old ORDER BY from overwritten table:"
f" {existing_order_by}"
)
# DROP and CREATE the table.
drop_sql = self._DROP_TABLE_TEMPLATE.format(table_name=self._table)
logger.info(f"Mode=OVERWRITE => {drop_sql}")
client.command(drop_sql)
self._table_dropped = True
create_sql = self._generate_create_table_sql(self._schema)
client.command(create_sql)
# CREATE MODE
elif self._mode == SinkMode.CREATE:
# If table already exists in CREATE mode, fail immediately.
if table_exists:
msg = (
f"Table {self._table} already exists in mode='CREATE'. "
"Use mode='APPEND' or 'OVERWRITE' instead."
)
logger.error(msg)
raise ValueError(msg)
# Otherwise, create it (requires user-provided schema).
create_sql = self._generate_create_table_sql(self._schema)
client.command(create_sql)
# APPEND MODE
elif self._mode == SinkMode.APPEND:
if table_exists:
# Table exists validate or adopt ORDER BY.
existing_order_by = self._get_existing_order_by(client)
user_order_by = self._table_settings.order_by
if user_order_by is not None:
# The user explicitly set an order_by. Check if it conflicts:
if existing_order_by and existing_order_by != user_order_by:
raise ValueError(
f"Conflict with order_by. The existing table {self._table} "
f"has ORDER BY {existing_order_by}, but the user specified "
f"ORDER BY {user_order_by}. Please drop or overwrite the table, "
f"or use the same ordering."
)
elif existing_order_by:
self._table_settings.order_by = existing_order_by
logger.info(
f"Reusing existing ORDER BY for table {self._table}: {existing_order_by}"
)
else:
# Table doesn't exist, so create it with user schema
create_sql = self._generate_create_table_sql(self._schema)
client.command(create_sql)
except Exception as e:
logger.error(
f"Could not complete on_write_start for table {self._table}: {e}"
)
raise e
finally:
if client:
client.close()
def get_name(self) -> str:
return self.NAME
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> WriteReturnType:
client = self._init_client()
total_inserted = 0
try:
for block in blocks:
arrow_table = BlockAccessor.for_block(block).to_arrow()
row_count = arrow_table.num_rows
if self._schema is not None:
arrow_table = reorder_columns_by_schema(arrow_table, self._schema)
arrow_table = arrow_table.cast(self._schema, safe=True)
if self._max_insert_block_rows is not None:
max_chunk_size = self._max_insert_block_rows
else:
# If max_insert_block_rows is not set, insert all rows in one go
max_chunk_size = row_count if row_count > 0 else 1
offsets = list(range(0, row_count, max_chunk_size))
offsets.append(row_count)
for i in range(len(offsets) - 1):
start = offsets[i]
end = offsets[i + 1]
chunk = arrow_table.slice(start, end - start)
client.insert_arrow(self._table, chunk)
total_inserted += chunk.num_rows
except Exception as e:
logger.error(f"Failed to write block(s) to table {self._table}: {e}")
raise e
finally:
client.close()
return [total_inserted]
@@ -0,0 +1,363 @@
import logging
import math
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
def _is_filter_string_safe(filter_str: str) -> bool:
in_string = False
escape_next = False
for c in filter_str:
if in_string:
# If we're inside a string, check if we're closing it.
if c == "'" and not escape_next:
in_string = False
escape_next = (c == "\\") and not escape_next
else:
# If we're not in a string, entering one if we see a single quote
if c == "'":
in_string = True
escape_next = False
# Disallow semicolon if we're not in a string
elif c == ";":
return False
else:
escape_next = False
# If we end inside a string, it's suspicious, but let's allow
# it to be further validated by the DB. Just return True here.
return True
@DeveloperAPI
class ClickHouseDatasource(Datasource):
"""
A Ray datasource for reading from ClickHouse.
Args:
table: Fully qualified table or view identifier (e.g.,
"default.table_name").
dsn: A string in DSN (Data Source Name) HTTP format (e.g.,
"clickhouse+http://username:password@host:8124/default").
For more information, see `ClickHouse Connection String doc
<https://clickhouse.com/docs/en/integrations/sql-clients/cli#connection_string>`_.
columns: Optional List of columns to select from the data source.
If no columns are specified, all columns will be selected by default.
filter: Optional SQL filter string that will be used in the
WHERE statement (e.g., "label = 2 AND text IS NOT NULL").
The filter must be valid for use in a ClickHouse SQL WHERE clause.
Note: Parallel reads are not currently supported when a filter is set.
Specifying a filter forces the parallelism to 1 to ensure deterministic
and consistent results. For more information, see
`ClickHouse SQL WHERE Clause doc
<https://clickhouse.com/docs/en/sql-reference/statements/select/where>`_.
order_by: Optional Tuple containing a list of columns to order by
and a boolean indicating the order. Note: order_by is required to
support parallelism.
client_settings: Optional ClickHouse server settings to be used with the
session/every request. For more information, see
`ClickHouse Client Settings doc
<https://clickhouse.com/docs/en/integrations/python#settings-argument>`_.
client_kwargs: Optional Additional keyword arguments to pass to the
ClickHouse client. For more information,
see `ClickHouse Core Settings doc
<https://clickhouse.com/docs/en/integrations/python#additional-options>`_.
"""
NUM_SAMPLE_ROWS = 100
MIN_ROWS_PER_READ_TASK = 50
_BASE_QUERY = "SELECT {select_clause} FROM {table}"
_EXPLAIN_FILTERS_QUERY = "EXPLAIN SELECT 1 FROM {table} WHERE {filter_clause}"
_SIZE_ESTIMATE_QUERY = "SELECT SUM(byteSize(*)) AS estimate FROM ({query})"
_COUNT_ESTIMATE_QUERY = "SELECT COUNT(*) AS estimate FROM ({query})"
_SAMPLE_BLOCK_QUERY = "{query} LIMIT {limit_row_count}"
_FIRST_BLOCK_QUERY = """
{query}
FETCH FIRST {fetch_row_count} {fetch_row_or_rows} ONLY
"""
_NEXT_BLOCK_QUERY = """
{query}
OFFSET {offset_row_count} {offset_row_or_rows}
FETCH NEXT {fetch_row_count} {fetch_row_or_rows} ONLY
"""
def __init__(
self,
table: str,
dsn: str,
columns: Optional[List[str]] = None,
filter: Optional[str] = None,
order_by: Optional[Tuple[List[str], bool]] = None,
client_settings: Optional[Dict[str, Any]] = None,
client_kwargs: Optional[Dict[str, Any]] = None,
):
self._table = table
self._dsn = dsn
self._columns = columns
self._filter = filter
self._order_by = order_by
self._client_settings = client_settings or {}
self._client_kwargs = client_kwargs or {}
self._query = self._generate_query()
def _init_client(self):
_check_import(self, module="clickhouse_connect", package="clickhouse-connect")
import clickhouse_connect
return clickhouse_connect.get_client(
dsn=self._dsn,
settings=self._client_settings or {},
**self._client_kwargs or {},
)
def _validate_filter(self):
if not self._filter:
return
# Minimal lexical check (regex or manual approach for semicolons, etc.).
if not _is_filter_string_safe(self._filter):
raise ValueError(
f"Invalid characters outside of "
f"string literals in filter: {self._filter}"
)
# Test "EXPLAIN" query to confirm parse-ability and catch expression errors.
client = self._init_client()
try:
test_query = self._EXPLAIN_FILTERS_QUERY.format(
table=self._table,
filter_clause=self._filter,
)
client.query(test_query)
except Exception as e:
raise ValueError(
f"Invalid filter expression: {self._filter}. Error: {e}",
)
finally:
client.close()
def _generate_query(self) -> str:
query = self._BASE_QUERY.format(
select_clause=", ".join(self._columns) if self._columns else "*",
table=self._table,
)
if self._filter:
self._validate_filter()
query += f" WHERE {self._filter}"
if self._order_by:
columns, desc = self._order_by
direction = " DESC" if desc else ""
if len(columns) == 1:
query += f" ORDER BY {columns[0]}{direction}"
elif len(columns) > 1:
columns_clause = ", ".join(columns)
query += f" ORDER BY ({columns_clause}){direction}"
return query
def _build_block_query(self, limit_row_count: int, offset_row_count: int) -> str:
if offset_row_count == 0:
# The first block query is optimized to use FETCH FIRST clause
# with an OFFSET specified.
return self._FIRST_BLOCK_QUERY.format(
query=self._query,
fetch_row_count=limit_row_count,
fetch_row_or_rows="ROWS" if limit_row_count > 1 else "ROW",
)
# Subsequent block queries use OFFSET and FETCH NEXT clauses to read the
# next block of data.
return self._NEXT_BLOCK_QUERY.format(
query=self._query,
offset_row_count=offset_row_count,
offset_row_or_rows="ROWS" if offset_row_count > 1 else "ROW",
fetch_row_count=limit_row_count,
fetch_row_or_rows="ROWS" if limit_row_count > 1 else "ROW",
)
def _create_read_fn(
self,
query: str,
) -> Callable[[], Iterable[Block]]:
def read_fn() -> Iterable[Block]:
return [self._execute_block_query(query)]
return read_fn
def _get_sampled_estimates(self):
if self._order_by is not None:
# If the query is ordered, we can use a FETCH clause to get a sample.
# This reduces the CPU overhead on ClickHouse and speeds up the
# estimation query.
query = self._FIRST_BLOCK_QUERY.format(
query=self._query,
fetch_row_count=self.NUM_SAMPLE_ROWS,
fetch_row_or_rows="ROWS" if self.NUM_SAMPLE_ROWS > 1 else "ROW",
)
else:
# If the query is not ordered, we need to use a LIMIT clause to
# get a sample.
query = self._SAMPLE_BLOCK_QUERY.format(
query=self._query,
limit_row_count=self.NUM_SAMPLE_ROWS,
)
sample_block_accessor = BlockAccessor.for_block(
self._execute_block_query(query)
)
estimated_size_bytes_per_row = math.ceil(
sample_block_accessor.size_bytes() / sample_block_accessor.num_rows()
)
sample_block_schema = sample_block_accessor.schema()
return estimated_size_bytes_per_row, sample_block_schema
def _get_estimate_count(self) -> Optional[int]:
return self._execute_estimate_query(self._COUNT_ESTIMATE_QUERY)
def _get_estimate_size(self) -> Optional[int]:
return self._execute_estimate_query(self._SIZE_ESTIMATE_QUERY)
def _execute_estimate_query(self, estimate_query: str) -> Optional[int]:
client = self._init_client()
try:
# Estimate queries wrap around the primary query, self._query.
# This allows us to use self._query as a sub-query to efficiently
# and accurately estimate the size or count of the result set.
query = estimate_query.format(query=self._query)
result = client.query(query)
if result and len(result.result_rows) > 0:
estimate = result.result_rows[0][0]
return int(estimate) if estimate is not None else None
except Exception as e:
logger.warning(f"Failed to execute estimate query: {e}")
finally:
client.close()
return None
def _execute_block_query(self, query: str) -> Block:
import pyarrow as pa
client = self._init_client()
try:
with client.query_arrow_stream(query) as stream:
record_batches = list(stream) # Collect all record batches
return pa.Table.from_batches(record_batches)
except Exception as e:
raise RuntimeError(f"Failed to execute block query: {e}")
finally:
client.close()
def estimate_inmemory_data_size(self) -> Optional[int]:
"""
Estimate the in-memory data size for the query.
Returns:
Estimated in-memory data size in bytes, or
None if the estimation cannot be performed.
"""
return self._get_estimate_size()
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
"""
Create read tasks for the ClickHouse query.
Args:
parallelism: The desired number of partitions to read the data into.
- If ``order_by`` is not set, parallelism will be forced to 1.
- If ``filter`` is set, parallelism will also be forced to 1
to ensure deterministic results.
per_task_row_limit: Maximum number of rows allowed in each emitted
task. Blocks larger than this limit will be sliced before
being yielded downstream.
data_context: The data context to use to get read tasks. Not used by this
datasource.
Returns:
A list of read tasks to be executed.
"""
num_rows_total = self._get_estimate_count()
if num_rows_total == 0 or num_rows_total is None:
return []
parallelism = min(
parallelism, math.ceil(num_rows_total / self.MIN_ROWS_PER_READ_TASK)
)
# To ensure consistent order of query results, self._order_by
# must be specified and self.filter must not be specified
# in order to support parallelism.
if self._filter is not None and parallelism > 1:
logger.warning(
"ClickHouse datasource does not currently support parallel reads "
"when a filter is set; falling back to parallelism of 1."
)
# When filter is specified and parallelism is greater than 1,
# we need to reduce parallelism to 1 to ensure consistent results.
parallelism = 1
# To ensure consistent order of query results, self._order_by
# must be specified in order to support parallelism.
if self._order_by is None and parallelism > 1:
logger.warning(
"ClickHouse datasource requires dataset to be explicitly ordered "
"to support parallelism; falling back to parallelism of 1."
)
# When order_by is not specified and parallelism is greater than 1,
# we need to reduce parallelism to 1 to ensure consistent results.
parallelism = 1
# By reducing parallelism to 1 when either of the conditions above are met,
# we ensure the downstream process is treated exactly as a non-parallelized
# (single block) process would be, thus ensuring output consistency.
num_rows_per_block = num_rows_total // parallelism
num_blocks_with_extra_row = num_rows_total % parallelism
(
estimated_size_bytes_per_row,
sample_block_schema,
) = self._get_sampled_estimates()
def _get_read_task(
block_rows: int, offset_rows: int, parallelized: bool
) -> ReadTask:
if parallelized:
# When parallelized, we need to build a block query with OFFSET
# and FETCH clauses.
query = self._build_block_query(block_rows, offset_rows)
else:
# When not parallelized, we can use the original query without
# OFFSET and FETCH clauses.
query = self._query
return ReadTask(
self._create_read_fn(query),
BlockMetadata(
num_rows=block_rows,
size_bytes=estimated_size_bytes_per_row * block_rows,
input_files=None,
exec_stats=None,
),
schema=sample_block_schema,
per_task_row_limit=per_task_row_limit,
)
if parallelism == 1:
# When parallelism is 1, we can read the entire dataset in a single task.
# We then optimize this scenario by using self._query directly without
# unnecessary OFFSET and FETCH clauses.
return [_get_read_task(num_rows_total, 0, False)]
# Otherwise we need to split the dataset into multiple tasks.
# Each task will include OFFSET and FETCH clauses to efficiently
# read a subset of the dataset.
read_tasks = []
offset = 0
for i in range(parallelism):
this_block_size = num_rows_per_block
if i < num_blocks_with_extra_row:
this_block_size += 1
read_tasks.append(_get_read_task(this_block_size, offset, True))
offset += this_block_size
return read_tasks
@@ -0,0 +1,36 @@
from typing import Any, Callable, Dict, Optional
import pyarrow
from ray.data.block import BlockAccessor
from ray.data.datasource.file_based_datasource import _resolve_kwargs
from ray.data.datasource.file_datasink import BlockBasedFileDatasink
class CSVDatasink(BlockBasedFileDatasink):
def __init__(
self,
path: str,
*,
arrow_csv_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
arrow_csv_args: Optional[Dict[str, Any]] = None,
file_format="csv",
**file_datasink_kwargs,
):
super().__init__(path, file_format=file_format, **file_datasink_kwargs)
if arrow_csv_args_fn is None:
arrow_csv_args_fn = lambda: {} # noqa: E731
if arrow_csv_args is None:
arrow_csv_args = {}
self.arrow_csv_args_fn = arrow_csv_args_fn
self.arrow_csv_args = arrow_csv_args
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
from pyarrow import csv
writer_args = _resolve_kwargs(self.arrow_csv_args_fn, **self.arrow_csv_args)
write_options = writer_args.pop("write_options", None)
csv.write_csv(block.to_arrow(), file, write_options, **writer_args)
@@ -0,0 +1,82 @@
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Union
from ray.data.block import Block
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
class CSVDatasource(FileBasedDatasource):
"""CSV datasource, for reading and writing CSV files."""
_FILE_EXTENSIONS = [
"csv",
"csv.gz", # gzip-compressed files
"csv.br", # Brotli-compressed files
"csv.zst", # Zstandard-compressed files
"csv.lz4", # lz4-compressed files
]
def __init__(
self,
paths: Union[str, List[str]],
arrow_csv_args: Optional[Dict[str, Any]] = None,
**file_based_datasource_kwargs,
):
from pyarrow import csv
super().__init__(paths, **file_based_datasource_kwargs)
if arrow_csv_args is None:
arrow_csv_args = {}
self.read_options = arrow_csv_args.pop(
"read_options", csv.ReadOptions(use_threads=False)
)
self.parse_options = arrow_csv_args.pop("parse_options", csv.ParseOptions())
self.arrow_csv_args = arrow_csv_args
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
import pyarrow as pa
from pyarrow import csv
# Re-init invalid row handler: https://issues.apache.org/jira/browse/ARROW-17641
if hasattr(self.parse_options, "invalid_row_handler"):
self.parse_options.invalid_row_handler = (
self.parse_options.invalid_row_handler
)
filter_expr = (
self._predicate_expr.to_pyarrow()
if self._predicate_expr is not None
else None
)
try:
reader = csv.open_csv(
f,
read_options=self.read_options,
parse_options=self.parse_options,
**self.arrow_csv_args,
)
schema = None
while True:
try:
batch = reader.read_next_batch()
table = pa.Table.from_batches([batch], schema=schema)
if schema is None:
schema = table.schema
if filter_expr is not None:
table = table.filter(filter_expr)
yield table
except StopIteration:
return
except pa.lib.ArrowInvalid as e:
raise ValueError(
f"Failed to read CSV file: {path}. "
"Please check the CSV file has correct format, or filter out non-CSV "
"file with 'partition_filter' field. See read_csv() documentation for "
"more details."
) from e
@@ -0,0 +1,285 @@
"""Databricks credential providers for Ray Data.
This module provides credential abstraction for Databricks authentication,
supporting static tokens with extensibility for future credential sources.
"""
import logging
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Optional, Union
import requests
logger = logging.getLogger(__name__)
# Default environment variable names for Databricks credentials
DEFAULT_TOKEN_ENV_VAR = "DATABRICKS_TOKEN"
DEFAULT_HOST_ENV_VAR = "DATABRICKS_HOST"
class DatabricksCredentialProvider(ABC):
"""Abstract base class for Databricks credential providers.
This abstraction allows different credential sources (static tokens,
file-based credentials, etc.) to be used with DatabricksUCDatasource.
Subclasses must implement:
- get_token(): Returns the current authentication token
- get_host(): Returns the Databricks host URL (optional)
- invalidate(): Clears any cached credentials
"""
@abstractmethod
def get_token(self) -> str:
"""Get the current authentication token.
Returns:
The Databricks authentication token string.
Raises:
ValueError: If no valid token is available.
"""
pass
@abstractmethod
def get_host(self) -> str:
"""Get the Databricks host URL.
Returns:
The Databricks host URL.
Raises:
ValueError: If no valid host is available.
"""
pass
@abstractmethod
def invalidate(self) -> None:
"""Invalidate any cached credentials.
This method should be called when credentials need to be refreshed,
such as after an authentication error.
"""
pass
class StaticCredentialProvider(DatabricksCredentialProvider):
"""A credential provider that wraps static token and host.
This is the simplest credential provider, useful when you have a
token that doesn't need to be refreshed.
Args:
token: The Databricks authentication token.
host: The Databricks host URL.
Raises:
ValueError: If token or host is empty or None.
"""
def __init__(self, token: str, host: str):
if not token:
raise ValueError("Token cannot be empty or None")
if not host:
raise ValueError("Host cannot be empty or None")
self._token = token
self._host = host
def get_token(self) -> str:
"""Get the static token.
Returns:
The authentication token provided at construction.
"""
return self._token
def get_host(self) -> str:
"""Get the host URL.
Returns:
The host URL provided at construction.
"""
return self._host
def invalidate(self) -> None:
"""No-op for static credentials.
Static credentials cannot be refreshed, so this method
does nothing.
"""
pass
class EnvironmentCredentialProvider(DatabricksCredentialProvider):
"""A credential provider that reads from environment variables.
Reads token and host from environment variables.
If host env var is not set and running in Databricks runtime,
automatically detects the host.
Args:
token_env_var: Environment variable name for the token.
Defaults to DEFAULT_TOKEN_ENV_VAR ("DATABRICKS_TOKEN").
host_env_var: Environment variable name for the host.
Defaults to DEFAULT_HOST_ENV_VAR ("DATABRICKS_HOST").
Raises:
ValueError: If token or host cannot be resolved.
"""
def __init__(
self,
token_env_var: str = DEFAULT_TOKEN_ENV_VAR,
host_env_var: str = DEFAULT_HOST_ENV_VAR,
):
self._token_env_var = token_env_var
self._host_env_var = host_env_var
# Validate token is set at initialization
token = os.environ.get(self._token_env_var)
if not token:
raise ValueError(
f"Environment variable '{self._token_env_var}' is not set. "
"Please set it to your Databricks access token."
)
self._token = token
# Resolve host: env var > Databricks runtime detection
host = os.environ.get(self._host_env_var) or self._detect_databricks_host()
if not host:
raise ValueError(
"You are not in databricks runtime, please set environment variable "
f"'{self._host_env_var}' to databricks workspace URL "
'(e.g. "adb-<workspace-id>.<random-number>.azuredatabricks.net").'
)
self._host = host
def _detect_databricks_host(self) -> Optional[str]:
"""Detect host from Databricks runtime if available."""
try:
from ray.util.spark.utils import is_in_databricks_runtime
if is_in_databricks_runtime():
import IPython
ip_shell = IPython.get_ipython()
if ip_shell is not None:
dbutils = ip_shell.ns_table["user_global"]["dbutils"]
ctx = (
dbutils.notebook.entry_point.getDbutils()
.notebook()
.getContext()
)
return ctx.tags().get("browserHostName").get()
except Exception as e:
logger.warning(f"Failed to detect Databricks host from runtime: {e}")
return None
def get_token(self) -> str:
"""Get the token from environment variable.
Returns:
The authentication token from the environment.
"""
return self._token
def get_host(self) -> str:
"""Get the host from environment variable or Databricks runtime.
Returns:
The host URL.
"""
return self._host
def invalidate(self) -> None:
"""Re-read token from environment variable.
This allows picking up refreshed tokens when the environment
variable is updated (e.g., by an external token refresh process).
"""
token = os.environ.get(self._token_env_var)
if token:
self._token = token
@dataclass
class BaseCredentialConfig:
credential_provider: Optional[DatabricksCredentialProvider] = None
@dataclass
class DatabricksTableCredentialConfig(BaseCredentialConfig):
pass
@dataclass
class UnityCatalogCredentialConfig(BaseCredentialConfig):
url: Optional[str] = None
token: Optional[str] = None
CredentialConfig = Union[DatabricksTableCredentialConfig, UnityCatalogCredentialConfig]
def resolve_credential_provider(
config: CredentialConfig,
) -> DatabricksCredentialProvider:
if config.credential_provider is not None:
return config.credential_provider
match config:
case DatabricksTableCredentialConfig():
return EnvironmentCredentialProvider()
case UnityCatalogCredentialConfig(url=str(url), token=str(token)):
return StaticCredentialProvider(token=token, host=url)
raise ValueError(
"Either 'credential_provider' or both 'url' and 'token' must be provided."
)
def build_headers(
credential_provider: DatabricksCredentialProvider,
) -> dict[str, str]:
"""Build request headers with fresh token from credential provider.
Args:
credential_provider: The credential provider to get the token from.
Returns:
Dictionary containing Content-Type and Authorization headers.
"""
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {credential_provider.get_token()}",
}
def request_with_401_retry(
request_fn: Callable[..., requests.Response],
url: str,
credential_provider: DatabricksCredentialProvider,
**kwargs,
) -> requests.Response:
"""Make an HTTP request with one retry on 401 after invalidating credentials.
Args:
request_fn: Request function (e.g., requests.get or requests.post)
url: Request URL
credential_provider: Credential provider for authentication
**kwargs: Additional arguments passed to requests
Returns:
Response object (after calling raise_for_status)
"""
response = request_fn(url, headers=build_headers(credential_provider), **kwargs)
if response.status_code == 401:
logger.info("Received 401 response, invalidating credentials and retrying.")
credential_provider.invalidate()
response = request_fn(url, headers=build_headers(credential_provider), **kwargs)
response.raise_for_status()
return response
@@ -0,0 +1,233 @@
import json
import logging
import os
import time
from typing import TYPE_CHECKING, List, Optional
from urllib.parse import urljoin
import numpy as np
import pyarrow
import requests
from ray.data._internal.datasource.databricks_credentials import (
DatabricksCredentialProvider,
build_headers,
request_with_401_retry,
)
from ray.data.block import BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
from ray.util.annotations import PublicAPI
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
_STATEMENT_EXEC_POLL_TIME_S = 1
@PublicAPI(stability="alpha")
class DatabricksUCDatasource(Datasource):
def __init__(
self,
warehouse_id: str,
catalog: str,
schema: str,
query: str,
credential_provider: DatabricksCredentialProvider,
):
self._credential_provider = credential_provider
# Get host from provider (token is fetched fresh for each request)
self.host = self._credential_provider.get_host()
self.warehouse_id = warehouse_id
self.catalog = catalog
self.schema_name = schema
self.query = query
if not self.host.startswith(("http://", "https://")):
self.host = f"https://{self.host}"
url_base = f"{self.host}/api/2.0/sql/statements/"
payload = json.dumps(
{
"statement": self.query,
"warehouse_id": self.warehouse_id,
"wait_timeout": "0s",
"disposition": "EXTERNAL_LINKS",
"format": "ARROW_STREAM",
"catalog": self.catalog,
"schema": self.schema_name,
}
)
response = request_with_401_retry(
requests.post,
url_base,
self._credential_provider,
data=payload,
)
statement_id = response.json()["statement_id"]
state = response.json()["status"]["state"]
logger.info(f"Waiting for query {query!r} execution result.")
try:
while state in ["PENDING", "RUNNING"]:
time.sleep(_STATEMENT_EXEC_POLL_TIME_S)
response = request_with_401_retry(
requests.get,
urljoin(url_base, statement_id) + "/",
self._credential_provider,
)
state = response.json()["status"]["state"]
except KeyboardInterrupt:
# User cancel the command, so we cancel query execution.
requests.post(
urljoin(url_base, f"{statement_id}/cancel"),
headers=build_headers(self._credential_provider),
)
try:
response.raise_for_status()
except Exception as e:
logger.warning(
f"Canceling query {query!r} execution failed, reason: {repr(e)}."
)
raise
if state != "SUCCEEDED":
raise RuntimeError(
f"Query {self.query!r} execution failed.\n{response.json()}"
)
manifest = response.json()["manifest"]
self.is_truncated = manifest.get("truncated", False)
if self.is_truncated:
logger.warning(
f"The resulting size of the dataset of '{query!r}' exceeds "
"100GiB and it is truncated."
)
chunks = manifest.get("chunks", [])
# Make chunks metadata are ordered by index.
chunks = sorted(chunks, key=lambda x: x["chunk_index"])
num_chunks = len(chunks)
self.num_chunks = num_chunks
self._estimate_inmemory_data_size = sum(chunk["byte_count"] for chunk in chunks)
# Capture credential provider (not self) to avoid serializing entire datasource
credential_provider_for_tasks = self._credential_provider
def get_read_task(
task_index: int, parallelism: int, per_task_row_limit: Optional[int] = None
):
# Handle empty chunk list by yielding an empty PyArrow table
if num_chunks == 0:
import pyarrow as pa
metadata = BlockMetadata(
num_rows=0,
size_bytes=0,
input_files=None,
exec_stats=None,
)
def empty_read_fn():
yield pa.Table.from_pydict({})
return ReadTask(read_fn=empty_read_fn, metadata=metadata)
# get chunk list to be read in this task and preserve original chunk order
chunk_index_list = list(
np.array_split(range(num_chunks), parallelism)[task_index]
)
num_rows = sum(
chunks[chunk_index]["row_count"] for chunk_index in chunk_index_list
)
size_bytes = sum(
chunks[chunk_index]["byte_count"] for chunk_index in chunk_index_list
)
metadata = BlockMetadata(
num_rows=num_rows,
size_bytes=size_bytes,
input_files=None,
exec_stats=None,
)
def _read_fn():
for chunk_index in chunk_index_list:
resolve_external_link_url = urljoin(
url_base, f"{statement_id}/result/chunks/{chunk_index}"
)
resolve_response = request_with_401_retry(
requests.get,
resolve_external_link_url,
credential_provider_for_tasks,
)
external_url = resolve_response.json()["external_links"][0][
"external_link"
]
# NOTE: do _NOT_ send the authorization header to external urls
raw_response = requests.get(external_url, auth=None, headers=None)
raw_response.raise_for_status()
with pyarrow.ipc.open_stream(raw_response.content) as reader:
arrow_table = reader.read_all()
yield arrow_table
def read_fn():
if mock_setup_fn_path := os.environ.get(
"RAY_DATABRICKS_UC_DATASOURCE_READ_FN_MOCK_TEST_SETUP_FN_PATH"
):
import ray.cloudpickle as pickle
# This is for testing.
with open(mock_setup_fn_path, "rb") as f:
mock_setup = pickle.load(f)
with mock_setup():
yield from _read_fn()
else:
yield from _read_fn()
return ReadTask(
read_fn=read_fn,
metadata=metadata,
per_task_row_limit=per_task_row_limit,
)
self._get_read_task = get_read_task
def estimate_inmemory_data_size(self) -> Optional[int]:
return self._estimate_inmemory_data_size
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
# Handle empty dataset case
if self.num_chunks == 0:
return [self._get_read_task(0, 1, per_task_row_limit)]
assert parallelism > 0, f"Invalid parallelism {parallelism}"
if parallelism > self.num_chunks:
parallelism = self.num_chunks
logger.info(
"The parallelism is reduced to chunk number due to "
"insufficient chunk parallelism."
)
return [
self._get_read_task(index, parallelism, per_task_row_limit)
for index in range(parallelism)
]
@@ -0,0 +1,138 @@
import logging
from json import loads
from typing import TYPE_CHECKING, List, Optional, Tuple
import numpy as np
from ray.data._internal.util import _check_import
from ray.data.block import BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
class DeltaSharingDatasource(Datasource):
def __init__(
self,
url: str,
json_predicate_hints: Optional[str] = None,
limit: Optional[int] = None,
version: Optional[int] = None,
timestamp: Optional[str] = None,
):
_check_import(self, module="delta_sharing", package="delta-sharing")
if limit is not None:
assert (
isinstance(limit, int) and limit >= 0
), "'limit' must be a non-negative int"
self._url = url
self._json_predicate_hints = json_predicate_hints
self._limit = limit
self._version = version
self._timestamp = timestamp
def estimate_inmemory_data_size(self) -> Optional[int]:
return None
def _read_files(self, files, converters):
"""Read files with Delta Sharing."""
from delta_sharing.reader import DeltaSharingReader
for file in files:
yield DeltaSharingReader._to_pandas(
action=file, converters=converters, for_cdf=False, limit=None
)
def setup_delta_sharing_connections(self, url: str):
"""
Set up delta sharing connections based on the url.
Args:
url: A URL under the format "<profile>#<share>.<schema>.<table>"
Returns:
A tuple of (table, rest_client) where table is a delta_sharing Table
object and rest_client is a DataSharingRestClient instance.
"""
from delta_sharing.protocol import DeltaSharingProfile, Table
from delta_sharing.rest_client import DataSharingRestClient
profile_str, share, schema, table_str = _parse_delta_sharing_url(url)
table = Table(name=table_str, share=share, schema=schema)
profile = DeltaSharingProfile.read_from_file(profile_str)
rest_client = DataSharingRestClient(profile)
return table, rest_client
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
assert parallelism > 0, f"Invalid parallelism {parallelism}"
from delta_sharing.converter import to_converters
self._table, self._rest_client = self.setup_delta_sharing_connections(self._url)
self._response = self._rest_client.list_files_in_table(
self._table,
jsonPredicateHints=self._json_predicate_hints,
limitHint=self._limit,
version=self._version,
timestamp=self._timestamp,
)
if len(self._response.add_files) == 0 or self._limit == 0:
logger.warning("No files found from the delta sharing table or limit is 0")
schema_json = loads(self._response.metadata.schema_string)
self._converters = to_converters(schema_json)
read_tasks = []
# get file list to be read in this task and preserve original chunk order
for files in np.array_split(self._response.add_files, parallelism):
files = files.tolist()
metadata = BlockMetadata(
num_rows=None,
input_files=files,
size_bytes=None,
exec_stats=None,
)
converters = self._converters
read_task = ReadTask(
lambda f=files: self._read_files(f, converters),
metadata,
per_task_row_limit=per_task_row_limit,
)
read_tasks.append(read_task)
return read_tasks
def _parse_delta_sharing_url(url: str) -> Tuple[str, str, str, str]:
"""
Developed from delta_sharing's _parse_url function.
https://github.com/delta-io/delta-sharing/blob/main/python/delta_sharing/delta_sharing.py#L36
Args:
url: a url under the format "<profile>#<share>.<schema>.<table>"
Returns:
a tuple with parsed (profile, share, schema, table)
"""
shape_index = url.rfind("#")
if shape_index < 0:
raise ValueError(f"Invalid 'url': {url}")
profile = url[0:shape_index]
fragments = url[shape_index + 1 :].split(".")
if len(fragments) != 3:
raise ValueError(f"Invalid 'url': {url}")
share, schema, table = fragments
if len(profile) == 0 or len(share) == 0 or len(schema) == 0 or len(table) == 0:
raise ValueError(f"Invalid 'url': {url}")
return (profile, share, schema, table)
@@ -0,0 +1,154 @@
import logging
import os
from enum import Enum
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Tuple
from ray.data._internal.util import _check_import
from ray.data.block import BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
class HudiQueryType(Enum):
SNAPSHOT = "snapshot"
INCREMENTAL = "incremental"
@classmethod
def supported_types(cls) -> List[str]:
return [e.value for e in cls]
class HudiDatasource(Datasource):
"""Hudi datasource, for reading Apache Hudi table."""
def __init__(
self,
table_uri: str,
query_type: str,
filters: Optional[List[Tuple[str, str, str]]] = None,
hudi_options: Optional[Dict[str, str]] = None,
storage_options: Optional[Dict[str, str]] = None,
):
_check_import(self, module="hudi", package="hudi-python")
self._table_uri = table_uri
self._query_type = HudiQueryType(query_type.lower())
self._filters = filters or []
self._hudi_options = hudi_options or {}
self._storage_options = storage_options or {}
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List["ReadTask"]:
import numpy as np
import pyarrow
from hudi import HudiTableBuilder
def _perform_read(
table_uri: str,
base_file_paths: List[str],
options: Dict[str, str],
) -> Iterator["pyarrow.Table"]:
from hudi import HudiFileGroupReader
for p in base_file_paths:
file_group_reader = HudiFileGroupReader(table_uri, options)
batch = file_group_reader.read_file_slice_by_base_file_path(p)
yield pyarrow.Table.from_batches([batch])
hudi_table = (
HudiTableBuilder.from_base_uri(self._table_uri)
.with_hudi_options(self._hudi_options)
.with_storage_options(self._storage_options)
# Although hudi-rs supports MOR snapshot, we need to add an API in
# the next release to allow file group reader to take in a list of
# files. Hence, setting this config for now to restrain reading
# only on parquet files (read optimized mode).
# This won't affect reading COW.
.with_hudi_option("hoodie.read.use.read_optimized.mode", "true")
.build()
)
logger.info("Collecting file slices for Hudi table at: %s", self._table_uri)
if self._query_type == HudiQueryType.SNAPSHOT:
file_slices_splits = hudi_table.get_file_slices_splits(
parallelism, self._filters
)
elif self._query_type == HudiQueryType.INCREMENTAL:
start_ts = self._hudi_options.get("hoodie.read.file_group.start_timestamp")
end_ts = self._hudi_options.get("hoodie.read.file_group.end_timestamp")
# TODO(xushiyan): add table API to return splits of file slices
file_slices = hudi_table.get_file_slices_between(start_ts, end_ts)
file_slices_splits = np.array_split(file_slices, parallelism)
else:
raise ValueError(
f"Unsupported query type: {self._query_type}. Supported types are: {HudiQueryType.supported_types()}."
)
logger.info("Creating read tasks for Hudi table at: %s", self._table_uri)
reader_options = {
**hudi_table.storage_options(),
**hudi_table.hudi_options(),
}
schema = hudi_table.get_schema()
read_tasks = []
for file_slices_split in file_slices_splits:
num_rows = 0
relative_paths = []
input_files = []
size_bytes = 0
for file_slice in file_slices_split:
# A file slice in a Hudi table is a logical group of data files
# within a physical partition. Records stored in a file slice
# are associated with a commit on the Hudi table's timeline.
# For more info, see https://hudi.apache.org/docs/file_layouts
num_rows += file_slice.num_records
relative_path = file_slice.base_file_relative_path()
relative_paths.append(relative_path)
full_path = os.path.join(self._table_uri, relative_path)
input_files.append(full_path)
size_bytes += file_slice.base_file_size
if self._query_type == HudiQueryType.SNAPSHOT:
metadata = BlockMetadata(
num_rows=num_rows,
input_files=input_files,
size_bytes=size_bytes,
exec_stats=None,
)
elif self._query_type == HudiQueryType.INCREMENTAL:
# need the check due to
# https://github.com/apache/hudi-rs/issues/401
metadata = BlockMetadata(
num_rows=None,
input_files=input_files,
size_bytes=None,
exec_stats=None,
)
read_task = ReadTask(
read_fn=lambda paths=relative_paths: _perform_read(
self._table_uri, paths, reader_options
),
metadata=metadata,
schema=schema,
per_task_row_limit=per_task_row_limit,
)
read_tasks.append(read_task)
return read_tasks
def estimate_inmemory_data_size(self) -> Optional[int]:
# TODO(xushiyan) add APIs to provide estimated in-memory size
return None
@@ -0,0 +1,194 @@
import sys
from typing import TYPE_CHECKING, Iterable, List, Optional, Union
from ray.data._internal.tensor_extensions.arrow import pyarrow_table_from_pydict
from ray.data._internal.util import _check_pyarrow_version
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.data.dataset import Dataset
from ray.data.datasource import Datasource, ReadTask
if TYPE_CHECKING:
import datasets
from ray.data.context import DataContext
TRANSFORMERS_IMPORT_ERROR: Optional[ImportError] = None
try:
# Due to HF Dataset's dynamic module system, we need to dynamically import the
# datasets_modules module on every actor when training.
# We accomplish this by simply running the following bit of code directly
# in the module you are currently viewing. This ensures that when we
# unpickle the Dataset, it runs before pickle tries to
# import datasets_modules and prevents an exception from being thrown.
# Same logic is present inside HF Transformers Ray
# integration: https://github.com/huggingface/transformers/blob/\
# 7d5fde991d598370d961be8cb7add6541e2b59ce/src/transformers/integrations.py#L271
# Also see https://github.com/ray-project/ray/issues/28084
from transformers.utils import is_datasets_available
if "datasets_modules" not in sys.modules and is_datasets_available():
import importlib
import importlib.metadata
import os
import datasets.load
from packaging.version import parse
# Datasets >= 4.0 removed dataset scripts support and the dynamic-modules cache.
# Only initialize dynamic modules on <= 3.x where the initializer `init_dynamic_modules` exists.
DATASETS_VERSION = parse(importlib.metadata.version("datasets"))
DATASETS_VERSION_WITHOUT_SCRIPT_SUPPORT = parse("4.0.0")
if DATASETS_VERSION < DATASETS_VERSION_WITHOUT_SCRIPT_SUPPORT:
dynamic_modules_path = os.path.join(
datasets.load.init_dynamic_modules(), "__init__.py"
)
# load dynamic_modules from path
spec = importlib.util.spec_from_file_location(
"datasets_modules", dynamic_modules_path
)
datasets_modules = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = datasets_modules
spec.loader.exec_module(datasets_modules)
except ImportError as e:
TRANSFORMERS_IMPORT_ERROR = e
class HuggingFaceDatasource(Datasource):
"""Hugging Face Dataset datasource, for reading from a
`Hugging Face Datasets Dataset <https://huggingface.co/docs/datasets/package_reference/main_classes#datasets.Dataset/>`_.
This Datasource implements a streamed read using a
single read task, most beneficial for a
`Hugging Face Datasets IterableDataset <https://huggingface.co/docs/datasets/package_reference/main_classes#datasets.IterableDataset/>`_
or datasets which are too large to fit in-memory.
For an in-memory Hugging Face Dataset (`datasets.Dataset`), use :meth:`~ray.data.from_huggingface`
directly for faster performance.
""" # noqa: E501
def __init__(
self,
dataset: Union["datasets.Dataset", "datasets.IterableDataset"],
batch_size: int = 4096,
):
if TRANSFORMERS_IMPORT_ERROR is not None:
raise TRANSFORMERS_IMPORT_ERROR
self._dataset = dataset
self._batch_size = batch_size
@classmethod
def list_parquet_urls_from_dataset(
cls, dataset: Union["datasets.Dataset", "datasets.IterableDataset"]
) -> Dataset:
"""Return list of Hugging Face hosted parquet file URLs if they
exist for the data (i.e. if the dataset is a public dataset that
has not been transformed) else return an empty list."""
import datasets
# We can use the dataset name, config name, and split name to load
# public hugging face datasets from the Hugging Face Hub. More info
# here: https://huggingface.co/docs/datasets-server/parquet
dataset_name = dataset.info.dataset_name
config_name = dataset.info.config_name
split_name = str(dataset.split)
# If a dataset is not an iterable dataset, we will check if the
# dataset with the matching dataset name, config name, and split name
# on the Hugging Face Hub has the same fingerprint as the
# dataset passed into this function. If it is not matching, transforms
# or other operations have been performed so we cannot use the parquet
# files on the Hugging Face Hub, so we return an empty list.
if not isinstance(dataset, datasets.IterableDataset):
from datasets import load_dataset
try:
ds = load_dataset(dataset_name, config_name, split=split_name)
if ds._fingerprint != dataset._fingerprint:
return []
except Exception:
# If an exception is thrown when trying to reload the dataset
# we should exit gracefully by returning an empty list.
return []
import requests
public_url = (
f"https://huggingface.co/api/datasets/{dataset_name}"
f"/parquet/{config_name}/{split_name}"
)
resp = requests.get(public_url)
if resp.status_code == requests.codes["ok"]:
# dataset corresponds to a public dataset, return list of parquet_files
return resp.json()
else:
return []
def estimate_inmemory_data_size(self) -> Optional[int]:
return self._dataset.dataset_size
def _read_dataset(self) -> Iterable[Block]:
# Note: This is a method instead of a higher level function because
# we need to capture `self`. This will trigger the try-import logic at
# the top of file to avoid import error of dataset_modules.
import numpy as np
import pandas as pd
import pyarrow
for batch in self._dataset.with_format("arrow").iter(
batch_size=self._batch_size
):
# HuggingFace IterableDatasets do not fully support methods like
# `set_format`, `with_format`, and `formatted_as`, so the dataset
# can return whatever is the default configured batch type, even if
# the format is manually overridden before iterating above.
# Therefore, we limit support to batch formats which have native
# block types in Ray Data (pyarrow.Table, pd.DataFrame),
# or can easily be converted to such (dict, np.array).
# See: https://github.com/huggingface/datasets/issues/3444
if not isinstance(batch, (pyarrow.Table, pd.DataFrame, dict, np.array)):
raise ValueError(
f"Batch format {type(batch)} isn't supported. Only the "
f"following batch formats are supported: "
f"dict (corresponds to `None` in `dataset.with_format()`), "
f"pyarrow.Table, np.array, pd.DataFrame."
)
# Ensure np.arrays are wrapped in a dict
# (subsequently converted to a pyarrow.Table).
if isinstance(batch, np.ndarray):
batch = {"item": batch}
if isinstance(batch, dict):
batch = pyarrow_table_from_pydict(batch)
# Ensure that we return the default block type.
block = BlockAccessor.for_block(batch).to_default()
yield block
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
# Note: `parallelism` arg is currently not used by HuggingFaceDatasource.
# We always generate a single ReadTask to perform the read.
_check_pyarrow_version()
# TODO(scottjlee): IterableDataset doesn't provide APIs
# for getting number of rows, byte size, etc., so the
# BlockMetadata is currently empty. Properly retrieve
# or calculate these so that progress bars have meaning.
meta = BlockMetadata(
num_rows=None,
size_bytes=None,
input_files=None,
exec_stats=None,
)
read_tasks: List[ReadTask] = [
ReadTask(
self._read_dataset,
meta,
per_task_row_limit=per_task_row_limit,
)
]
return read_tasks
@@ -0,0 +1,968 @@
"""
Module to write a Ray Dataset into an iceberg table, by using the Ray Datasink API.
"""
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Union
import ray
from ray._common.retry import call_with_retry
from ray.data._internal.datasource.parquet_datasource import (
PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT,
)
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.savemode import SaveMode
from ray.data._internal.util import MiB
from ray.data.block import Block, BlockAccessor
from ray.data.context import DataContext
from ray.data.datasource.datasink import Datasink, WriteResult
from ray.data.expressions import Expr
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
import pyarrow as pa
from pyiceberg.catalog import Catalog
from pyiceberg.expressions import BooleanExpression
from pyiceberg.io import FileIO
from pyiceberg.manifest import DataFile
from pyiceberg.schema import Schema
from pyiceberg.table import DataScan, FileScanTask, Table
from pyiceberg.table.metadata import TableMetadata
from pyiceberg.table.update.schema import UpdateSchema
logger = logging.getLogger(__name__)
_REWRITE_STALL_TIMEOUT_S = 600
@ray.remote
def _rewrite_iceberg_file(
file_scan_task: "FileScanTask",
keys_ref: "pa.Table",
upsert_cols: List[str],
table_metadata: "TableMetadata",
io: "FileIO",
) -> "tuple[Optional[DataFile], List[DataFile]]":
"""Read one Iceberg file, anti-join against upsert keys, write preserved rows.
Preserved rows are rows in the file that are not in the upsert batch. The
coarse range filter would delete them (see ``IcebergDatasink._build_coarse_range_filter``),
so we preserve them by writing them as new data files before the delete.
The file is read in streaming fashion via ``ArrowScan.to_record_batches()``
so the full file is never materialised at once. The anti-join is applied
per RecordBatch and preserved rows are accumulated, then concatenated and
written as a single output once the stream is exhausted.
Returns (original DataFile to delete, list of new preserved DataFiles).
If the entire file is matched (no preserved rows), returns (file, []).
If the file has no matched rows at all, returns (None, []), leave it untouched.
"""
import hashlib
import time as _time
import uuid as _uuid
import numpy as np
import pyarrow as pa
from pyiceberg.expressions import AlwaysTrue
from pyiceberg.io.pyarrow import ArrowScan, _dataframe_to_data_files
file_path = file_scan_task.file.file_path
file_name = file_path.split("/")[-1]
file_size_mb = file_scan_task.file.file_size_in_bytes / MiB
t_start = _time.perf_counter()
# Cast target pulled from keys_ref once. Applied per batch so PyArrow's join
# doesn't raise ArrowInvalid on utf8/large_utf8 or similar width mismatches.
target_key_schema = pa.schema([keys_ref.schema.field(c) for c in upsert_cols])
record_batches = ArrowScan(
table_metadata=table_metadata,
io=io,
projected_schema=table_metadata.schema(),
row_filter=AlwaysTrue(),
).to_record_batches(tasks=[file_scan_task])
preserved_rows: Optional["pa.Table"] = None
total_in_rows = 0
total_preserved_rows = 0
n_batches = 0
for rb in record_batches:
n_batches += 1
batch_table = pa.Table.from_batches([rb])
if len(batch_table) == 0:
continue
total_in_rows += len(batch_table)
batch_keys = batch_table.select(upsert_cols).cast(target_key_schema)
idx_col = pa.array(np.arange(len(batch_table), dtype=np.int64))
preserved_keys = batch_keys.append_column("__row_idx__", idx_col).join(
keys_ref, keys=upsert_cols, join_type="left anti"
)
if len(preserved_keys) > 0:
new_rows = batch_table.take(preserved_keys["__row_idx__"])
if preserved_rows is None:
preserved_rows = new_rows
else:
preserved_rows = pa.concat_tables(
[preserved_rows, new_rows], promote_options="permissive"
)
total_preserved_rows += len(preserved_keys)
t_read = _time.perf_counter()
logger.debug(
"[rewrite] stream-read+join %d rows / %.1f MB (compressed) from %s "
"across %d batch(es) in %.2fs",
total_in_rows,
file_size_mb,
file_name,
n_batches,
t_read - t_start,
)
if total_in_rows == 0:
return (None, [])
if total_preserved_rows == 0:
# Every row in this file is being upserted — delete the whole file, no preserved file needed.
logger.debug(
"[rewrite] %s: all %d rows matched -> whole-file delete",
file_name,
total_in_rows,
)
return (file_scan_task.file, [])
if total_preserved_rows == total_in_rows:
# No rows in this file match any upsert key — leave it alone entirely.
logger.debug("[rewrite] %s: 0 rows matched -> untouched", file_name)
return (None, [])
# Derive a deterministic write_uuid from the source file path so that
# task retries overwrite the same object rather than leaking orphan files.
preserved_write_uuid = _uuid.UUID(hashlib.md5(file_path.encode()).hexdigest())
preserved_files = list(
_dataframe_to_data_files(
table_metadata=table_metadata,
df=preserved_rows,
io=io,
write_uuid=preserved_write_uuid,
)
)
logger.debug(
"[rewrite] %s: %d/%d rows preserved -> wrote %d preserved file(s) in %.2fs",
file_name,
total_preserved_rows,
total_in_rows,
len(preserved_files),
_time.perf_counter() - t_read,
)
return (file_scan_task.file, preserved_files)
@dataclass
class IcebergWriteResult:
"""Result from writing blocks to Iceberg storage.
Attributes:
data_files: List of DataFile objects containing metadata about written Parquet files.
upsert_keys: PyArrow table containing key columns for upsert operations.
schemas: List of PyArrow schemas from all non-empty blocks.
"""
data_files: List["DataFile"] = field(default_factory=list)
upsert_keys: Optional["pa.Table"] = None
schemas: List["pa.Schema"] = field(default_factory=list)
_UPSERT_COLS_ID = "join_cols"
@DeveloperAPI
class IcebergDatasink(Datasink[IcebergWriteResult]):
"""
Iceberg datasink to write a Ray Dataset into an existing Iceberg table.
This datasink handles concurrent writes by:
- Each worker writes Parquet files to storage and returns DataFile metadata
- The driver collects all DataFile objects and performs a single commit
Schema evolution is supported:
- New columns in incoming data are automatically added to the table schema
- Type promotion across blocks is handled via schema reconciliation on the driver
"""
def __init__(
self,
table_identifier: str,
catalog_kwargs: Optional[Dict[str, Any]] = None,
snapshot_properties: Optional[Dict[str, str]] = None,
mode: SaveMode = SaveMode.APPEND,
overwrite_filter: Optional["Expr"] = None,
upsert_kwargs: Optional[Dict[str, Any]] = None,
overwrite_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Initialize the IcebergDatasink
Args:
table_identifier: The identifier of the table such as `default.taxi_dataset`
catalog_kwargs: Optional arguments to use when setting up the Iceberg catalog
snapshot_properties: Custom properties to write to snapshot summary
mode: Write mode - APPEND, UPSERT, or OVERWRITE. Defaults to APPEND.
- APPEND: Add new data without checking for duplicates
- UPSERT: Update existing rows or insert new ones based on a join condition
- OVERWRITE: Replace table data (all data or filtered subset)
overwrite_filter: Optional filter for OVERWRITE mode to perform partial overwrites.
Must be a Ray Data expression from `ray.data.expressions`. Only rows matching
this filter are replaced. If None with OVERWRITE mode, replaces all table data.
upsert_kwargs: Optional arguments for upsert operations.
Supported parameters: join_cols (List[str]), case_sensitive (bool),
branch (str). Note: This implementation uses a copy-on-write strategy
that always updates all columns for matched keys and inserts all new keys.
overwrite_kwargs: Optional arguments to pass through to PyIceberg's table.overwrite()
method. Supported parameters include case_sensitive (bool) and branch (str).
See PyIceberg documentation for details.
Note:
Schema evolution is automatically enabled. New columns in the incoming data
are automatically added to the table schema. The schema is extracted from
the first input bundle when on_write_start is called.
"""
self.table_identifier = table_identifier
self._catalog_kwargs = (catalog_kwargs or {}).copy()
self._snapshot_properties = (snapshot_properties or {}).copy()
self._mode = mode
self._overwrite_filter = overwrite_filter
self._upsert_kwargs = (upsert_kwargs or {}).copy()
self._overwrite_kwargs = (overwrite_kwargs or {}).copy()
# Validate kwargs are only set for relevant modes
if self._upsert_kwargs and self._mode != SaveMode.UPSERT:
raise ValueError(
f"upsert_kwargs can only be specified when mode is SaveMode.UPSERT, but mode is {self._mode}"
)
if self._overwrite_kwargs and self._mode != SaveMode.OVERWRITE:
raise ValueError(
f"overwrite_kwargs can only be specified when mode is SaveMode.OVERWRITE, but mode is {self._mode}"
)
if self._overwrite_filter and self._mode != SaveMode.OVERWRITE:
raise ValueError(
f"overwrite_filter can only be specified when mode is SaveMode.OVERWRITE, but mode is {self._mode}"
)
# Remove invalid parameters from overwrite_kwargs if present
for invalid_param, reason in [
(
"overwrite_filter",
"should be passed as a separate parameter to write_iceberg()",
),
(
"delete_filter",
"is an internal PyIceberg parameter; use 'overwrite_filter' instead",
),
]:
if self._overwrite_kwargs.pop(invalid_param, None) is not None:
logger.warning(
f"Removed '{invalid_param}' from overwrite_kwargs: {reason}"
)
if "name" in self._catalog_kwargs:
self._catalog_name = self._catalog_kwargs.pop("name")
else:
self._catalog_name = "default"
self._table: "Table" = None
self._io: "FileIO" = None
self._table_metadata: "TableMetadata" = None
self._data_context = DataContext.get_current()
def __getstate__(self) -> dict:
"""Exclude `_table` during pickling."""
state = self.__dict__.copy()
state.pop("_table", None)
return state
def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)
self._table = None
def _with_retry(self, func: Callable, description: str) -> Any:
"""Execute a function with retry logic.
This helper encapsulates the common retry pattern for Iceberg catalog
operations, using the configured retry parameters from DataContext.
Args:
func: The callable to execute with retry logic.
description: Human-readable description for logging/error messages.
Returns:
The result of calling func.
"""
iceberg_config = self._data_context.iceberg_config
return call_with_retry(
func,
description=description,
match=iceberg_config.catalog_retried_errors,
max_attempts=iceberg_config.catalog_max_attempts,
max_backoff_s=iceberg_config.catalog_retry_max_backoff_s,
)
def _get_catalog(self) -> "Catalog":
from pyiceberg import catalog
return self._with_retry(
lambda: catalog.load_catalog(self._catalog_name, **self._catalog_kwargs),
description=f"load Iceberg catalog '{self._catalog_name}'",
)
def _reload_table(self) -> None:
"""Reload the Iceberg table from the catalog."""
cat = self._get_catalog()
self._table = self._with_retry(
lambda: cat.load_table(self.table_identifier),
description=f"load Iceberg table '{self.table_identifier}'",
)
self._io = self._table.io
self._table_metadata = self._table.metadata
def _get_upsert_cols(self) -> List[str]:
"""Get join columns for upsert, using table identifier fields as fallback."""
upsert_cols = self._upsert_kwargs.get(_UPSERT_COLS_ID, [])
if not upsert_cols:
# Use table's identifier fields as fallback
identifier_cols = []
schema = self._table_metadata.schema()
for field_id in schema.identifier_field_ids:
col_name = schema.find_column_name(field_id)
if col_name:
identifier_cols.append(col_name)
return identifier_cols
case_sensitive = self._upsert_kwargs.get("case_sensitive", True)
# To support case insensitivity, we need to define a mapping of
# provided (possibly case-modified) names to their original names in the schema
if not case_sensitive:
schema = self._table_metadata.schema()
lower_to_original_mapping = {
col.name.lower(): col.name for col in schema.fields
}
resolved_upsert_cols = []
for upsert_col in upsert_cols:
resolved_col = lower_to_original_mapping.get(upsert_col.lower())
if resolved_col is None:
raise ValueError(
f"Upsert join column {upsert_col!r} does not match any column in "
f"table schema (case-insensitive)."
)
resolved_upsert_cols.append(resolved_col)
upsert_cols = resolved_upsert_cols
return upsert_cols
def _build_coarse_range_filter(
self,
keys_table: "pa.Table",
upsert_cols: List[str],
) -> "BooleanExpression":
"""Build an O(1) coarse range filter covering all upsert key values.
For each upsert column computes AND(GTE(col, min), LTE(col, max)).
The filter may match rows outside the upsert batch (filter overshoot);
callers must anti-join to identify and preserve those rows.
"""
import pyarrow.compute as pc
from pyiceberg.expressions import (
AlwaysTrue,
And,
GreaterThanOrEqual,
LessThanOrEqual,
)
expr = None
for col_name in upsert_cols:
mm = pc.min_max(keys_table[col_name])
min_val = mm["min"].as_py()
max_val = mm["max"].as_py()
if min_val is None:
continue
col_expr = And(
GreaterThanOrEqual(col_name, min_val),
LessThanOrEqual(col_name, max_val),
)
expr = col_expr if expr is None else And(expr, col_expr)
return expr if expr is not None else AlwaysTrue()
def _commit_upsert_scan_merge(
self,
txn: "Table.transaction",
data_files: List["DataFile"],
keys_table: "pa.Table",
upsert_cols: List[str],
) -> None:
"""Upsert commit using coarse range filter + per-file distributed anti-join.
┌─────────────────────────────────────────────────────────────┐
│ Stage 1: Build coarse filter (driver) │
│ keys_table ──► min/max per col ──► coarse_filter │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Stage 2: Plan candidate files (driver) │
│ table.scan(coarse_filter).plan_files() │
│ ──► file_scan_tasks │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Stage 3: Rewrite (one _rewrite_iceberg_file task per file) │
│ read file ─► anti-join keys ─► write preserved rows │
│ returns (old_file, preserved_files) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Stage 4: Atomic overwrite (driver) │
│ delete old_file (each rewritten candidate) │
│ append preserved_files (preserved rows kept) │
│ append data_files (new upsert payload) │
└─────────────────────────────────────────────────────────────┘
commit_transaction
1. Build an O(1) coarse range filter using min-max covering upsert key values (for each column).
2. plan_files() on the driver to find candidate files that could be updated
3. Dispatch one Ray task per candidate file. Each task reads its file,
anti-joins against the upsert keys to find preserved rows (rows that
the coarse delete would remove but that are NOT being upserted), and
writes them as new data files directly to storage.
4. Commit atomically via txn.update_snapshot().overwrite(): delete each
original candidate file and append preserved files + new upsert data files.
"""
import time
case_sensitive = self._upsert_kwargs.get("case_sensitive", True)
branch = self._upsert_kwargs.get("branch", "main")
unknown = set(self._upsert_kwargs) - {
_UPSERT_COLS_ID,
"case_sensitive",
"branch",
}
if unknown:
logger.warning(
"[scan-merge] ignoring unsupported upsert_kwargs: %s", sorted(unknown)
)
# Dedup keys to minimise per-task anti-join hash table size.
keys_table = keys_table.group_by(upsert_cols).aggregate([])
coarse_filter = self._build_coarse_range_filter(keys_table, upsert_cols)
logger.debug("[scan-merge] coarse_filter=%s", coarse_filter)
# plan_files() reads only manifest metadata, no Parquet data on the driver.
t0 = time.perf_counter()
scan: "DataScan" = self._table.scan(
row_filter=coarse_filter, case_sensitive=case_sensitive
)
# Use the specific branch for the scan
scan = scan.use_ref(branch)
file_scan_tasks: List["FileScanTask"] = list(scan.plan_files())
logger.info(
"[scan-merge] planned %d candidate file(s) in %.2fs",
len(file_scan_tasks),
time.perf_counter() - t0,
)
if not file_scan_tasks:
# No existing files match the coarse filter, so it's a pure insert.
self._append_and_commit(txn, data_files, branch=branch)
return
# Put the deduped keys in the object store once; all tasks share one copy.
keys_ref = ray.put(keys_table)
t0 = time.perf_counter()
refs = [
_rewrite_iceberg_file.options(
memory=int(
task.file.file_size_in_bytes
* PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT
* 3 # Bump memory estimate to account for the anti-join and the preserved rows (also since to_record_batches materializes the entire table in memory, see https://github.com/apache/iceberg-python/issues/3036)
),
num_cpus=1,
).remote(task, keys_ref, upsert_cols, self._table_metadata, self._io)
for task in file_scan_tasks
]
logger.info("[scan-merge] dispatched %d rewrite task(s)", len(refs))
# Collect results with periodic progress logs so long rewrites aren't silent.
results = []
pending = list(refs)
_LOG_INTERVAL = max(1, len(refs) // 10) # log ~10 times total
while pending:
done, pending = ray.wait(
pending,
num_returns=min(_LOG_INTERVAL, len(pending)),
timeout=_REWRITE_STALL_TIMEOUT_S,
fetch_local=True,
)
results.extend(ray.get(done))
logger.debug(
"[scan-merge] rewrite progress: %d/%d file(s) done (%.1fs elapsed)",
len(results),
len(refs),
time.perf_counter() - t0,
)
logger.info(
"[scan-merge] all %d file(s) rewritten in %.2fs",
len(refs),
time.perf_counter() - t0,
)
# Count how many files were wholly deleted vs partially rewritten.
n_whole_delete = n_partial = n_untouched = 0
for old, preserved_files in results:
if old is None:
n_untouched += 1
elif preserved_files:
n_partial += 1
else:
n_whole_delete += 1
logger.info(
"[scan-merge] files: %d whole-delete, %d partial-rewrite, %d untouched",
n_whole_delete,
n_partial,
n_untouched,
)
# Single atomic commit: schema update (already staged in txn), and overwrite.
# _OverwriteFiles handles both file-level deletes and appends in one snapshot.
t0 = time.perf_counter()
with txn.update_snapshot(
snapshot_properties=self._snapshot_properties, branch=branch
).overwrite() as snap:
for old_file, preserved_files in results:
if old_file is not None:
snap.delete_data_file(old_file)
for preserved_file in preserved_files:
snap.append_data_file(preserved_file)
for df in data_files:
snap.append_data_file(df)
self._with_retry(
txn.commit_transaction,
description=f"commit upsert transaction to Iceberg table '{self.table_identifier}'",
)
logger.info("[scan-merge] committed in %.2fs", time.perf_counter() - t0)
def _append_and_commit(
self,
txn: "Table.transaction",
data_files: List["DataFile"],
branch: str = "main",
) -> None:
"""Append data files to a transaction and commit.
Args:
txn: PyIceberg transaction object
data_files: List of DataFile objects to append
branch: Iceberg branch to commit the snapshot to. Defaults to "main"
to match pyiceberg's default
"""
with txn._append_snapshot_producer(
self._snapshot_properties, branch=branch
) as append_files:
for data_file in data_files:
append_files.append_data_file(data_file)
self._with_retry(
txn.commit_transaction,
description=f"commit transaction to Iceberg table '{self.table_identifier}'",
)
def _commit_upsert(
self,
txn: "Table.transaction",
data_files: List["DataFile"],
upsert_keys: Optional["pa.Table"],
) -> None:
"""
Commit upsert transaction with copy-on-write strategy.
Args:
txn: PyIceberg transaction object
data_files: List of DataFile objects to commit
upsert_keys: PyArrow table containing upsert key columns
"""
import functools
import time
import pyarrow as pa
# Create delete filter if we have join keys
if upsert_keys is not None and len(upsert_keys) > 0:
# Filter out rows with any NULL values in join columns
# (NULL != NULL in SQL semantics)
upsert_cols = self._get_upsert_cols()
logger.info(
"[upsert commit] Filtering NULL keys from %d rows on cols %s",
len(upsert_keys),
upsert_cols,
)
t0 = time.perf_counter()
masks = (pa.compute.is_valid(upsert_keys[col]) for col in upsert_cols)
mask = functools.reduce(pa.compute.and_, masks)
keys_table = upsert_keys.filter(mask)
logger.info(
"[upsert commit] NULL filter done in %.2fs: %d -> %d rows (dropped %d NULLs)",
time.perf_counter() - t0,
len(upsert_keys),
len(keys_table),
len(upsert_keys) - len(keys_table),
)
# Only delete if we have non-NULL keys
if len(keys_table) > 0:
self._commit_upsert_scan_merge(txn, data_files, keys_table, upsert_cols)
return
else:
logger.info("[upsert commit] No upsert keys — skipping delete phase")
# No non-NULL keys — just append new data files and commit
logger.info(
"[upsert commit] Appending %d data files and committing ...",
len(data_files),
)
t0 = time.perf_counter()
branch = self._upsert_kwargs.get("branch", "main")
self._append_and_commit(txn, data_files, branch=branch)
logger.info(
"[upsert commit] Append+commit done in %.2fs",
time.perf_counter() - t0,
)
def _preserve_identifier_field_requirements(
self, update: "UpdateSchema", table_schema: "Schema"
) -> None:
"""Ensure identifier fields remain required after schema union.
When union_by_name is called with a schema that has nullable fields,
PyIceberg may make identifier fields optional. Since identifier fields
must be required, this helper ensures they remain required after union.
Example:
Table schema: id: int (required, identifier), val: string
Input schema: id: int (optional), val: string
`union_by_name` merges them to:
id: int (optional), val: string
This violates the identifier constraint. This function forces `id`
back to required in the pending update.
Args:
update: The UpdateSchema object from update_schema() context manager
table_schema: The current table schema to get identifier field IDs from
"""
from pyiceberg.types import NestedField
identifier_field_ids = table_schema.identifier_field_ids
for field_id in identifier_field_ids:
# Check if this field has a pending update
if field_id in update._updates:
updated_field = update._updates[field_id]
# If it was made optional (likely by union_by_name), force it back to required
if not updated_field.required:
# Directly update the pending change to enforce required=True.
# We create a new NestedField because it might be immutable.
# We bypass _set_column_requirement because it has a check that
# incorrectly returns early if the original field is already required,
# ignoring the fact that we are overwriting a pending update.
update._updates[field_id] = NestedField(
field_id=updated_field.field_id,
name=updated_field.name,
field_type=updated_field.field_type,
doc=updated_field.doc,
required=True,
initial_default=updated_field.initial_default,
write_default=updated_field.write_default,
)
def _update_schema_with_union(
self,
update: "UpdateSchema",
new_schema: Union["pa.Schema", "Schema"],
table_schema: "Schema",
) -> None:
"""Update schema using union_by_name while preserving identifier field requirements.
Args:
update: The UpdateSchema object.
new_schema: The new schema to union with the table schema.
table_schema: The current table schema.
"""
update.union_by_name(new_schema)
self._preserve_identifier_field_requirements(update, table_schema)
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
"""Initialize table for writing and create a shared write UUID.
Args:
schema: The PyArrow schema of the data being written. This is
automatically extracted from the first input bundle by the
Write operator. Used to evolve the table schema before writing
to avoid PyIceberg name mapping errors.
"""
self._reload_table()
# Evolve schema BEFORE any files are written
# This prevents PyIceberg name mapping errors when incoming data has new columns
if schema is not None:
table_schema = self._table.metadata.schema()
def _update_schema():
with self._table.update_schema() as update:
self._update_schema_with_union(update, schema, table_schema)
self._with_retry(
_update_schema,
description=f"update schema for Iceberg table '{self.table_identifier}'",
)
# Succeeded, reload to get latest table version and exit.
self._reload_table()
# Validate join_cols for UPSERT mode before writing any files
if self._mode == SaveMode.UPSERT:
upsert_cols = self._upsert_kwargs.get(_UPSERT_COLS_ID, [])
if not upsert_cols:
# Check if table has identifier fields as fallback
identifier_field_ids = (
self._table.metadata.schema().identifier_field_ids
)
if not identifier_field_ids:
raise ValueError(
"join_cols must be specified in upsert_kwargs for UPSERT mode "
"when table has no identifier fields"
)
def write(self, blocks: Iterable[Block], ctx: TaskContext) -> IcebergWriteResult:
"""
Write blocks to Parquet files in storage and return DataFile metadata with schemas.
This runs on each worker in parallel. Files are written directly to storage
(S3, HDFS, etc.) and only metadata is returned to the driver.
Schema updates are NOT performed here - they happen on the driver.
Args:
blocks: Iterable of Ray Data blocks to write
ctx: TaskContext object containing task-specific information
Returns:
IcebergWriteResult containing DataFile objects, upsert keys, and schemas.
"""
from pyiceberg.io.pyarrow import _dataframe_to_data_files
all_data_files = []
upsert_keys_tables = []
block_schemas = []
use_copy_on_write_upsert = self._mode == SaveMode.UPSERT
for block in blocks:
pa_table = BlockAccessor.for_block(block).to_arrow()
if pa_table.num_rows > 0:
block_schemas.append(pa_table.schema)
# Extract join key values for copy-on-write upsert
if use_copy_on_write_upsert:
upsert_cols = self._get_upsert_cols()
if len(upsert_cols) > 0:
upsert_keys_tables.append(pa_table.select(upsert_cols))
# Write data files to storage with retry for transient errors
def _write_data_files():
return list(
_dataframe_to_data_files(
table_metadata=self._table_metadata,
df=pa_table,
io=self._io,
)
)
iceberg_config = self._data_context.iceberg_config
data_files = call_with_retry(
_write_data_files,
description=f"write data files to Iceberg table '{self.table_identifier}'",
match=self._data_context.retried_io_errors,
max_attempts=iceberg_config.write_file_max_attempts,
max_backoff_s=iceberg_config.write_file_retry_max_backoff_s,
)
all_data_files.extend(data_files)
# Combine all upsert key tables into one
from ray.data._internal.arrow_ops.transform_pyarrow import concat
upsert_keys = concat(upsert_keys_tables) if upsert_keys_tables else None
return IcebergWriteResult(
data_files=all_data_files,
upsert_keys=upsert_keys,
schemas=block_schemas,
)
def _commit_overwrite(
self, txn: "Table.transaction", data_files: List["DataFile"]
) -> None:
"""Commit data files using OVERWRITE mode."""
from pyiceberg.expressions import AlwaysTrue
# Default - Full overwrite - delete all
pyi_filter = AlwaysTrue()
# Delete matching data if filter provided
if self._overwrite_filter is not None:
from ray.data._internal.datasource.iceberg_datasource import (
_IcebergExpressionVisitor,
)
visitor = _IcebergExpressionVisitor()
pyi_filter = visitor.visit(self._overwrite_filter)
txn.delete(
delete_filter=pyi_filter,
snapshot_properties=self._snapshot_properties,
**self._overwrite_kwargs,
)
# Append on the same branch the delete targeted (defaults to "main").
branch = self._overwrite_kwargs.get("branch", "main")
self._append_and_commit(txn, data_files, branch=branch)
def on_write_complete(self, write_result: WriteResult) -> None:
"""
Complete the write by reconciling schemas and committing all data files.
This runs on the driver after all workers finish writing files.
Collects all DataFile objects and schemas from all workers, reconciles schemas
(allowing type promotion), updates table schema if needed, then performs a single
atomic commit.
"""
import time
t_start = time.perf_counter()
logger.info("[on_write_complete] Starting commit phase (mode=%s)", self._mode)
# Collect all data files and schemas from all workers
all_data_files: List["DataFile"] = []
all_schemas: List["pa.Schema"] = []
upsert_keys_tables: List["pa.Table"] = []
for write_return in write_result.write_returns:
if not write_return:
continue
if write_return.data_files: # Only add schema if we have data files
all_data_files.extend(write_return.data_files)
all_schemas.extend(write_return.schemas)
if write_return.upsert_keys is not None:
upsert_keys_tables.append(write_return.upsert_keys)
logger.info(
"[on_write_complete] Collected results: %d data files, %d schema blocks, "
"%d upsert key batches from workers (%.2fs)",
len(all_data_files),
len(all_schemas),
len(upsert_keys_tables),
time.perf_counter() - t_start,
)
if not all_data_files:
logger.info("[on_write_complete] No data files written, nothing to commit")
return
# Concatenate all upsert keys from all workers into a single table
from ray.data._internal.arrow_ops.transform_pyarrow import concat
if upsert_keys_tables:
total_key_rows = sum(len(t) for t in upsert_keys_tables)
logger.info(
"[on_write_complete] Concatenating %d upsert key batches (%d total rows) ...",
len(upsert_keys_tables),
total_key_rows,
)
t0 = time.perf_counter()
upsert_keys = concat(upsert_keys_tables)
logger.info(
"[on_write_complete] upsert key concat done in %.2fs: %d rows, cols=%s",
time.perf_counter() - t0,
len(upsert_keys),
upsert_keys.column_names,
)
else:
upsert_keys = None
# Reconcile all schemas from all blocks across all workers
# Get table schema and union with reconciled schema using unify_schemas with promotion
from pyiceberg.io import pyarrow as pyi_pa_io
from ray.data._internal.arrow_ops.transform_pyarrow import unify_schemas
logger.info("[on_write_complete] Reconciling %d schemas ...", len(all_schemas))
t0 = time.perf_counter()
table_schema = pyi_pa_io.schema_to_pyarrow(self._table.schema())
final_reconciled_schema = unify_schemas(
[table_schema] + all_schemas, promote_types=True
)
logger.info(
"[on_write_complete] Schema reconciliation done in %.2fs",
time.perf_counter() - t0,
)
# Create transaction and commit schema update + data files atomically
txn = self._table.transaction()
# Update table schema within the transaction if it differs
if not final_reconciled_schema.equals(table_schema):
logger.info(
"[on_write_complete] Schema changed — updating table schema ..."
)
t0 = time.perf_counter()
current_table_schema = self._table.metadata.schema()
with txn.update_schema() as update:
self._update_schema_with_union(
update, final_reconciled_schema, current_table_schema
)
logger.info(
"[on_write_complete] Schema update done in %.2fs",
time.perf_counter() - t0,
)
else:
logger.info("[on_write_complete] Schema unchanged, skipping update")
# Create transaction and commit based on mode
logger.info(
"[on_write_complete] Starting %s commit for %d data files ...",
self._mode,
len(all_data_files),
)
t0 = time.perf_counter()
if self._mode == SaveMode.APPEND:
self._append_and_commit(txn, all_data_files)
elif self._mode == SaveMode.OVERWRITE:
self._commit_overwrite(txn, all_data_files)
elif self._mode == SaveMode.UPSERT:
self._commit_upsert(txn, all_data_files, upsert_keys)
else:
raise ValueError(f"Unsupported mode: {self._mode}")
logger.info(
"[on_write_complete] Commit complete in %.2fs (total on_write_complete=%.2fs)",
time.perf_counter() - t0,
time.perf_counter() - t_start,
)
@@ -0,0 +1,514 @@
"""
Module to read an iceberg table into a Ray Dataset, by using the Ray Datasource API.
"""
import heapq
import itertools
import logging
from functools import partial
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple, Union
import pyarrow as pa
from packaging import version
from ray.data._internal.planner.plan_expression.expression_visitors import _ExprVisitor
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
from ray.data.expressions import (
AliasExpr,
BinaryExpr,
ColumnExpr,
DownloadExpr,
LiteralExpr,
MonotonicallyIncreasingIdExpr,
Operation,
RandomExpr,
StarExpr,
UDFExpr,
UnaryExpr,
UUIDExpr,
)
from ray.util import log_once
from ray.util.annotations import DeveloperAPI
try:
from pyiceberg.expressions import (
And,
EqualTo,
GreaterThan,
GreaterThanOrEqual,
In,
IsNull,
LessThan,
LessThanOrEqual,
Literal,
Not,
NotEqualTo,
NotIn,
NotNull,
Or,
Reference,
UnboundTerm,
literal,
)
RAY_DATA_OPERATION_TO_ICEBERG = {
Operation.EQ: EqualTo,
Operation.NE: NotEqualTo,
Operation.GT: GreaterThan,
Operation.GE: GreaterThanOrEqual,
Operation.LT: LessThan,
Operation.LE: LessThanOrEqual,
Operation.AND: And,
Operation.OR: Or,
Operation.IN: In,
Operation.NOT_IN: NotIn,
Operation.IS_NULL: IsNull,
Operation.IS_NOT_NULL: NotNull,
Operation.NOT: Not,
}
except ImportError:
log_once("pyiceberg.expressions not found. Please install pyiceberg >= 0.9.0")
if TYPE_CHECKING:
from pyiceberg.catalog import Catalog
from pyiceberg.expressions import BooleanExpression
from pyiceberg.io import FileIO
from pyiceberg.manifest import DataFile
from pyiceberg.schema import Schema
from pyiceberg.table import DataScan, FileScanTask, Table
from pyiceberg.table.metadata import TableMetadata
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
class _IcebergExpressionVisitor(
_ExprVisitor["BooleanExpression | UnboundTerm | Literal"]
):
"""
Visitor that converts Ray Data expressions to PyIceberg expressions.
This enables Ray Data users to write filters using the familiar col() syntax
while leveraging Iceberg's native filtering capabilities.
Example:
>>> from ray.data.expressions import col
>>> ray_expr = (col("date") >= "2024-01-01") & (col("status") == "active")
>>> iceberg_expr = _IcebergExpressionVisitor().visit(ray_expr)
>>> # iceberg_expr can now be used with PyIceberg's filter APIs
"""
def visit_column(self, expr: "ColumnExpr") -> "UnboundTerm":
"""Convert a column reference to an Iceberg reference."""
return Reference(expr.name)
def visit_literal(self, expr: "LiteralExpr") -> "Literal":
"""Convert a literal value to an Iceberg literal."""
return literal(expr.value)
def visit_binary(self, expr: "BinaryExpr") -> "BooleanExpression":
"""Convert a binary operation to an Iceberg expression."""
# Handle IN/NOT_IN specially since they don't visit the right operand
# (the right operand is a list literal that can't be converted)
if expr.op in (Operation.IN, Operation.NOT_IN):
left = self.visit(expr.left)
if not isinstance(expr.right, LiteralExpr):
raise ValueError(
f"{expr.op.name} operation requires right operand to be a literal list, "
f"got {type(expr.right).__name__}"
)
return RAY_DATA_OPERATION_TO_ICEBERG[expr.op](left, expr.right.value)
# For all other operations, visit both operands
left = self.visit(expr.left)
right = self.visit(expr.right)
if expr.op in RAY_DATA_OPERATION_TO_ICEBERG:
return RAY_DATA_OPERATION_TO_ICEBERG[expr.op](left, right)
else:
# Arithmetic operations are not supported in filter expressions
raise ValueError(
f"Unsupported binary operation for Iceberg filters: {expr.op}. "
f"Iceberg filters support: {RAY_DATA_OPERATION_TO_ICEBERG.keys()}. "
f"Arithmetic operations (ADD, SUB, MUL, DIV) cannot be used in filters."
)
def visit_unary(self, expr: "UnaryExpr") -> "BooleanExpression":
"""Convert a unary operation to an Iceberg expression."""
operand = self.visit(expr.operand)
if expr.op in RAY_DATA_OPERATION_TO_ICEBERG:
return RAY_DATA_OPERATION_TO_ICEBERG[expr.op](operand)
else:
raise ValueError(
f"Unsupported unary operation for Iceberg: {expr.op}. "
f"Supported operations: {RAY_DATA_OPERATION_TO_ICEBERG.keys()}"
)
def visit_alias(
self, expr: "AliasExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Convert an aliased expression (just unwrap the alias)."""
return self.visit(expr.expr)
def visit_udf(self, expr: "UDFExpr") -> "BooleanExpression | UnboundTerm | Literal":
"""UDF expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"UDF expressions cannot be converted to Iceberg expressions. "
"Iceberg filters must use simple column comparisons and boolean operations."
)
def visit_download(
self, expr: "DownloadExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Download expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"Download expressions cannot be converted to Iceberg expressions."
)
def visit_star(
self, expr: "StarExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Star expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"Star expressions cannot be converted to Iceberg filter expressions."
)
def visit_monotonically_increasing_id(
self, expr: "MonotonicallyIncreasingIdExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Monotonically increasing ID expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"monotonically_increasing_id expressions cannot be converted to Iceberg filter expressions."
)
def visit_random(
self, expr: "RandomExpr"
) -> "BooleanExpression | UnboundTerm[Any] | Literal[Any]":
"""Random expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"Random expressions cannot be converted to Iceberg filter expressions."
)
def visit_uuid(
self, expr: "UUIDExpr"
) -> "BooleanExpression | UnboundTerm[Any] | Literal[Any]":
"""UUID expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"UUID expressions cannot be converted to Iceberg filter expressions."
)
def _get_read_task(
tasks: Iterable["FileScanTask"],
table_io: "FileIO",
table_metadata: "TableMetadata",
row_filter: "BooleanExpression",
case_sensitive: bool,
limit: Optional[int],
schema: "Schema",
) -> Iterable[Block]:
# Determine the PyIceberg version to handle backward compatibility
import pyiceberg
def _generate_tables() -> Iterable[pa.Table]:
if version.parse(pyiceberg.__version__) >= version.parse("0.9.0"):
# Modern implementation using ArrowScan (PyIceberg 0.9.0+)
from pyiceberg.io.pyarrow import ArrowScan
# Initialize scanner with Iceberg metadata and query parameters
scanner = ArrowScan(
table_metadata=table_metadata,
io=table_io,
row_filter=row_filter,
projected_schema=schema,
case_sensitive=case_sensitive,
limit=limit,
)
# Convert scanned data to Arrow Table format
result_table = scanner.to_table(tasks=tasks)
# Stream results as RecordBatches for memory efficiency
for batch in result_table.to_batches():
yield pa.Table.from_batches([batch])
else:
# Legacy implementation using project_table (PyIceberg <0.9.0)
from pyiceberg.io import pyarrow as pyi_pa_io
# Use the PyIceberg API to read only a single task (specifically, a
# FileScanTask) - note that this is not as simple as reading a single
# parquet file, as there might be delete files, etc. associated, so we
# must use the PyIceberg API for the projection.
table = pyi_pa_io.project_table(
tasks=tasks,
table_metadata=table_metadata,
io=table_io,
row_filter=row_filter,
projected_schema=schema,
case_sensitive=case_sensitive,
limit=limit,
)
yield table
yield from _generate_tables()
@DeveloperAPI
class IcebergDatasource(Datasource):
"""
Iceberg datasource to read Iceberg tables into a Ray Dataset. This module heavily
uses PyIceberg to read iceberg tables. All the routines in this class override
`ray.data.Datasource`.
"""
def __init__(
self,
table_identifier: str,
row_filter: Union[str, "BooleanExpression"] = None,
selected_fields: Tuple[str, ...] = ("*",),
snapshot_id: Optional[int] = None,
scan_kwargs: Optional[Dict[str, Any]] = None,
catalog_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Initialize an IcebergDatasource.
Args:
table_identifier: Fully qualified table identifier (i.e.,
"db_name.table_name")
row_filter: A PyIceberg BooleanExpression to use to filter the data *prior*
to reading
selected_fields: Which columns from the data to read, passed directly to
PyIceberg's load functions
snapshot_id: Optional snapshot ID for the Iceberg table
scan_kwargs: Optional arguments to pass to PyIceberg's Table.scan()
function
catalog_kwargs: Optional arguments to use when setting up the Iceberg
catalog
"""
# Initialize parent class to set up predicate pushdown mixin
super().__init__()
_check_import(self, module="pyiceberg", package="pyiceberg")
from pyiceberg.expressions import AlwaysTrue
self._scan_kwargs = scan_kwargs if scan_kwargs is not None else {}
self._catalog_kwargs = catalog_kwargs if catalog_kwargs is not None else {}
if "name" in self._catalog_kwargs:
self._catalog_name = self._catalog_kwargs.pop("name")
else:
self._catalog_name = "default"
self.table_identifier = table_identifier
self._row_filter = row_filter if row_filter is not None else AlwaysTrue()
# Convert selected_fields to projection_map (identity mapping if specified)
# Note: Empty tuple () means no columns, None/"*" means all columns
if selected_fields is None or selected_fields == ("*",):
self._projection_map = None
else:
self._projection_map = {col: col for col in selected_fields}
if snapshot_id:
self._scan_kwargs["snapshot_id"] = snapshot_id
self._plan_files = None
self._table = None
def _get_catalog(self) -> "Catalog":
from pyiceberg import catalog
return catalog.load_catalog(self._catalog_name, **self._catalog_kwargs)
@property
def table(self) -> "Table":
"""
Return the table reference from the catalog
"""
if self._table is None:
catalog = self._get_catalog()
self._table = catalog.load_table(self.table_identifier)
return self._table
@property
def plan_files(self) -> List["FileScanTask"]:
"""
Return the plan files specified by this query
"""
# Calculate and cache the plan_files if they don't already exist
if self._plan_files is None:
data_scan = self._get_data_scan()
self._plan_files = data_scan.plan_files()
return self._plan_files
def _get_combined_filter(self) -> "BooleanExpression":
"""Get the combined filter including both row_filter and pushed-down predicates."""
combined_filter = self._row_filter
if self._predicate_expr is not None:
# Convert Ray Data expression to PyIceberg expression using internal visitor
visitor = _IcebergExpressionVisitor()
iceberg_filter = visitor.visit(self._predicate_expr)
# Combine with existing row_filter using AND
from pyiceberg.expressions import AlwaysTrue, And
if not isinstance(combined_filter, AlwaysTrue):
combined_filter = And(combined_filter, iceberg_filter)
else:
combined_filter = iceberg_filter
return combined_filter
def _get_data_scan(self) -> "DataScan":
# Get the combined filter
combined_filter = self._get_combined_filter()
# Convert back to tuple for PyIceberg API (None -> ("*",))
data_columns = self._get_data_columns()
selected_fields = ("*",) if data_columns is None else tuple(data_columns)
data_scan = self.table.scan(
row_filter=combined_filter,
selected_fields=selected_fields,
**self._scan_kwargs,
)
return data_scan
def estimate_inmemory_data_size(self) -> Optional[int]:
# Approximate the size by using the plan files - this will not
# incorporate the deletes, but that's a reasonable approximation
# task
return sum(task.file.file_size_in_bytes for task in self.plan_files)
def supports_predicate_pushdown(self) -> bool:
"""Returns True to indicate this datasource supports predicate pushdown."""
return True
def supports_projection_pushdown(self) -> bool:
"""Returns True to indicate this datasource supports projection pushdown."""
return True
@staticmethod
def _distribute_tasks_into_equal_chunks(
plan_files: Iterable["FileScanTask"], n_chunks: int
) -> List[List["FileScanTask"]]:
"""
Implement a greedy knapsack algorithm to distribute the files in the scan
across tasks, based on their file size, as evenly as possible
"""
chunks = [list() for _ in range(n_chunks)]
chunk_sizes = [(0, chunk_id) for chunk_id in range(n_chunks)]
heapq.heapify(chunk_sizes)
# From largest to smallest, add the plan files to the smallest chunk one at a
# time
for plan_file in sorted(
plan_files, key=lambda f: f.file.file_size_in_bytes, reverse=True
):
smallest_chunk = heapq.heappop(chunk_sizes)
chunks[smallest_chunk[1]].append(plan_file)
heapq.heappush(
chunk_sizes,
(
smallest_chunk[0] + plan_file.file.file_size_in_bytes,
smallest_chunk[1],
),
)
return chunks
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
from pyiceberg.io import pyarrow as pyi_pa_io
from pyiceberg.manifest import DataFileContent
# Get the PyIceberg scan
data_scan = self._get_data_scan()
# Get the plan files in this query
plan_files = self.plan_files
# Get the projected schema for this scan, given all the row filters,
# snapshot ID, etc.
projected_schema = data_scan.projection()
# Get the arrow schema, to set in the metadata
pya_schema = pyi_pa_io.schema_to_pyarrow(projected_schema)
# Set the n_chunks to the min of the number of plan files and the actual
# requested n_chunks, so that there are no empty tasks
if parallelism > len(list(plan_files)):
parallelism = len(list(plan_files))
logger.warning(
f"Reducing the parallelism to {parallelism}, as that is the number of files"
)
# Get required properties for reading tasks - table IO, table metadata,
# row filter, case sensitivity,limit and projected schema to pass
# them directly to `_get_read_task` to avoid capture of `self` reference
# within the closure carrying substantial overhead invoking these tasks
#
# See https://github.com/ray-project/ray/issues/49107 for more context
table_io = self.table.io
table_metadata = self.table.metadata
row_filter = self._get_combined_filter()
case_sensitive = self._scan_kwargs.get("case_sensitive", True)
limit = self._scan_kwargs.get("limit")
get_read_task = partial(
_get_read_task,
table_io=table_io,
table_metadata=table_metadata,
row_filter=row_filter,
case_sensitive=case_sensitive,
limit=limit,
schema=projected_schema,
)
read_tasks = []
# Chunk the plan files based on the requested parallelism
for chunk_tasks in IcebergDatasource._distribute_tasks_into_equal_chunks(
plan_files, parallelism
):
unique_deletes: Set[DataFile] = set(
itertools.chain.from_iterable(
[task.delete_files for task in chunk_tasks]
)
)
# Get a rough estimate of the number of deletes by just looking at
# position deletes. Equality deletes are harder to estimate, as they
# can delete multiple rows.
position_delete_count = sum(
delete.record_count
for delete in unique_deletes
if delete.content == DataFileContent.POSITION_DELETES
)
metadata = BlockMetadata(
num_rows=sum(task.file.record_count for task in chunk_tasks)
- position_delete_count,
size_bytes=sum(task.file.file_size_in_bytes for task in chunk_tasks),
input_files=[task.file.file_path for task in chunk_tasks],
exec_stats=None,
)
read_tasks.append(
ReadTask(
read_fn=lambda tasks=chunk_tasks: get_read_task(tasks),
metadata=metadata,
schema=pya_schema,
per_task_row_limit=per_task_row_limit,
)
)
return read_tasks
@@ -0,0 +1,24 @@
import io
from typing import Any, Dict
import pyarrow
from ray.data.datasource.file_datasink import RowBasedFileDatasink
class ImageDatasink(RowBasedFileDatasink):
def __init__(
self, path: str, column: str, file_format: str, **file_datasink_kwargs
):
super().__init__(path, file_format=file_format, **file_datasink_kwargs)
self.column = column
self.file_format = file_format
def write_row_to_file(self, row: Dict[str, Any], file: "pyarrow.NativeFile"):
from PIL import Image
image = Image.fromarray(row[self.column])
buffer = io.BytesIO()
image.save(buffer, format=self.file_format)
file.write(buffer.getvalue())
@@ -0,0 +1,177 @@
import io
import logging
import time
from dataclasses import replace
from typing import TYPE_CHECKING, Iterator, List, Optional, Tuple, Union
import numpy as np
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.file_based_datasource import FileBasedDatasource
from ray.data.datasource.file_meta_provider import DefaultFileMetadataProvider
if TYPE_CHECKING:
import pyarrow
logger = logging.getLogger(__name__)
# The default size multiplier for reading image data source.
# This essentially is using image on-disk file size to estimate
# in-memory data size.
IMAGE_ENCODING_RATIO_ESTIMATE_DEFAULT = 1
# The lower bound value to estimate image encoding ratio.
IMAGE_ENCODING_RATIO_ESTIMATE_LOWER_BOUND = 0.5
class ImageDatasource(FileBasedDatasource):
"""A datasource that lets you read images."""
_WRITE_FILE_PER_ROW = True
_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif"]
# Use 8 threads per task to read image files.
_NUM_THREADS_PER_TASK = 8
def __init__(
self,
paths: Union[str, List[str]],
size: Optional[Tuple[int, int]] = None,
mode: Optional[str] = None,
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
_check_import(self, module="PIL", package="Pillow")
if size is not None and len(size) != 2:
raise ValueError(
"Expected `size` to contain two integers for height and width, "
f"but got {len(size)} integers instead."
)
if size is not None and (size[0] < 0 or size[1] < 0):
raise ValueError(
f"Expected `size` to contain positive integers, but got {size} instead."
)
self.size = size
self.mode = mode
meta_provider = file_based_datasource_kwargs.get("meta_provider", None)
if isinstance(meta_provider, ImageFileMetadataProvider):
self._encoding_ratio = self._estimate_files_encoding_ratio()
meta_provider._set_encoding_ratio(self._encoding_ratio)
else:
self._encoding_ratio = IMAGE_ENCODING_RATIO_ESTIMATE_DEFAULT
def _read_stream(
self,
f: "pyarrow.NativeFile",
path: str,
) -> Iterator[Block]:
from PIL import Image, UnidentifiedImageError
data = f.readall()
try:
image = Image.open(io.BytesIO(data))
except UnidentifiedImageError as e:
raise ValueError(f"PIL couldn't load image file at path '{path}'.") from e
if self.size is not None and image.size != tuple(reversed(self.size)):
height, width = self.size
image = image.resize((width, height), resample=Image.BILINEAR)
if self.mode is not None and image.mode != self.mode:
image = image.convert(self.mode)
builder = DelegatingBlockBuilder()
array = np.asarray(image)
item = {"image": array}
builder.add(item)
block = builder.build()
yield block
def _rows_per_file(self):
return 1
def estimate_inmemory_data_size(self) -> Optional[int]:
total_size = 0
for file_size in self._file_sizes():
# NOTE: check if file size is not None, because some metadata providers
# may not provide file size information.
if file_size is not None:
total_size += file_size
return total_size * self._encoding_ratio
def _estimate_files_encoding_ratio(self) -> float:
"""Return an estimate of the image files encoding ratio."""
start_time = time.perf_counter()
# Filter out empty file to avoid noise.
non_empty_path_and_size = list(
filter(lambda p: p[1] > 0, zip(self._paths(), self._file_sizes()))
)
num_files = len(non_empty_path_and_size)
if num_files == 0:
logger.warning(
"All input image files are empty. "
"Use on-disk file size to estimate images in-memory size."
)
return IMAGE_ENCODING_RATIO_ESTIMATE_DEFAULT
if self.size is not None and self.mode is not None:
# Use image size and mode to calculate data size for all images,
# because all images are homogeneous with same size after resizing.
# Resizing is enforced when reading every image in `ImageDatasource`
# when `size` argument is provided.
if self.mode in ["1", "L", "P"]:
dimension = 1
elif self.mode in ["RGB", "YCbCr", "LAB", "HSV"]:
dimension = 3
elif self.mode in ["RGBA", "CMYK", "I", "F"]:
dimension = 4
else:
logger.warning(f"Found unknown image mode: {self.mode}.")
return IMAGE_ENCODING_RATIO_ESTIMATE_DEFAULT
height, width = self.size
single_image_size = height * width * dimension
total_estimated_size = single_image_size * num_files
total_file_size = sum(p[1] for p in non_empty_path_and_size)
ratio = total_estimated_size / total_file_size
else:
# TODO(chengsu): sample images to estimate data size
ratio = IMAGE_ENCODING_RATIO_ESTIMATE_DEFAULT
sampling_duration = time.perf_counter() - start_time
if sampling_duration > 5:
logger.warning(
"Image input size estimation took "
f"{round(sampling_duration, 2)} seconds."
)
logger.debug(f"Estimated image encoding ratio from sampling is {ratio}.")
return max(ratio, IMAGE_ENCODING_RATIO_ESTIMATE_LOWER_BOUND)
class ImageFileMetadataProvider(DefaultFileMetadataProvider):
def _set_encoding_ratio(self, encoding_ratio: int):
"""Set image file encoding ratio, to provide accurate size in bytes metadata."""
self._encoding_ratio = encoding_ratio
def _get_block_metadata(
self,
paths: List[str],
*,
rows_per_file: Optional[int],
file_sizes: List[Optional[int]],
) -> BlockMetadata:
metadata = super()._get_block_metadata(
paths, rows_per_file=rows_per_file, file_sizes=file_sizes
)
if metadata.size_bytes is not None:
metadata = replace(
metadata, size_bytes=int(metadata.size_bytes * self._encoding_ratio)
)
return metadata
@@ -0,0 +1,36 @@
from typing import Any, Callable, Dict, Optional
import pyarrow
from ray.data.block import BlockAccessor
from ray.data.datasource.file_based_datasource import _resolve_kwargs
from ray.data.datasource.file_datasink import BlockBasedFileDatasink
class JSONDatasink(BlockBasedFileDatasink):
def __init__(
self,
path: str,
*,
pandas_json_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
pandas_json_args: Optional[Dict[str, Any]] = None,
file_format: str = "json",
**file_datasink_kwargs,
):
super().__init__(path, file_format=file_format, **file_datasink_kwargs)
if pandas_json_args_fn is None:
pandas_json_args_fn = lambda: {} # noqa: E731
if pandas_json_args is None:
pandas_json_args = {}
self.pandas_json_args_fn = pandas_json_args_fn
self.pandas_json_args = pandas_json_args
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
writer_args = _resolve_kwargs(self.pandas_json_args_fn, **self.pandas_json_args)
orient = writer_args.pop("orient", "records")
lines = writer_args.pop("lines", True)
block.to_pandas().to_json(file, orient=orient, lines=lines, **writer_args)
@@ -0,0 +1,293 @@
import io
import logging
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
import pandas as pd
from ray.data._internal.pandas_block import PandasBlockAccessor
from ray.data._internal.tensor_extensions.arrow import pyarrow_table_from_pydict
from ray.data.context import DataContext
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
logger = logging.getLogger(__name__)
JSON_FILE_EXTENSIONS = [
"json",
"jsonl",
# gzip-compressed files
"json.gz",
"jsonl.gz",
# Brotli-compressed fi;es
"json.br",
"jsonl.br",
# Zstandard-compressed files
"json.zst",
"jsonl.zst",
# lz4-compressed files
"json.lz4",
"jsonl.lz4",
]
class ArrowJSONDatasource(FileBasedDatasource):
"""JSON datasource, for reading and writing JSON and JSONL files."""
def __init__(
self,
paths: Union[str, List[str]],
*,
arrow_json_args: Optional[Dict[str, Any]] = None,
**file_based_datasource_kwargs,
):
from pyarrow import json
super().__init__(paths, **file_based_datasource_kwargs)
if arrow_json_args is None:
arrow_json_args = {}
self.read_options = arrow_json_args.pop(
"read_options", json.ReadOptions(use_threads=False)
)
self.arrow_json_args = arrow_json_args
def _read_with_pyarrow_read_json(self, buffer: "pyarrow.lib.Buffer"):
"""Read with PyArrow JSON reader, trying to auto-increase the
read block size in the case of the read object
straddling block boundaries."""
import pyarrow as pa
import pyarrow.json as pajson
# When reading large files, the default block size configured in PyArrow can be
# too small, resulting in the following error: `pyarrow.lib.ArrowInvalid:
# straddling object straddles two block boundaries (try to increase block
# size?)`. More information on this issue can be found here:
# https://github.com/apache/arrow/issues/25674
# The read will be retried with geometrically increasing block size
# until the size reaches `DataContext.get_current().target_max_block_size`.
# The initial block size will start at the PyArrow default block size
# or it can be manually set through the `read_options` parameter as follows.
# >>> import pyarrow.json as pajson
# >>> block_size = 10 << 20 # Set block size to 10MB
# >>> ray.data.read_json( # doctest: +SKIP
# ... "s3://anonymous@ray-example-data/log.json",
# ... read_options=pajson.ReadOptions(block_size=block_size)
# ... )
init_block_size = self.read_options.block_size
max_block_size = DataContext.get_current().target_max_block_size
while True:
try:
yield pajson.read_json(
io.BytesIO(buffer),
read_options=self.read_options,
**self.arrow_json_args,
)
self.read_options.block_size = init_block_size
break
except pa.ArrowInvalid as e:
if "straddling object straddles two block boundaries" in str(e):
if (
max_block_size is None
or self.read_options.block_size < max_block_size
):
# Increase the block size in case it was too small.
logger.debug(
f"JSONDatasource read failed with "
f"block_size={self.read_options.block_size}. Retrying with "
f"block_size={self.read_options.block_size * 2}."
)
self.read_options.block_size *= 2
else:
raise pa.ArrowInvalid(
f"{e} - Auto-increasing block size to "
f"{self.read_options.block_size} bytes failed. "
f"Please try manually increasing the block size through "
f"the `read_options` parameter to a larger size. "
f"For example: `read_json(..., read_options="
f"pyarrow.json.ReadOptions(block_size=10 << 25))`"
f"More information on this issue can be found here: "
f"https://github.com/apache/arrow/issues/25674"
)
else:
# unrelated error, simply reraise
raise e
def _read_with_python_json(self, buffer: "pyarrow.lib.Buffer"):
"""Fallback method to read JSON files with Python's native json.load(),
in case the default pyarrow json reader fails."""
import json
import pyarrow as pa
# Check if the buffer is empty
if buffer.size == 0:
return
parsed_json = json.load(io.BytesIO(buffer))
try:
yield pa.Table.from_pylist(parsed_json)
except AttributeError as e:
# For PyArrow < 7.0.0, `pa.Table.from_pylist()` is not available.
# Construct a dict from the list and call
# `pa.Table.from_pydict()` instead.
assert "no attribute 'from_pylist'" in str(e), str(e)
from collections import defaultdict
dct = defaultdict(list)
for row in parsed_json:
for k, v in row.items():
dct[k].append(v)
yield pyarrow_table_from_pydict(dct)
# TODO(ekl) The PyArrow JSON reader doesn't support streaming reads.
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
import pyarrow as pa
buffer: pa.lib.Buffer = f.read_buffer()
try:
yield from self._read_with_pyarrow_read_json(buffer)
except pa.ArrowInvalid as e:
# If read with PyArrow fails, try falling back to native json.load().
logger.warning(
f"Error reading with pyarrow.json.read_json(). "
f"Falling back to native json.load(), which may be slower. "
f"PyArrow error was:\n{e}"
)
yield from self._read_with_python_json(buffer)
class PandasJSONDatasource(FileBasedDatasource):
# Buffer size in bytes for reading files. Default is 1MB.
#
# pandas reads data in small chunks (~8 KiB), which leads to many costly
# small read requests when accessing cloud storage. To reduce overhead and
# improve performance, we wrap the file in a larger buffered reader that
# reads bigger blocks at once.
_BUFFER_SIZE = 1024**2
# In the case of zipped json files, we cannot infer the chunk_size.
_DEFAULT_CHUNK_SIZE = 10000
def __init__(
self,
paths: Union[str, List[str]],
target_output_size_bytes: int,
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
self._target_output_size_bytes = target_output_size_bytes
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
chunksize = self._estimate_chunksize(f)
with StrictBufferedReader(f, buffer_size=self._BUFFER_SIZE) as stream:
if chunksize is None:
# When chunksize=None, pandas returns DataFrame directly
# (no context manager).
df = pd.read_json(stream, chunksize=chunksize, lines=True)
yield _cast_range_index_to_string(df)
else:
# When chunksize is a number, pandas returns JsonReader
# (supports context manager).
with pd.read_json(stream, chunksize=chunksize, lines=True) as reader:
for df in reader:
yield _cast_range_index_to_string(df)
def _estimate_chunksize(self, f: "pyarrow.NativeFile") -> Optional[int]:
"""Estimate the chunksize by sampling the first row.
This is necessary to avoid OOMs while reading the file.
"""
if not f.seekable():
return self._DEFAULT_CHUNK_SIZE
# ``_read_stream`` can be recreated on the same file handle when
# ``FileBasedDatasource`` retries a transient read error.
f.seek(0)
if self._target_output_size_bytes is None:
return None
try:
with StrictBufferedReader(f, buffer_size=self._BUFFER_SIZE) as stream:
with pd.read_json(stream, chunksize=1, lines=True) as reader:
try:
df = _cast_range_index_to_string(next(reader))
except StopIteration:
return 1
block_accessor = PandasBlockAccessor.for_block(df)
if block_accessor.num_rows() == 0:
chunksize = 1
else:
bytes_per_row = block_accessor.size_bytes() / block_accessor.num_rows()
chunksize = max(
round(self._target_output_size_bytes / bytes_per_row), 1
)
return chunksize
finally:
# Reset file pointer to the beginning for the actual read and for any
# subsequent retry that reuses the same file handle.
f.seek(0)
def _open_input_source(
self,
filesystem: "pyarrow.fs.FileSystem",
path: str,
**open_args,
) -> "pyarrow.NativeFile":
compression = self.resolve_compression(path, open_args)
if compression is None:
# We use a seekable file to estimate chunksize.
return filesystem.open_input_file(path)
return super()._open_input_source(filesystem, path, **open_args)
def _cast_range_index_to_string(df: pd.DataFrame):
# NOTE: PandasBlockAccessor doesn't support RangeIndex, so we need to convert
# to string.
if isinstance(df.columns, pd.RangeIndex):
df.columns = df.columns.astype(str)
return df
class StrictBufferedReader(io.RawIOBase):
"""Wrapper that prevents premature file closure and ensures full-buffered reads.
This is necessary for two reasons:
1. The datasource reads the file twice -- first to sample and determine the chunk size,
and again to load the actual data. Since pandas assumes ownership of the file and
may close it, we prevent that by explicitly detaching the underlying file before
closing the buffer.
2. pandas wraps the file in a TextIOWrapper to decode bytes into text. TextIOWrapper
prefers calling read1(), which doesn't prefetch for random-access files
(e.g., from PyArrow). This wrapper forces all reads through the full buffer to
avoid inefficient small-range S3 GETs.
"""
def __init__(self, file: io.RawIOBase, buffer_size: int):
self._file = io.BufferedReader(file, buffer_size=buffer_size)
def read(self, size=-1, /):
return self._file.read(size)
def readable(self) -> bool:
return True
def close(self):
if not self.closed:
self._file.detach()
super().close()
@@ -0,0 +1,296 @@
"""Kafka datasink
This module provides a Kafka datasink implementation for Ray Data.
Requires:
- confluent-kafka: https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/
"""
import json
import logging
from collections.abc import Iterable, Mapping
from enum import Enum
from typing import Any, Optional
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink
logger = logging.getLogger(__name__)
# Polling/flush constants. These are intentionally conservative defaults
# that work well for typical workloads. All three can be tuned indirectly
# through producer_config (e.g. queue.buffering.max.messages, message.timeout.ms)
# or overridden by subclassing if needed.
# Number of messages to produce before polling for delivery reports.
# At ~1.5 KB per message (a common upper bound), 10 000 messages ≈ 15 MB
# of buffered data — well within librdkafka's default queue limits while
# keeping Python→C crossing overhead low.
_POLL_BATCH_SIZE = 10000
# Timeout (seconds) for the final flush that waits for all in-flight messages
_FLUSH_TIMEOUT_S = 30
# Timeout (seconds) when polling to drain the queue after a BufferError
_BUFFER_FULL_POLL_TIMEOUT_S = 10
class SerializerFormat(str, Enum):
"""Supported serialization formats for Kafka message keys and values."""
JSON = "json"
STRING = "string"
BYTES = "bytes"
def _serialize(data: Any, serializer: SerializerFormat) -> bytes:
"""Serialize *data* according to *serializer*.
This is a standalone function so it can be used without a class instance.
"""
if serializer == SerializerFormat.JSON:
return json.dumps(data).encode("utf-8")
elif serializer == SerializerFormat.STRING:
return str(data).encode("utf-8")
else: # BYTES
return data if isinstance(data, bytes) else str(data).encode("utf-8")
class KafkaDatasink(Datasink):
"""
Ray Data sink for writing to Apache Kafka topics using confluent-kafka.
Writes blocks of data to Kafka with configurable serialization
and producer settings.
Delivery guarantees:
This sink provides best-effort delivery. Partial writes can occur if a
task fails midway (already-flushed messages are not rolled back), and
duplicates are possible when the system retries a failed task (e.g., on
node failure), since each attempt re-sends all messages from scratch
without Kafka transactions or cross-task deduplication.
"""
def __init__(
self,
topic: str,
bootstrap_servers: str,
key_field: Optional[str] = None,
key_serializer: SerializerFormat = SerializerFormat.STRING,
value_serializer: SerializerFormat = SerializerFormat.JSON,
producer_config: Optional[dict[str, Any]] = None,
):
"""
Initialize Kafka sink.
Args:
topic: Kafka topic name
bootstrap_servers: Comma-separated Kafka broker addresses (e.g., 'localhost:9092')
key_field: Optional field name to use as message key
key_serializer: Key serialization format ('json', 'string', or 'bytes')
value_serializer: Value serialization format ('json', 'string', or 'bytes')
producer_config: Additional Kafka producer configuration.
Uses confluent-kafka / librdkafka configuration keys
(see https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md).
Example: ``{"linger.ms": 5, "acks": "all"}``.
The ``bootstrap.servers`` option is derived from ``bootstrap_servers``
and cannot be overridden here.
"""
_check_import(self, module="confluent_kafka", package="confluent-kafka")
try:
key_serializer = SerializerFormat(key_serializer)
except ValueError:
raise ValueError(
f"key_serializer must be one of "
f"{[s.value for s in SerializerFormat]}, "
f"got '{key_serializer}'"
)
try:
value_serializer = SerializerFormat(value_serializer)
except ValueError:
raise ValueError(
f"value_serializer must be one of "
f"{[s.value for s in SerializerFormat]}, "
f"got '{value_serializer}'"
)
self.topic = topic
self.bootstrap_servers = bootstrap_servers
self.key_field = key_field
self.key_serializer = key_serializer
self.value_serializer = value_serializer
self.producer_config = producer_config or {}
@staticmethod
def _row_to_dict(row: Any) -> Any:
"""Convert a Ray data row to a plain dict if possible.
Handles Ray's internal row types (ArrowRow, PandasRow), namedtuples,
and generic Mappings. Returns the input unchanged for primitives.
"""
if isinstance(row, dict):
return row
if hasattr(row, "as_pydict"):
return row.as_pydict()
if hasattr(row, "_asdict"):
return row._asdict()
if isinstance(row, Mapping):
return dict(row)
return row
def _serialize_value(self, value: Any) -> bytes:
"""Serialize value based on configured format."""
return _serialize(value, self.value_serializer)
def _serialize_key(self, key: Any) -> bytes:
"""Serialize key based on configured format."""
return _serialize(key, self.key_serializer)
def _extract_key(self, row_dict: Any) -> Optional[bytes]:
"""Extract and serialize the message key from a row dict.
Returns ``None`` when no ``key_field`` is configured, when the row is
not a dict, or when the key field is absent/``None`` in the row. A
``None`` key tells the Kafka producer to use the default partitioner
(round-robin or sticky partitioning depending on librdkafka version),
distributing messages evenly across partitions.
"""
if self.key_field and isinstance(row_dict, dict):
key_value = row_dict.get(self.key_field)
if key_value is not None:
return self._serialize_key(key_value)
return None
def _produce_with_retry(self, producer, value, key, on_delivery):
"""Produce a single message, retrying once on ``BufferError``.
``producer.produce()`` is asynchronous — it enqueues the message into
librdkafka's internal buffer and returns immediately. Actual delivery
happens in a background thread; results are reported via the
*on_delivery* callback when ``producer.poll()`` or ``producer.flush()``
is called.
If the internal buffer is full, a ``BufferError`` is raised. We handle
this by polling to drain completed deliveries (which frees buffer
space) and retrying once.
"""
try:
producer.produce(
self.topic,
value=value,
key=key,
on_delivery=on_delivery,
)
except BufferError:
# Internal queue is full — poll to serve delivery callbacks and
# free space, then retry. The poll timeout caps how long we block
# waiting for the broker to acknowledge in-flight messages.
producer.poll(_BUFFER_FULL_POLL_TIMEOUT_S)
try:
producer.produce(
self.topic,
value=value,
key=key,
on_delivery=on_delivery,
)
except BufferError:
raise RuntimeError(
f"Kafka producer queue is still full after "
f"{_BUFFER_FULL_POLL_TIMEOUT_S}s of polling "
f"for topic '{self.topic}'. "
f"Consider increasing queue.buffering.max.messages "
f"in producer_config."
)
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> Any:
"""
Write blocks of data to Kafka.
Args:
blocks: Iterable of Ray data blocks
ctx: Ray data context
Returns:
Dict with ``total_records`` and ``failed_messages`` counts.
"""
from confluent_kafka import KafkaException, Producer
# Build confluent config
config: dict[str, Any] = {
"bootstrap.servers": self.bootstrap_servers,
}
for k, v in self.producer_config.items():
if k == "bootstrap.servers":
logger.warning(
"Ignoring 'bootstrap.servers' from producer_config; "
"use bootstrap_servers parameter instead."
)
continue
config[k] = v
producer = Producer(config)
total_records = 0
remaining = 0
# Mutable container so on_delivery callback can update without nonlocal
delivery_state = {"failed": 0, "first_exception": None}
def on_delivery(err, msg):
if err is not None:
delivery_state["failed"] += 1
if delivery_state["first_exception"] is None:
delivery_state["first_exception"] = KafkaException(err)
try:
for block in blocks:
block_accessor = BlockAccessor.for_block(block)
for row in block_accessor.iter_rows(public_row_format=False):
row_dict = self._row_to_dict(row)
key = self._extract_key(row_dict)
value = self._serialize_value(row_dict)
self._produce_with_retry(producer, value, key, on_delivery)
total_records += 1
# Periodically poll to serve delivery report callbacks
# and avoid unbounded internal queue growth.
if total_records % _POLL_BATCH_SIZE == 0:
producer.poll(0)
# Final flush: blocks until all in-flight messages are delivered
# or the timeout expires. Returns the count of messages still
# queued (0 = everything delivered). Does NOT raise on timeout.
remaining = producer.flush(timeout=_FLUSH_TIMEOUT_S)
except KafkaException as e:
raise RuntimeError(
f"Failed to write to Kafka topic '{self.topic}': {e}"
) from e
if remaining > 0:
raise RuntimeError(
f"{remaining} out of {total_records} messages were still "
f"in-flight after flush timeout for topic '{self.topic}'. "
f"This usually means the broker is unreachable."
)
failed_messages = delivery_state["failed"]
if failed_messages > 0:
raise RuntimeError(
f"Failed to write {failed_messages} out of {total_records} "
f"messages to Kafka topic '{self.topic}'."
) from delivery_state["first_exception"]
# Logged once per write task (one task per data block partition).
logger.debug(
"Wrote %d records to Kafka topic '%s'.",
total_records,
self.topic,
)
return {"total_records": total_records, "failed_messages": failed_messages}
@@ -0,0 +1,817 @@
"""Kafka datasource for bounded data reads.
This module provides a Kafka datasource implementation for Ray Data that supports
bounded reads with offset-based range queries.
Message keys and values are returned as raw bytes to support any serialization format
(JSON, Avro, Protobuf, etc.). Users can decode them using map operations.
Requires:
- confluent-kafka: https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/
"""
import logging
import time
import warnings
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
List,
Literal,
Optional,
Set,
Tuple,
Union,
)
import pyarrow as pa
if TYPE_CHECKING:
from confluent_kafka import Consumer, TopicPartition
from ray.data._internal.output_buffer import BlockOutputBuffer, OutputBlockSizeOption
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockMetadata
from ray.data.context import DataContext
from ray.data.datasource import Datasource, ReadTask
PartitionOffsets = Dict[int, Union[int, str]]
PerPartitionOffsets = Dict[str, PartitionOffsets]
logger = logging.getLogger(__name__)
# Mapping from kafka-python style KafkaAuthConfig fields to Confluent/librdkafka config.
# TODO(youcheng): Remove this mapping and use consumer_config directly.
_KAFKA_AUTH_TO_CONFLUENT: Dict[str, str] = {
"security_protocol": "security.protocol",
"sasl_mechanism": "sasl.mechanism",
"sasl_plain_username": "sasl.username",
"sasl_plain_password": "sasl.password",
"sasl_kerberos_service_name": "sasl.kerberos.service.name",
"sasl_kerberos_name": "sasl.kerberos.principal",
"ssl_cafile": "ssl.ca.location",
"ssl_certfile": "ssl.certificate.location",
"ssl_keyfile": "ssl.key.location",
"ssl_password": "ssl.key.password",
"ssl_ciphers": "ssl.cipher.suites",
"ssl_crlfile": "ssl.crl.location",
# Note: ssl_check_hostname is intentionally NOT mapped due to semantics mismatch.
}
KAFKA_TOPIC_METADATA_TIMEOUT_S = 10
KAFKA_QUERY_OFFSET_TIMEOUT_S = 10
# Cap each consume timeout to keep responsiveness of timeout/position checks.
KAFKA_CONSUME_TIMEOUT_MAX_S = 10 # 10 seconds per consume call
KAFKA_MSG_SCHEMA = pa.schema(
[
("offset", pa.int64()),
("key", pa.binary()),
("value", pa.binary()),
("topic", pa.string()),
("partition", pa.int32()),
("timestamp", pa.int64()), # Kafka timestamp in milliseconds
(
"timestamp_type",
pa.int32(),
), # 0=TIMESTAMP_NOT_AVAILABLE, 1=TIMESTAMP_CREATE_TIME, 2=TIMESTAMP_LOG_APPEND_TIME
("headers", pa.map_(pa.string(), pa.binary())), # Message headers
]
)
@dataclass
class KafkaAuthConfig:
"""Authentication configuration for Kafka connections.
Uses standard kafka-python parameter names. See kafka-python documentation
for full details: https://kafka-python.readthedocs.io/
Note: Ray Data maps these options to Confluent/librdkafka config under the hood.
Some options have different semantics or are unsupported by the Confluent client;
see notes below for those fields. Prefer passing Confluent options directly via
``consumer_config`` where possible.
security_protocol: Protocol used to communicate with brokers.
Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL.
Default: PLAINTEXT.
sasl_mechanism: Authentication mechanism when security_protocol
is configured for SASL_PLAINTEXT or SASL_SSL. Valid values are:
PLAIN, GSSAPI, OAUTHBEARER, SCRAM-SHA-256, SCRAM-SHA-512.
sasl_plain_username: username for sasl PLAIN and SCRAM authentication.
Required if sasl_mechanism is PLAIN or one of the SCRAM mechanisms.
sasl_plain_password: password for sasl PLAIN and SCRAM authentication.
Required if sasl_mechanism is PLAIN or one of the SCRAM mechanisms.
sasl_kerberos_name: Constructed gssapi.Name for use with
sasl mechanism handshake. If provided, sasl_kerberos_service_name and
sasl_kerberos_domain name are ignored. Default: None.
sasl_kerberos_service_name: Service name to include in GSSAPI
sasl mechanism handshake. Default: 'kafka'
sasl_kerberos_domain_name: kerberos domain name to use in GSSAPI
sasl mechanism handshake. Default: one of bootstrap servers.
Note (Confluent): This option is not supported by Confluent/librdkafka
and will be ignored when building the client configuration. Prefer specifying
an explicit principal via ``sasl_kerberos_name`` or rely on defaults.
sasl_oauth_token_provider: OAuthBearer token provider instance. Default: None.
Note (Confluent): Not supported directly; use ``consumer_config`` with
``sasl.oauthbearer.*`` options instead.
ssl_context: Pre-configured SSLContext for wrapping socket connections. If provided,
all other ssl_* configurations will be ignored. Default: None.
Note (Confluent): Passing an SSLContext object is not supported and will be
ignored. Use ``ssl_cafile``, ``ssl_certfile``, and ``ssl_keyfile`` instead.
ssl_check_hostname: Flag to configure whether ssl handshake should verify that the
certificate matches the broker's hostname. Default: True.
Note (Confluent): There is no 1:1 equivalent; disabling hostname verification
via ``enable.ssl.certificate.verification=False`` would also disable the entire
certificate chain verification. To avoid weakening security, this flag is not
mapped when False. If you need to disable only hostname verification, set
``ssl.endpoint.identification.algorithm=none`` via ``consumer_config`` (if supported
by your librdkafka version).
ssl_cafile: Optional filename of ca file to use in certificate verification. Default: None.
ssl_certfile: Optional filename of file in pem format containing the client certificate,
as well as any ca certificates needed to establish the certificate's authenticity. Default: None.
ssl_keyfile: Optional filename containing the client private key. Default: None.
ssl_password: Optional password to be used when loading the certificate chain. Default: None.
ssl_crlfile: Optional filename containing the CRL to check for certificate expiration. By default,
no CRL check is done. When providing a file, only the leaf certificate will be checked against
this CRL. The CRL can only be checked with Python 3.4+ or 2.7.9+. Default: None.
ssl_ciphers: optionally set the available ciphers for ssl connections. It should be a string in the
OpenSSL cipher list format. If no cipher can be selected (because compile-time options or other
configuration forbids use of all the specified ciphers), an ssl.SSLError will be raised.
See ssl.SSLContext.set_ciphers.
"""
# Security protocol
security_protocol: Optional[str] = None
# SASL configuration
sasl_mechanism: Optional[str] = None
sasl_plain_username: Optional[str] = None
sasl_plain_password: Optional[str] = None
sasl_kerberos_name: Optional[str] = None
sasl_kerberos_service_name: Optional[str] = None
sasl_kerberos_domain_name: Optional[str] = None
sasl_oauth_token_provider: Optional[Any] = None
# SSL configuration
ssl_context: Optional[Any] = None
ssl_check_hostname: Optional[bool] = None
ssl_cafile: Optional[str] = None
ssl_certfile: Optional[str] = None
ssl_keyfile: Optional[str] = None
ssl_password: Optional[str] = None
ssl_ciphers: Optional[str] = None
ssl_crlfile: Optional[str] = None
def _handle_deprecated_configs(kafka_auth_config: KafkaAuthConfig) -> None:
# Handle special fields with warnings
if kafka_auth_config.ssl_context is not None:
logger.warning(
"ssl_context is not supported by Confluent. Skipping. "
"Use KafkaAuthConfig fields ssl_cafile, ssl_certfile, ssl_keyfile instead."
)
if kafka_auth_config.sasl_oauth_token_provider is not None:
logger.warning(
"sasl_oauth_token_provider is not supported by Confluent. Skipping. "
"Use consumer_config with sasl.oauthbearer.* options instead."
)
if kafka_auth_config.sasl_kerberos_domain_name is not None:
logger.warning(
"sasl_kerberos_domain_name is not supported by Confluent and will be ignored. "
"Set sasl_kerberos_name (principal) or rely on defaults."
)
if kafka_auth_config.ssl_check_hostname is False:
logger.warning(
"ssl_check_hostname=False cannot be mapped safely to Confluent; "
"setting enable.ssl.certificate.verification=False would disable all certificate verification. "
"Ignoring ssl_check_hostname. If you need to disable only hostname verification, "
"configure the client directly via consumer_config (e.g., ssl.endpoint.identification.algorithm=none)."
)
def _add_authentication_to_config(
config: Dict[str, Any], kafka_auth_config: Optional[KafkaAuthConfig]
) -> None:
"""Map KafkaAuthConfig (kafka-python style) into Confluent/librdkafka config.
Special cases:
- ssl_context: unsupported; warn and ignore
- sasl_oauth_token_provider: unsupported; warn and ignore
- sasl_kerberos_domain_name: unsupported; warn and ignore
- ssl_check_hostname: not mapped due to semantics; if False, warn and ignore
"""
if not kafka_auth_config:
return
warnings.warn(
"kafka_auth_config (kafka-python style) is deprecated and will be removed in a future release. "
"Please provide Confluent/librdkafka options via consumer_config instead.",
DeprecationWarning,
stacklevel=2,
)
_handle_deprecated_configs(kafka_auth_config)
# Map directly compatible fields
for key, confluent_key in _KAFKA_AUTH_TO_CONFLUENT.items():
val = getattr(kafka_auth_config, key, None)
if val is not None:
config[confluent_key] = val
def _build_confluent_config(
bootstrap_servers: List[str],
kafka_auth_config: Optional[KafkaAuthConfig] = None,
extra: Optional[Dict[str, Any]] = None,
user_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build Confluent config with bootstrap servers and auth.
Args:
bootstrap_servers: List of Kafka broker addresses.
kafka_auth_config: Authentication configuration (kafka-python style). Deprecated; prefer consumer_config with Confluent keys. Mutually exclusive with consumer_config.
extra: Additional config options.
user_config: User-provided config options.
Returns:
Confluent configuration dict.
"""
config: Dict[str, Any] = {
"bootstrap.servers": ",".join(bootstrap_servers),
}
# Map kafka-python-style auth if provided
_add_authentication_to_config(config, kafka_auth_config)
if extra:
config.update(extra)
if user_config:
if (
"bootstrap.servers" in user_config
and user_config["bootstrap.servers"] != config["bootstrap.servers"]
):
logger.warning(
"Ignoring 'bootstrap.servers' from consumer_config; use bootstrap_servers parameter instead."
)
for k, v in user_config.items():
if k == "bootstrap.servers":
continue
config[k] = v
return config
def _build_consumer_config_for_read(
bootstrap_servers: List[str],
kafka_auth_config: Optional[KafkaAuthConfig] = None,
consumer_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build Consumer config for reading messages (Confluent)."""
return _build_confluent_config(
bootstrap_servers,
extra={
"enable.auto.commit": False,
# Confluent requires a group.id even when using manual assign.
"group.id": "ray-data-kafka-reader",
},
user_config=consumer_config,
kafka_auth_config=kafka_auth_config,
)
def _datetime_to_ms(dt: datetime) -> int:
"""Convert a datetime to milliseconds since epoch (UTC).
If the datetime has no timezone info (i.e., ``tzinfo is None``),
it is assumed to be UTC. Timezone-aware datetimes are converted to
UTC automatically via ``datetime.timestamp()``.
Args:
dt: A datetime object, with or without timezone info.
Returns:
Milliseconds since Unix epoch.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
def _validate_offsets(
start_offset: Union[int, datetime, Literal["earliest"], PerPartitionOffsets],
end_offset: Union[int, datetime, Literal["latest"], PerPartitionOffsets],
) -> None:
if isinstance(start_offset, dict):
for topic, partition_map in start_offset.items():
if not isinstance(partition_map, dict):
raise ValueError(
f"start_offset[{topic!r}] must be a dict mapping "
f"partition_id (int) to offset (int or str)."
)
for partition_id, offset in partition_map.items():
if not isinstance(partition_id, int):
raise ValueError(
f"start_offset[{topic!r}] keys must be integers "
f"(partition IDs), got {type(partition_id).__name__!r}."
)
if isinstance(offset, str) and offset == "latest":
raise ValueError(
f"start_offset[{topic!r}][{partition_id}] cannot be 'latest'."
)
else:
if isinstance(start_offset, int) and isinstance(end_offset, int):
if start_offset > end_offset:
raise ValueError("start_offset must be less than end_offset")
if isinstance(start_offset, datetime) and isinstance(end_offset, datetime):
if _datetime_to_ms(start_offset) > _datetime_to_ms(end_offset):
raise ValueError("start_offset must be less than end_offset")
if isinstance(start_offset, str) and start_offset == "latest":
raise ValueError("start_offset cannot be 'latest'")
if isinstance(end_offset, dict):
for topic, partition_map in end_offset.items():
if not isinstance(partition_map, dict):
raise ValueError(
f"end_offset[{topic!r}] must be a dict mapping "
f"partition_id (int) to offset (int or str)."
)
for partition_id, offset in partition_map.items():
if not isinstance(partition_id, int):
raise ValueError(
f"end_offset[{topic!r}] keys must be integers "
f"(partition IDs), got {type(partition_id).__name__!r}."
)
if isinstance(offset, str) and offset == "earliest":
raise ValueError(
f"end_offset[{topic!r}][{partition_id}] cannot be 'earliest'."
)
else:
if isinstance(end_offset, str) and end_offset == "earliest":
raise ValueError("end_offset cannot be 'earliest'")
def _resolve_datetime_to_offset(
consumer: "Consumer",
topic_partition: "TopicPartition",
dt: datetime,
fallback_offset: int,
) -> int:
"""Resolve a datetime to an integer offset via offsets_for_times.
Returns fallback_offset when offsets_for_times returns empty or offset < 0
(e.g., datetime in future or no messages at that time).
"""
from confluent_kafka import TopicPartition
timestamp_ms = _datetime_to_ms(dt)
tp_with_ts = TopicPartition(
topic_partition.topic, topic_partition.partition, timestamp_ms
)
result = consumer.offsets_for_times(
[tp_with_ts], timeout=KAFKA_QUERY_OFFSET_TIMEOUT_S
)
if result and result[0].offset >= 0:
return result[0].offset
return fallback_offset
def _resolve_offsets(
consumer: "Consumer",
topic_partition: "TopicPartition",
start_offset: Union[int, datetime, Literal["earliest"]],
end_offset: Union[int, datetime, Literal["latest"]],
) -> Tuple[int, int]:
"""Resolve start and end offsets to actual integer offsets.
Handles int offsets, "earliest"/"latest" strings, and datetime objects.
For datetime objects, uses ``consumer.offsets_for_times()`` to find the
earliest offset whose timestamp is >= the given datetime.
Args:
consumer: Confluent Kafka consumer instance.
topic_partition: TopicPartition to resolve offsets for.
start_offset: Start offset (int, datetime, or "earliest").
end_offset: End offset (int, datetime, or "latest").
Returns:
Tuple of (resolved_start_offset, resolved_end_offset).
"""
# TODO(youcheng): add retry logic for this call.
low, high = consumer.get_watermark_offsets(
topic_partition, timeout=KAFKA_QUERY_OFFSET_TIMEOUT_S
)
earliest_offset = low
latest_offset = high
# Keep original values for error messages
original_start = start_offset
original_end = end_offset
if start_offset == "earliest" or start_offset is None:
start_offset = earliest_offset
elif isinstance(start_offset, datetime):
# fallback to latest_offset if the start_offset is in the future, so the read range is empty (start == end).
start_offset = _resolve_datetime_to_offset(
consumer, topic_partition, start_offset, latest_offset
)
if end_offset == "latest" or end_offset is None:
end_offset = latest_offset
elif isinstance(end_offset, datetime):
end_offset = _resolve_datetime_to_offset(
consumer, topic_partition, end_offset, latest_offset
)
# Clamp end_offset to the high watermark so we never try to read beyond
# what is currently available. This prevents the read loop from polling
# indefinitely when a user-supplied integer end_offset exceeds the number
# of messages in the partition.
end_offset = min(end_offset, latest_offset)
if isinstance(start_offset, int) and start_offset < earliest_offset:
logger.warning(
f"start_offset ({start_offset}) is below the earliest available offset "
f"({earliest_offset}) for partition {topic_partition.partition} in topic "
f"{topic_partition.topic} (data may have been deleted by Kafka retention). "
f"Falling back to earliest available offset ({earliest_offset})."
)
start_offset = earliest_offset
if start_offset > end_offset:
start_str = (
f"{original_start}"
if original_start == start_offset
else f"{original_start} (resolved to {start_offset})"
)
end_str = (
f"{original_end}"
if original_end == end_offset
else f"{original_end} (resolved to {end_offset})"
)
raise ValueError(
f"start_offset ({start_str}) > end_offset ({end_str}) "
f"for partition {topic_partition.partition} in topic {topic_partition.topic}"
)
return start_offset, end_offset
class KafkaDatasource(Datasource):
"""Kafka datasource for reading from Kafka topics with bounded reads."""
# Batch size for incremental block yielding
BATCH_SIZE_FOR_YIELD = 1000
def __init__(
self,
topics: Union[str, List[str]],
bootstrap_servers: Union[str, List[str]],
start_offset: Union[int, datetime, Literal["earliest"], PerPartitionOffsets],
end_offset: Union[int, datetime, Literal["latest"], PerPartitionOffsets],
kafka_auth_config: Optional[KafkaAuthConfig] = None,
consumer_config: Optional[Dict[str, Any]] = None,
timeout_ms: Optional[int] = None,
):
"""Initialize Kafka datasource.
Args:
topics: Kafka topic name(s) to read from.
bootstrap_servers: Kafka broker addresses (string or list of strings).
start_offset: Starting position. Can be:
- int: Offset number
- datetime: Read from the first message at or after this time.
datetimes with no timezone info are treated as UTC.
- str: "earliest"
end_offset: Ending position (exclusive). Can be:
- int: Offset number
- datetime: Read up to (but not including) the first message
at or after this time. datetimes with no timezone info are treated as UTC.
- str: "latest"
kafka_auth_config: Authentication configuration (kafka-python style). Deprecated; prefer consumer_config with Confluent keys. Mutually exclusive with consumer_config.
consumer_config: Confluent/librdkafka consumer configuration dict.
Keys and values are passed through to the underlying client. The
`bootstrap.servers` option is derived from `bootstrap_servers` and
cannot be overridden here.
timeout_ms: Optional timeout in milliseconds for every read task to poll until reaching end_offset.
If None (default), no task-level timeout is applied and each read task
will poll until it reaches end_offset. If set, the read task will stop
polling after the timeout and return the messages it has read so far.
Raises:
ValueError: If required configuration is missing.
ImportError: If confluent-kafka is not installed.
"""
_check_import(self, module="confluent_kafka", package="confluent-kafka")
if not topics:
raise ValueError("topics cannot be empty")
if not bootstrap_servers:
raise ValueError("bootstrap_servers cannot be empty")
if timeout_ms is not None and timeout_ms <= 0:
raise ValueError("timeout_ms must be positive")
_validate_offsets(start_offset, end_offset)
# Validate bootstrap_servers format
if isinstance(bootstrap_servers, str):
if not bootstrap_servers or ":" not in bootstrap_servers:
raise ValueError(
f"Invalid bootstrap_servers format: {bootstrap_servers}. "
"Expected 'host:port' or list of 'host:port' strings."
)
elif isinstance(bootstrap_servers, list):
if not bootstrap_servers:
raise ValueError("bootstrap_servers cannot be empty list")
for server in bootstrap_servers:
if not isinstance(server, str) or ":" not in server:
raise ValueError(
f"Invalid bootstrap_servers format: {server}. "
"Expected 'host:port' string."
)
# Disallow specifying both config styles at once to avoid ambiguity.
if kafka_auth_config is not None and consumer_config is not None:
raise ValueError(
"Provide only one of kafka_auth_config (deprecated) or consumer_config, not both."
)
self._topics = topics if isinstance(topics, list) else [topics]
self._bootstrap_servers = (
bootstrap_servers
if isinstance(bootstrap_servers, list)
else [bootstrap_servers]
)
self._start_offset = start_offset
self._end_offset = end_offset
self._kafka_auth_config = kafka_auth_config
self._consumer_config = consumer_config
self._timeout_ms = timeout_ms
self._target_max_block_size = DataContext.get_current().target_max_block_size
def estimate_inmemory_data_size(self) -> Optional[int]:
"""Return an estimate of the in-memory data size, or None if unknown."""
return None
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
"""Create read tasks for Kafka partitions.
Creates one read task per partition.
Each task reads from a single partition of a single topic.
Args:
parallelism: This argument is deprecated.
per_task_row_limit: Maximum number of rows per read task.
data_context: The data context to use to get read tasks. This is not used by this datasource.
Returns:
List of ReadTask objects, one per partition.
"""
from confluent_kafka import Consumer
consumer_config = _build_consumer_config_for_read(
self._bootstrap_servers, self._kafka_auth_config, self._consumer_config
)
discovery_consumer = Consumer(consumer_config)
try:
metadata = discovery_consumer.list_topics(
timeout=KAFKA_TOPIC_METADATA_TIMEOUT_S
)
topic_partitions: List[Tuple[str, int]] = []
for topic in self._topics:
if topic not in metadata.topics:
raise ValueError(
f"Topic {topic} has no partitions or doesn't exist"
)
topic_meta = metadata.topics[topic]
if not topic_meta.partitions:
raise ValueError(
f"Topic {topic} has no partitions or doesn't exist"
)
for partition_id in topic_meta.partitions.keys():
topic_partitions.append((topic, partition_id))
finally:
discovery_consumer.close()
bootstrap_servers = self._bootstrap_servers
start_offset = self._start_offset
end_offset = self._end_offset
timeout_ms = self._timeout_ms
target_max_block_size = self._target_max_block_size
# Validate that any partitions referenced in a per-partition dict
# actually exist on the broker. Check once per topic before the loop.
actual_partition_ids: Dict[str, Set[int]] = {}
for topic_name, partition_id in topic_partitions:
actual_partition_ids.setdefault(topic_name, set()).add(partition_id)
for param_name, offset in (
("start_offset", start_offset),
("end_offset", end_offset),
):
if isinstance(offset, dict):
for topic, partition_map in offset.items():
existing_partitions = actual_partition_ids.get(topic, set())
for pid in partition_map:
if pid not in existing_partitions:
raise ValueError(
f"{param_name} references partition {pid} in topic "
f"{topic!r}, but that partition does not exist."
)
tasks = []
for topic_name, partition_id in topic_partitions:
def create_kafka_read_fn(
topic_name: str = topic_name,
partition_id: int = partition_id,
bootstrap_servers: List[str] = bootstrap_servers,
start_offset: Optional[
Union[int, datetime, Literal["earliest"]]
] = start_offset,
end_offset: Optional[
Union[int, datetime, Literal["latest"]]
] = end_offset,
kafka_auth_config: Optional[KafkaAuthConfig] = self._kafka_auth_config,
user_consumer_config: Optional[Dict[str, Any]] = self._consumer_config,
timeout_ms: Optional[int] = timeout_ms,
target_max_block_size: int = target_max_block_size,
):
"""Create a Kafka read function with captured variables."""
def kafka_read_fn() -> Iterable[Block]:
"""Read function for a single Kafka partition using confluent-kafka."""
from confluent_kafka import (
Consumer,
KafkaError,
KafkaException,
TopicPartition,
)
built_consumer_config = _build_consumer_config_for_read(
bootstrap_servers, kafka_auth_config, user_consumer_config
)
consumer = Consumer(built_consumer_config)
try:
topic_partition = TopicPartition(topic_name, partition_id)
resolved_start, resolved_end = _resolve_offsets(
consumer, topic_partition, start_offset, end_offset
)
records = []
output_buffer = BlockOutputBuffer(
OutputBlockSizeOption.of(
target_max_block_size=target_max_block_size
)
)
if resolved_start < resolved_end:
start_time = time.perf_counter()
timeout_seconds = (
timeout_ms / 1000.0 if timeout_ms is not None else None
)
tp_with_offset = TopicPartition(
topic_name, partition_id, resolved_start
)
consumer.assign([tp_with_offset])
next_offset = resolved_start
partition_done = False
while not partition_done:
if next_offset >= resolved_end:
break
if timeout_seconds is not None:
elapsed_time_s = time.perf_counter() - start_time
if elapsed_time_s >= timeout_seconds:
logger.warning(
f"Kafka read task timed out after {timeout_ms}ms while reading partition {partition_id} of topic {topic_name}; "
f"end_offset {resolved_end} was not reached. Returning {len(records)} messages collected in this read task so far."
)
break
remaining_timeout_s = (
timeout_seconds - elapsed_time_s
)
consume_timeout_s = min(
remaining_timeout_s, KAFKA_CONSUME_TIMEOUT_MAX_S
)
else:
consume_timeout_s = KAFKA_CONSUME_TIMEOUT_MAX_S
remaining = resolved_end - next_offset
batch_size = min(
remaining,
KafkaDatasource.BATCH_SIZE_FOR_YIELD,
)
msgs = consumer.consume(
num_messages=batch_size,
timeout=consume_timeout_s,
)
if not msgs:
continue
for msg in msgs:
if msg.error():
# In confluent-kafka, errors are delivered
# as messages. Only skip partition EOF
# events, raise others.
err = msg.error()
if err.code() == KafkaError._PARTITION_EOF:
continue
raise KafkaException(err)
# Stop once we reached the end offset
# (exclusive).
if msg.offset() >= resolved_end:
partition_done = True
break
ts_type, ts_ms = msg.timestamp()
headers_list = msg.headers() or []
headers_dict = dict(headers_list)
records.append(
{
"offset": msg.offset(),
"key": msg.key(),
"value": msg.value(),
"topic": msg.topic(),
"partition": msg.partition(),
"timestamp": ts_ms,
"timestamp_type": ts_type,
"headers": headers_dict,
}
)
next_offset = msg.offset() + 1
if (
len(records)
>= KafkaDatasource.BATCH_SIZE_FOR_YIELD
):
table = pa.Table.from_pylist(records)
output_buffer.add_block(table)
yield from output_buffer.iter_ready_blocks()
records = []
# Yield any remaining records
if records:
table = pa.Table.from_pylist(records)
output_buffer.add_block(table)
output_buffer.finalize()
yield from output_buffer.iter_ready_blocks()
finally:
consumer.close()
return kafka_read_fn
# TODO: We could output the offset range for every partition after the read is done.
metadata = BlockMetadata(
num_rows=None,
size_bytes=None,
input_files=[f"kafka://{topic_name}/{partition_id}"],
exec_stats=None,
)
effective_start = (
start_offset.get(topic_name, {}).get(partition_id, "earliest")
if isinstance(start_offset, dict)
else start_offset
)
effective_end = (
end_offset.get(topic_name, {}).get(partition_id, "latest")
if isinstance(end_offset, dict)
else end_offset
)
kafka_read_fn = create_kafka_read_fn(
topic_name,
partition_id,
start_offset=effective_start,
end_offset=effective_end,
)
# Create read task
task = ReadTask(
read_fn=kafka_read_fn,
metadata=metadata,
schema=KAFKA_MSG_SCHEMA,
per_task_row_limit=per_task_row_limit,
)
tasks.append(task)
return tasks
@@ -0,0 +1,460 @@
import pickle
from itertools import chain
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Tuple,
Union,
)
import pyarrow as pa
from ray._common.retry import call_with_retry
from ray.data._internal.arrow_ops.transform_pyarrow import (
reorder_columns_by_schema,
)
from ray.data._internal.datasource.lance_utils import (
create_storage_options_provider,
get_or_create_namespace,
)
from ray.data._internal.savemode import SaveMode
from ray.data._internal.util import _check_import, unify_schemas_with_validation
from ray.data.block import Block, BlockAccessor
from ray.data.context import DataContext
from ray.data.datasource.datasink import Datasink
if TYPE_CHECKING:
import pandas as pd
from lance import LanceDataset
from lance.fragment import FragmentMetadata
_WRITE_LANCE_FRAGMENTS_DESCRIPTION = "write lance fragments"
def _declare_table_with_fallback(
namespace, table_id: List[str]
) -> Tuple[str, Optional[Dict[str, str]]]:
"""Declare a table using declare_table, falling back to create_empty_table."""
try:
from lance_namespace import DeclareTableRequest
declare_request = DeclareTableRequest(id=table_id, location=None)
declare_response = namespace.declare_table(declare_request)
return declare_response.location, declare_response.storage_options
except (AttributeError, NotImplementedError):
# Fallback for older namespace implementations without declare_table.
from lance_namespace import CreateEmptyTableRequest
create_request = CreateEmptyTableRequest(id=table_id)
create_response = namespace.create_empty_table(create_request)
return create_response.location, create_response.storage_options
def _make_stream_factory(
stream: Iterable[Block], replayable: bool
) -> Tuple[Optional[Callable[[], Iterator[Block]]], Optional[Block]]:
"""Return a reusable stream factory and the first block, or (None, None)."""
if replayable:
blocks = list(stream)
if not blocks:
return None, None
def stream_factory() -> Iterator[Block]:
return iter(blocks)
return stream_factory, blocks[0]
stream_iter = iter(stream)
first = next(stream_iter, None)
if first is None:
return None, None
def stream_factory() -> Iterator[Block]:
return chain([first], stream_iter)
return stream_factory, first
def _write_fragment(
stream: Iterable[Block],
uri: str,
*,
schema: Optional["pa.Schema"] = None,
max_rows_per_file: int = 64 * 1024 * 1024,
max_bytes_per_file: Optional[int] = None,
max_rows_per_group: int = 1024, # Only useful for v1 writer.
data_storage_version: Optional[str] = None,
storage_options: Optional[Dict[str, Any]] = None,
namespace_impl: Optional[str] = None,
namespace_properties: Optional[Dict[str, str]] = None,
table_id: Optional[List[str]] = None,
retry_params: Optional[Dict[str, Any]] = None,
) -> List[Tuple["FragmentMetadata", "pa.Schema"]]:
import pandas as pd
from lance.fragment import DEFAULT_MAX_BYTES_PER_FILE, write_fragments
if retry_params is None:
retry_params = {
"description": _WRITE_LANCE_FRAGMENTS_DESCRIPTION,
"match": [],
"max_attempts": 1,
"max_backoff_s": 0,
}
max_attempts = retry_params.get("max_attempts", 1)
stream_factory, first = _make_stream_factory(stream, replayable=max_attempts > 1)
if stream_factory is None or first is None:
return []
if schema is None:
if isinstance(first, pd.DataFrame):
schema = pa.Schema.from_pandas(first).remove_metadata()
else:
schema = first.schema
if len(schema.names) == 0:
# Empty table.
schema = None
def record_batch_converter(block_stream):
for block in block_stream:
tbl = BlockAccessor.for_block(block).to_arrow()
# `RecordBatchReader.from_batches(schema, ...)` is positional.
if schema is not None:
tbl = reorder_columns_by_schema(tbl, schema)
yield from tbl.to_batches()
max_bytes_per_file = (
DEFAULT_MAX_BYTES_PER_FILE if max_bytes_per_file is None else max_bytes_per_file
)
storage_options_provider = create_storage_options_provider(
namespace_impl,
namespace_properties,
table_id,
)
def _write_once():
reader = pa.RecordBatchReader.from_batches(
schema, record_batch_converter(stream_factory())
)
return write_fragments(
reader,
uri,
schema=schema,
max_rows_per_file=max_rows_per_file,
max_rows_per_group=max_rows_per_group,
max_bytes_per_file=max_bytes_per_file,
data_storage_version=data_storage_version,
storage_options=storage_options,
storage_options_provider=storage_options_provider,
)
fragments = call_with_retry(_write_once, **retry_params)
return [(fragment, schema) for fragment in fragments]
class _BaseLanceDatasink(Datasink):
"""Base class for Lance Datasink."""
def __init__(
self,
uri: Optional[str] = None,
schema: Optional[pa.Schema] = None,
mode: SaveMode = SaveMode.CREATE,
storage_options: Optional[Dict[str, Any]] = None,
table_id: Optional[List[str]] = None,
namespace_impl: Optional[str] = None,
namespace_properties: Optional[Dict[str, str]] = None,
*args: Any,
**kwargs: Any,
):
super().__init__(*args, **kwargs)
if mode not in {SaveMode.CREATE, SaveMode.APPEND, SaveMode.OVERWRITE}:
raise ValueError(
f"Unsupported Lance write mode: {mode!r}. "
"Supported modes are SaveMode.CREATE, SaveMode.APPEND, and SaveMode.OVERWRITE."
)
merged_storage_options: Dict[str, Any] = {}
if storage_options:
merged_storage_options.update(storage_options)
self._namespace_impl = namespace_impl
self._namespace_properties = namespace_properties
namespace = get_or_create_namespace(namespace_impl, namespace_properties)
if namespace is not None and table_id is not None:
if uri is not None:
import warnings
warnings.warn(
"The 'uri' argument is ignored when namespace parameters are "
"provided. The resolved namespace location will be used instead.",
UserWarning,
stacklevel=2,
)
self.table_id = table_id
if mode != SaveMode.CREATE:
raise ValueError(
"Namespace writes currently only support mode='create'. "
"Use mode='create' for now."
)
uri, ns_storage_options = _declare_table_with_fallback(namespace, table_id)
self.uri = uri
if ns_storage_options:
merged_storage_options.update(ns_storage_options)
self._has_namespace_storage_options = True
else:
self.table_id = None
if uri is None:
raise ValueError(
"Must provide either 'uri' or ('namespace_impl' and 'table_id')."
)
self.uri = uri
self._has_namespace_storage_options = False
self.schema = schema
self.mode = mode
self.read_version: Optional[int] = None
self.storage_options = merged_storage_options
@property
def storage_options_provider(self):
"""Lazily create storage options provider using namespace_impl/properties."""
if not self._has_namespace_storage_options:
return None
return create_storage_options_provider(
self._namespace_impl,
self._namespace_properties,
self.table_id,
)
@property
def supports_distributed_writes(self) -> bool:
return True
def _open_dataset(self) -> "LanceDataset":
"""Open the Lance dataset at ``self.uri``.
Raises whatever Lance raises if the dataset can't be opened (missing,
bad ``storage_options``, etc.). Opening natively honors
``storage_options``/``storage_options_provider``.
"""
import lance
return lance.LanceDataset(
self.uri,
storage_options=self.storage_options,
storage_options_provider=self.storage_options_provider,
)
def _dataset_exists(self) -> bool:
"""Whether a Lance dataset already exists at ``self.uri``.
A *successful open* is the authoritative existence signal, and it honors
``storage_options`` for every backend. We intentionally do not try to
classify failures (e.g. by matching Lance error strings, which drift
across versions): if the dataset can't be opened we report it as
not-existing and let the subsequent write surface any real error (such
as invalid ``storage_options``) with Lance's own message.
"""
try:
self._open_dataset()
return True
except Exception:
return False
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
_check_import(self, module="lance", package="pylance")
if self.mode == SaveMode.CREATE:
# CREATE must not clobber an existing dataset. Users who want to
# replace existing data should use SaveMode.OVERWRITE. Namespace
# writes manage table creation separately (the table location is
# declared/created up front), so skip the check in that case.
if self.table_id is None and self._dataset_exists():
raise ValueError(
f"Dataset at {self.uri} already exists. "
"Use mode=SaveMode.OVERWRITE to replace it, or "
"mode=SaveMode.APPEND to add to it."
)
elif self.mode == SaveMode.APPEND:
# APPEND needs the existing dataset's version/schema. Let Lance
# raise its own error (e.g. dataset not found) if it can't open.
ds = self._open_dataset()
self.read_version = ds.version
if self.schema is None:
self.schema = ds.schema
def on_write_complete(
self,
write_results: List[List[Tuple[str, str]]],
):
import warnings
import lance
if not write_results:
warnings.warn(
"write_results is empty.",
DeprecationWarning,
)
return
if hasattr(write_results, "write_returns"):
write_results = write_results.write_returns
if len(write_results) == 0:
warnings.warn(
"write results is empty. please check ray version or internal error",
DeprecationWarning,
)
return
fragments = []
schemas = []
for batch in write_results:
for fragment_str, schema_str in batch:
fragment = pickle.loads(fragment_str)
fragments.append(fragment)
schema = pickle.loads(schema_str)
if schema is not None:
schemas.append(schema)
# Skip commit when there are no fragments/schemas to commit.
if not schemas:
return
unified_schema = unify_schemas_with_validation(schemas)
if unified_schema is None:
return
if self.mode in {SaveMode.CREATE, SaveMode.OVERWRITE}:
op = lance.LanceOperation.Overwrite(unified_schema, fragments)
elif self.mode == SaveMode.APPEND:
op = lance.LanceOperation.Append(fragments)
lance.LanceDataset.commit(
self.uri,
op,
read_version=self.read_version,
storage_options=self.storage_options,
storage_options_provider=self.storage_options_provider,
)
class LanceDatasink(_BaseLanceDatasink):
"""Lance Ray Datasink.
Write a Ray dataset to lance.
If we expect to write larger-than-memory files,
we can use `LanceFragmentWriter` and `LanceCommitter`.
Args:
uri: The base URI of the dataset.
schema: The schema of the dataset.
mode: The write mode. Default is SaveMode.CREATE. Choices are
SaveMode.CREATE, SaveMode.APPEND, SaveMode.OVERWRITE. Namespace-backed
writes currently support only SaveMode.CREATE.
min_rows_per_file: The minimum number of rows per file. Default is 1024 * 1024.
max_rows_per_file: The maximum number of rows per file. Default is 64 * 1024 * 1024.
data_storage_version: The version of the data storage format to use. Newer versions are more
efficient but require newer versions of lance to read. The default is "legacy",
which will use the legacy v1 version. See the user guide for more details.
storage_options: The storage options for the writer. Default is None.
table_id: The table identifier as a list of strings, used with namespace params.
namespace_impl: The namespace implementation type (e.g., "rest", "dir").
Used together with namespace_properties and table_id for credentials vending.
namespace_properties: Properties for connecting to the namespace.
Used together with namespace_impl and table_id for credentials vending.
When namespace params are provided, only SaveMode.CREATE is currently
supported.
*args: Additional positional arguments forwarded to the base class.
**kwargs: Additional keyword arguments forwarded to the base class.
"""
NAME = "Lance"
def __init__(
self,
uri: Optional[str] = None,
schema: Optional[pa.Schema] = None,
mode: SaveMode = SaveMode.CREATE,
min_rows_per_file: int = 1024 * 1024,
max_rows_per_file: int = 64 * 1024 * 1024,
data_storage_version: Optional[str] = None,
storage_options: Optional[Dict[str, Any]] = None,
table_id: Optional[List[str]] = None,
namespace_impl: Optional[str] = None,
namespace_properties: Optional[Dict[str, str]] = None,
*args: Any,
**kwargs: Any,
):
super().__init__(
uri,
schema=schema,
mode=mode,
storage_options=storage_options,
table_id=table_id,
namespace_impl=namespace_impl,
namespace_properties=namespace_properties,
*args,
**kwargs,
)
self.min_rows_per_file = min_rows_per_file
self.max_rows_per_file = max_rows_per_file
self.data_storage_version = data_storage_version
# if mode is append, read_version is read from existing dataset.
self.read_version: Optional[int] = None
data_context = DataContext.get_current()
lance_config = data_context.lance_config
match = []
match.extend(lance_config.write_fragments_errors_to_retry)
match.extend(data_context.retried_io_errors)
self._retry_params = {
"description": _WRITE_LANCE_FRAGMENTS_DESCRIPTION,
"match": match,
"max_attempts": lance_config.write_fragments_max_attempts,
"max_backoff_s": lance_config.write_fragments_retry_max_backoff_s,
}
@property
def min_rows_per_write(self) -> int:
return self.min_rows_per_file
def get_name(self) -> str:
return self.NAME
def write(
self,
blocks: Iterable[Union[pa.Table, "pd.DataFrame"]],
_ctx,
):
fragments_and_schema = _write_fragment(
blocks,
self.uri,
schema=self.schema,
max_rows_per_file=self.max_rows_per_file,
data_storage_version=self.data_storage_version,
storage_options=self.storage_options,
namespace_impl=self._namespace_impl,
namespace_properties=self._namespace_properties,
table_id=self.table_id,
retry_params=self._retry_params,
)
return [
(pickle.dumps(fragment), pickle.dumps(schema))
for fragment, schema in fragments_and_schema
]
@@ -0,0 +1,160 @@
import logging
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Union
import numpy as np
from ray._common.retry import call_with_retry
from ray.data._internal.util import _check_import
from ray.data.block import BlockMetadata
from ray.data.context import DataContext
from ray.data.datasource.datasource import Datasource, ReadTask
if TYPE_CHECKING:
import pyarrow
logger = logging.getLogger(__name__)
class LanceDatasource(Datasource):
"""Lance datasource, for reading Lance dataset."""
def __init__(
self,
uri: str,
version: Optional[Union[int, str]] = None,
columns: Optional[List[str]] = None,
filter: Optional[str] = None,
storage_options: Optional[Dict[str, str]] = None,
scanner_options: Optional[Dict[str, Any]] = None,
):
super().__init__()
_check_import(self, module="lance", package="pylance")
import lance
self._projection_map = None
self.uri = uri
self.scanner_options = scanner_options or {}
if columns is not None:
self.scanner_options["columns"] = columns
if filter is not None:
self.scanner_options["filter"] = filter
self.storage_options = storage_options
self.lance_ds = lance.dataset(
uri=uri, version=version, storage_options=storage_options
)
data_context = DataContext.get_current()
lance_config = data_context.lance_config
match = []
match.extend(lance_config.read_fragments_errors_to_retry)
match.extend(data_context.retried_io_errors)
self._retry_params = {
"description": "read lance fragments",
"match": match,
"max_attempts": lance_config.read_fragments_max_attempts,
"max_backoff_s": lance_config.read_fragments_retry_max_backoff_s,
}
def supports_predicate_pushdown(self) -> bool:
return True
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
read_tasks = []
ds_fragments = self.scanner_options.get("fragments")
if ds_fragments is None:
ds_fragments = self.lance_ds.get_fragments()
# Lance scanner's filter attr accepts only a string (SQL).
# See: https://github.com/lance-format/lance/blob/aac74b441cdb6df7d78700dbba33c521e6379ca5/python/python/lance/lance/__init__.pyi#L230
filter_expr = (
str(self._predicate_expr.to_pyarrow())
if self._predicate_expr is not None
else None
)
filter_from_arg = self.scanner_options.get("filter")
if filter_from_arg is not None:
filter_expr = (
filter_from_arg
if filter_expr is None
else f"({filter_expr}) AND ({filter_from_arg})"
)
for fragments in np.array_split(ds_fragments, parallelism):
if len(fragments) <= 0:
continue
fragment_ids = [f.metadata.id for f in fragments]
num_rows = sum(f.count_rows() for f in fragments)
input_files = [
data_file.path() for f in fragments for data_file in f.data_files()
]
# TODO(chengsu): Take column projection into consideration for schema.
metadata = BlockMetadata(
num_rows=num_rows,
size_bytes=None,
input_files=input_files,
exec_stats=None,
)
# Use a copy per task to avoid mutation races when tasks run in parallel
task_scanner_options = dict(self.scanner_options)
if filter_expr is not None:
task_scanner_options["filter"] = filter_expr
lance_ds = self.lance_ds
retry_params = self._retry_params
read_task = ReadTask(
lambda f=fragment_ids, opts=task_scanner_options: _read_fragments_with_retry(
f,
lance_ds,
opts,
retry_params,
),
metadata,
schema=fragments[0].schema,
per_task_row_limit=per_task_row_limit,
)
read_tasks.append(read_task)
return read_tasks
def estimate_inmemory_data_size(self) -> Optional[int]:
# TODO(chengsu): Add memory size estimation to improve auto-tune of parallelism.
return None
def _read_fragments_with_retry(
fragment_ids,
lance_ds,
scanner_options,
retry_params,
) -> Iterator["pyarrow.Table"]:
return call_with_retry(
lambda: _read_fragments(fragment_ids, lance_ds, scanner_options),
**retry_params,
)
def _read_fragments(
fragment_ids,
lance_ds,
scanner_options,
) -> Iterator["pyarrow.Table"]:
"""Read Lance fragments in batches.
NOTE: Use fragment ids, instead of fragments as parameter, because pickling
LanceFragment is expensive.
"""
import pyarrow
fragments = [lance_ds.get_fragment(id) for id in fragment_ids]
scanner_options["fragments"] = fragments
scanner = lance_ds.scanner(**scanner_options)
for batch in scanner.to_reader():
yield pyarrow.Table.from_batches([batch])
@@ -0,0 +1,58 @@
import os
from functools import lru_cache
from typing import Dict, List, Optional, Tuple
_NAMESPACE_CACHE_SIZE = int(os.environ.get("LANCE_RAY_NAMESPACE_CACHE_SIZE", "16"))
def _has_namespace_params(
namespace_impl: Optional[str],
table_id: Optional[List[str]],
) -> bool:
return namespace_impl is not None and table_id is not None
@lru_cache(maxsize=_NAMESPACE_CACHE_SIZE)
def _get_cached_namespace(
namespace_impl: str,
namespace_properties_tuple: Optional[Tuple[Tuple[str, str], ...]],
):
import lance_namespace as ln
namespace_properties = (
dict(namespace_properties_tuple) if namespace_properties_tuple else {}
)
return ln.connect(namespace_impl, namespace_properties)
def get_or_create_namespace(
namespace_impl: Optional[str],
namespace_properties: Optional[Dict[str, str]],
):
"""Get or create a cached namespace client for the worker."""
if namespace_impl is None:
return None
namespace_properties_tuple = (
tuple(sorted(namespace_properties.items())) if namespace_properties else None
)
return _get_cached_namespace(namespace_impl, namespace_properties_tuple)
def create_storage_options_provider(
namespace_impl: Optional[str],
namespace_properties: Optional[Dict[str, str]],
table_id: Optional[List[str]],
):
"""Create a LanceNamespaceStorageOptionsProvider if namespace params exist."""
if not _has_namespace_params(namespace_impl, table_id):
return None
assert table_id is not None
namespace = get_or_create_namespace(namespace_impl, namespace_properties)
if namespace is None:
return None
from lance import LanceNamespaceStorageOptionsProvider
return LanceNamespaceStorageOptionsProvider(namespace=namespace, table_id=table_id)
@@ -0,0 +1,258 @@
"""MCAP (Message Capture) datasource for Ray Data.
MCAP is a standardized format for storing timestamped messages from robotics and
autonomous systems, commonly used for sensor data, control commands, and other
time-series data.
"""
import json
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Set, Union
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.util import _check_import
from ray.data.block import Block
from ray.data.datasource.file_based_datasource import FileBasedDatasource
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
import pyarrow
from mcap.reader import Channel, Message, Schema
logger = logging.getLogger(__name__)
@dataclass
class TimeRange:
"""Time range for filtering MCAP messages.
Attributes:
start_time: Start time in nanoseconds (inclusive).
end_time: End time in nanoseconds (exclusive).
"""
start_time: int
end_time: int
def __post_init__(self):
"""Validate time range after initialization."""
if self.start_time >= self.end_time:
raise ValueError(
f"start_time ({self.start_time}) must be less than "
f"end_time ({self.end_time})"
)
if self.start_time < 0 or self.end_time < 0:
raise ValueError(
f"time values must be non-negative, got start_time={self.start_time}, "
f"end_time={self.end_time}"
)
@DeveloperAPI
class MCAPDatasource(FileBasedDatasource):
"""MCAP (Message Capture) datasource for Ray Data.
This datasource provides reading of MCAP files with predicate pushdown
optimization for filtering by topics, time ranges, and message types.
MCAP is a standardized format for storing timestamped messages from robotics and
autonomous systems, commonly used for sensor data, control commands, and other
time-series data.
Examples:
Basic usage:
>>> import ray # doctest: +SKIP
>>> ds = ray.data.read_mcap("/path/to/data.mcap") # doctest: +SKIP
With topic filtering and time range:
>>> from ray.data.datasource import TimeRange # doctest: +SKIP
>>> ds = ray.data.read_mcap( # doctest: +SKIP
... "/path/to/data.mcap",
... topics={"/camera/image_raw", "/lidar/points"},
... time_range=TimeRange(start_time=1000000000, end_time=2000000000)
... ) # doctest: +SKIP
With multiple files and metadata:
>>> ds = ray.data.read_mcap( # doctest: +SKIP
... ["file1.mcap", "file2.mcap"],
... topics={"/camera/image_raw", "/lidar/points"},
... message_types={"sensor_msgs/Image", "sensor_msgs/PointCloud2"},
... include_metadata=True
... ) # doctest: +SKIP
"""
_FILE_EXTENSIONS = ["mcap"]
def __init__(
self,
paths: Union[str, List[str]],
topics: Optional[Union[List[str], Set[str]]] = None,
time_range: Optional[TimeRange] = None,
message_types: Optional[Union[List[str], Set[str]]] = None,
include_metadata: bool = True,
**file_based_datasource_kwargs,
):
"""Initialize MCAP datasource.
Args:
paths: Path or list of paths to MCAP files.
topics: Optional list/set of topic names to include. If specified,
only messages from these topics will be read.
time_range: Optional TimeRange for filtering messages by timestamp.
TimeRange contains start_time and end_time in nanoseconds, where
both values must be non-negative and start_time < end_time.
message_types: Optional list/set of message type names (schema names)
to include. Only messages with matching schema names will be read.
include_metadata: Whether to include MCAP metadata fields in the output.
Defaults to True. When True, includes schema, channel, and message
metadata.
**file_based_datasource_kwargs: Additional arguments for FileBasedDatasource.
"""
super().__init__(paths, **file_based_datasource_kwargs)
_check_import(self, module="mcap", package="mcap")
# Convert to sets for faster lookup
self._topics = set(topics) if topics else None
self._message_types = set(message_types) if message_types else None
self._time_range = time_range
self._include_metadata = include_metadata
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
"""Read MCAP file and yield blocks of message data.
This method implements efficient MCAP reading with predicate pushdown.
It uses MCAP's built-in filtering capabilities for optimal performance
and applies additional filters when needed.
Args:
f: File-like object to read from. Must be seekable for MCAP reading.
path: Path to the MCAP file being processed.
Yields:
Block: Blocks of MCAP message data as pyarrow Tables.
Raises:
ValueError: If the MCAP file cannot be read or has invalid format.
"""
from mcap.reader import make_reader
reader = make_reader(f)
# Note: MCAP summaries are optional and iter_messages works without them
# We don't need to validate the summary since it's not required
# Use MCAP's built-in filtering for topics and time range
messages = reader.iter_messages(
topics=list(self._topics) if self._topics else None,
start_time=self._time_range.start_time if self._time_range else None,
end_time=self._time_range.end_time if self._time_range else None,
log_time_order=True,
reverse=False,
)
builder = DelegatingBlockBuilder()
for schema, channel, message in messages:
# Apply filters that couldn't be pushed down to MCAP level
if not self._should_include_message(schema, channel, message):
continue
# Convert message to dictionary format
message_data = self._message_to_dict(schema, channel, message, path)
builder.add(message_data)
# Yield the block if we have any messages
if builder.num_rows() > 0:
yield builder.build()
def _should_include_message(
self, schema: "Schema", channel: "Channel", message: "Message"
) -> bool:
"""Check if a message should be included based on filters.
This method applies Python-level filtering that cannot be pushed down
to the MCAP library level. Topic filters are already handled by the
MCAP reader, so only message_types filtering is needed here.
Args:
schema: MCAP schema object containing message type information.
channel: MCAP channel object containing topic and metadata.
message: MCAP message object containing the actual data.
Returns:
True if the message should be included, False otherwise.
"""
# Message type filter (cannot be pushed down to MCAP reader)
if self._message_types and schema and schema.name not in self._message_types:
return False
return True
def _message_to_dict(
self, schema: "Schema", channel: "Channel", message: "Message", path: str
) -> Dict[str, Any]:
"""Convert MCAP message to dictionary format.
This method converts MCAP message objects into a standardized dictionary
format suitable for Ray Data processing.
Args:
schema: MCAP schema object containing message type and encoding info.
channel: MCAP channel object containing topic and channel metadata.
message: MCAP message object containing the actual message data.
path: Path to the source file (for include_paths functionality).
Returns:
Dictionary containing message data in Ray Data format.
"""
# Decode message data based on encoding
decoded_data = message.data
if channel.message_encoding == "json" and isinstance(message.data, bytes):
try:
decoded_data = json.loads(message.data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
# Keep raw bytes if decoding fails
decoded_data = message.data
# Core message data
message_data = {
"data": decoded_data,
"topic": channel.topic,
"log_time": message.log_time,
"publish_time": message.publish_time,
"sequence": message.sequence,
}
# Add metadata if requested
if self._include_metadata:
message_data.update(
{
"channel_id": message.channel_id,
"message_encoding": channel.message_encoding,
"schema_name": schema.name if schema else None,
"schema_encoding": schema.encoding if schema else None,
"schema_data": schema.data if schema else None,
}
)
# Add file path if include_paths is enabled (from FileBasedDatasource)
if getattr(self, "include_paths", False):
message_data["path"] = path
return message_data
def get_name(self) -> str:
"""Return a human-readable name for this datasource."""
return "MCAP"
@property
def supports_distributed_reads(self) -> bool:
"""Whether this datasource supports distributed reads.
MCAP files can be read in parallel across multiple files.
"""
return True
@@ -0,0 +1,48 @@
import logging
from typing import Iterable
from ray.data._internal.datasource.mongo_datasource import (
_validate_database_collection_exist,
)
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink
logger = logging.getLogger(__name__)
class MongoDatasink(Datasink[None]):
def __init__(self, uri: str, database: str, collection: str) -> None:
_check_import(self, module="pymongo", package="pymongo")
_check_import(self, module="pymongoarrow", package="pymongoarrow")
self.uri = uri
self.database = database
self.collection = collection
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> None:
import pymongo
_validate_database_collection_exist(
pymongo.MongoClient(self.uri), self.database, self.collection
)
def write_block(uri: str, database: str, collection: str, block: Block):
from pymongoarrow.api import write
block = BlockAccessor.for_block(block).to_arrow()
client = pymongo.MongoClient(uri)
write(client[database][collection], block)
builder = DelegatingBlockBuilder()
for block in blocks:
builder.add_block(block)
block = builder.build()
write_block(self.uri, self.database, self.collection, block)
@@ -0,0 +1,147 @@
import logging
from typing import TYPE_CHECKING, Dict, List, Optional
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
if TYPE_CHECKING:
import pymongoarrow.api
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
class MongoDatasource(Datasource):
"""Datasource for reading from and writing to MongoDB."""
def __init__(
self,
uri: str,
database: str,
collection: str,
pipeline: Optional[List[Dict]] = None,
schema: Optional["pymongoarrow.api.Schema"] = None,
**mongo_args,
):
self._uri = uri
self._database = database
self._collection = collection
self._pipeline = pipeline
self._schema = schema
self._mongo_args = mongo_args
# If pipeline is unspecified, read the entire collection.
if not pipeline:
self._pipeline = [{"$match": {"_id": {"$exists": "true"}}}]
# Initialize Mongo client lazily later when creating read tasks.
self._client = None
def estimate_inmemory_data_size(self) -> Optional[int]:
# TODO(jian): Add memory size estimation to improve auto-tune of parallelism.
return None
def _get_match_query(self, pipeline: List[Dict]) -> Dict:
if len(pipeline) == 0 or "$match" not in pipeline[0]:
return {}
return pipeline[0]["$match"]
def _get_or_create_client(self):
import pymongo
if self._client is None:
self._client = pymongo.MongoClient(self._uri)
_validate_database_collection_exist(
self._client, self._database, self._collection
)
self._avg_obj_size = self._client[self._database].command(
"collStats", self._collection
)["avgObjSize"]
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
from bson.objectid import ObjectId
self._get_or_create_client()
coll = self._client[self._database][self._collection]
match_query = self._get_match_query(self._pipeline)
partitions_ids = list(
coll.aggregate(
[
{"$match": match_query},
{"$bucketAuto": {"groupBy": "$_id", "buckets": parallelism}},
],
allowDiskUse=True,
)
)
def make_block(
uri: str,
database: str,
collection: str,
pipeline: List[Dict],
min_id: ObjectId,
max_id: ObjectId,
right_closed: bool,
schema: "pymongoarrow.api.Schema",
kwargs: dict,
) -> Block:
import pymongo
from pymongoarrow.api import aggregate_arrow_all
# A range query over the partition.
match = [
{
"$match": {
"_id": {
"$gte": min_id,
"$lte" if right_closed else "$lt": max_id,
}
}
}
]
client = pymongo.MongoClient(uri)
return aggregate_arrow_all(
client[database][collection], match + pipeline, schema=schema, **kwargs
)
read_tasks: List[ReadTask] = []
for i, partition in enumerate(partitions_ids):
metadata = BlockMetadata(
num_rows=partition["count"],
size_bytes=partition["count"] * self._avg_obj_size,
input_files=None,
exec_stats=None,
)
make_block_args = (
self._uri,
self._database,
self._collection,
self._pipeline,
partition["_id"]["min"],
partition["_id"]["max"],
i == len(partitions_ids) - 1,
self._schema,
self._mongo_args,
)
read_task = ReadTask(
lambda args=make_block_args: [make_block(*args)],
metadata,
per_task_row_limit=per_task_row_limit,
)
read_tasks.append(read_task)
return read_tasks
def _validate_database_collection_exist(client, database: str, collection: str):
db_names = client.list_database_names()
if database not in db_names:
raise ValueError(f"The destination database {database} doesn't exist.")
collection_names = client[database].list_collection_names()
if collection not in collection_names:
raise ValueError(f"The destination collection {collection} doesn't exist.")
@@ -0,0 +1,23 @@
import numpy as np
import pyarrow
from ray.data.block import BlockAccessor
from ray.data.datasource.file_datasink import BlockBasedFileDatasink
class NumpyDatasink(BlockBasedFileDatasink):
def __init__(
self,
path: str,
column: str,
*,
file_format: str = "npy",
**file_datasink_kwargs,
):
super().__init__(path, file_format=file_format, **file_datasink_kwargs)
self.column = column
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
value = block.to_numpy(self.column)
np.save(file, value)
@@ -0,0 +1,41 @@
from io import BytesIO
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Union
import numpy as np
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
class NumpyDatasource(FileBasedDatasource):
"""Numpy datasource, for reading and writing Numpy files."""
_COLUMN_NAME = "data"
_FILE_EXTENSIONS = ["npy"]
def __init__(
self,
paths: Union[str, List[str]],
numpy_load_args: Optional[Dict[str, Any]] = None,
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
if numpy_load_args is None:
numpy_load_args = {}
self.numpy_load_args = numpy_load_args
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
# TODO(ekl) Ideally numpy can read directly from the file, but it
# seems like it requires the file to be seekable.
buf = BytesIO()
data = f.readall()
buf.write(data)
buf.seek(0)
yield BlockAccessor.batch_to_block(
{"data": np.load(buf, allow_pickle=True, **self.numpy_load_args)}
)
@@ -0,0 +1,390 @@
import logging
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional
from ray._common.retry import call_with_retry
from ray._private.utils import INT32_MAX
from ray.data._internal.arrow_ops.transform_pyarrow import (
reorder_columns_by_schema,
)
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.planner.plan_write_op import WRITE_UUID_KWARG_NAME
from ray.data._internal.savemode import SaveMode
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.file_based_datasource import _resolve_kwargs
from ray.data.datasource.file_datasink import _FileDatasink
from ray.data.datasource.filename_provider import FilenameProvider
if TYPE_CHECKING:
import pyarrow
WRITE_FILE_MAX_ATTEMPTS = 10
WRITE_FILE_RETRY_MAX_BACKOFF_SECONDS = 32
FILE_FORMAT = "parquet"
# These args are part of https://arrow.apache.org/docs/python/generated/pyarrow.fs.FileSystem.html#pyarrow.fs.FileSystem.open_output_stream
# and are not supported by ParquetDatasink.
UNSUPPORTED_OPEN_STREAM_ARGS = {"path", "buffer", "metadata"}
# https://arrow.apache.org/docs/python/generated/pyarrow.dataset.write_dataset.html
ARROW_DEFAULT_MAX_ROWS_PER_GROUP = 1024 * 1024
DEFAULT_PARTITIONING_FLAVOR = "hive"
logger = logging.getLogger(__name__)
def choose_row_group_limits(
row_group_size: Optional[int],
min_rows_per_file: Optional[int],
max_rows_per_file: Optional[int],
) -> tuple[Optional[int], Optional[int], Optional[int]]:
"""Configure row-group limits for Pyarrow's ``write_dataset`` API.
Configures the ``min_rows_per_group``, ``max_rows_per_group``, and
``max_rows_per_file`` parameters based on Ray Data's configuration.
Args:
row_group_size: The requested row-group size.
min_rows_per_file: The minimum number of rows per file.
max_rows_per_file: The maximum number of rows per file.
Returns:
A tuple of (min_rows_per_group, max_rows_per_group, max_rows_per_file).
"""
if (
row_group_size is None
and min_rows_per_file is None
and max_rows_per_file is None
):
return None, None, None
elif row_group_size is None:
# No explicit row group size provided. We are defaulting to
# either the caller's min_rows_per_file or max_rows_per_file limits
# or Arrow's defaults
min_rows_per_group, max_rows_per_group, max_rows_per_file = (
min_rows_per_file,
max_rows_per_file,
max_rows_per_file,
)
# If min_rows_per_group is provided and max_rows_per_group is not,
# and min_rows_per_group is greater than Arrow's default max_rows_per_group,
# we set max_rows_per_group to min_rows_per_group to avoid creating too many row groups.
if (
min_rows_per_group is not None
and max_rows_per_group is None
and min_rows_per_group > ARROW_DEFAULT_MAX_ROWS_PER_GROUP
):
max_rows_per_group, max_rows_per_file = (
min_rows_per_group,
min_rows_per_group,
)
return min_rows_per_group, max_rows_per_group, max_rows_per_file
elif row_group_size is not None and (
min_rows_per_file is None or max_rows_per_file is None
):
return row_group_size, row_group_size, max_rows_per_file
else:
# Clamp the requested `row_group_size` so that it is
# * no smaller than `min_rows_per_file` (`lower`)
# * no larger than `max_rows_per_file` (or Arrow's default cap) (`upper`)
# This keeps each row-group within the per-file limits while staying
# as close as possible to the requested size.
clamped_group_size = max(
min_rows_per_file, min(row_group_size, max_rows_per_file)
)
return clamped_group_size, clamped_group_size, max_rows_per_file
def _widen_offset_overflowing_columns(
tables: List["pyarrow.Table"], schema: "pyarrow.Schema"
) -> "pyarrow.Schema":
"""Promote `string`/`binary` columns to 64-bit-offset variants when needed.
Arrow addresses the data of `string` and `binary` columns with int32
offsets, so a single contiguous array can hold at most `INT32_MAX` bytes.
When the writer coalesces blocks into a large row group (e.g. because
`min_rows_per_file` set `min_rows_per_group`), pyarrow must materialize each
column of that row group as one contiguous array. If a `string`/`binary`
column's combined size across the blocks in this write exceeds the int32
limit, `write_dataset` fails with an offset-overflow error that surfaces as
a column-length mismatch (the column is truncated to what fits).
Promoting such columns to `large_string`/`large_binary` (int64 offsets)
removes the ceiling. The promotion is invisible on disk -- parquet stores
both as `BYTE_ARRAY` -- so only the in-memory Arrow type changes.
Only top-level `string`/`binary` columns are promoted. Other int32-offset
types are not handled: `string`/`binary` nested inside `list`/`struct`/`map`
(whose inner offsets carry the same byte ceiling) and `list`/`map` columns
themselves (which overflow on cumulative child-element count rather than
bytes). These require recursive type rewriting and are far rarer in practice.
Args:
tables: The blocks about to be written together in one task.
schema: The unified output schema for those blocks.
Returns:
``schema`` unchanged when no column overflows, otherwise a copy with the
overflowing variable-width columns promoted to their ``large_*`` type.
"""
import pyarrow as pa
candidate_types = {}
for field in schema:
if pa.types.is_string(field.type):
candidate_types[field.name] = pa.large_string()
elif pa.types.is_binary(field.type):
candidate_types[field.name] = pa.large_binary()
if not candidate_types:
return schema
# `.nbytes` is O(1) buffer metadata, so summing across blocks is cheap.
overflowing: set = set()
combined_nbytes: Dict[str, int] = defaultdict(int)
for table in tables:
for name in candidate_types:
idx = table.schema.get_field_index(name)
if idx != -1:
combined_nbytes[name] += table.column(idx).nbytes
if combined_nbytes[name] > INT32_MAX:
overflowing.add(name)
if not overflowing:
return schema
new_fields = [
field.with_type(candidate_types[field.name])
if field.name in overflowing
else field
for field in schema
]
return pa.schema(new_fields, metadata=schema.metadata)
class ParquetDatasink(_FileDatasink):
def __init__(
self,
path: str,
*,
partition_cols: Optional[List[str]] = None,
arrow_parquet_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
arrow_parquet_args: Optional[Dict[str, Any]] = None,
min_rows_per_file: Optional[int] = None,
max_rows_per_file: Optional[int] = None,
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
try_create_dir: bool = True,
open_stream_args: Optional[Dict[str, Any]] = None,
filename_provider: Optional[FilenameProvider] = None,
dataset_uuid: Optional[str] = None,
mode: SaveMode = SaveMode.APPEND,
):
if arrow_parquet_args_fn is None:
arrow_parquet_args_fn = lambda: {} # noqa: E731
if arrow_parquet_args is None:
arrow_parquet_args = {}
self.arrow_parquet_args_fn = arrow_parquet_args_fn
self.arrow_parquet_args = arrow_parquet_args
self.min_rows_per_file = min_rows_per_file
self.max_rows_per_file = max_rows_per_file
self.partition_cols = partition_cols
if self.min_rows_per_file is not None and self.max_rows_per_file is not None:
if self.min_rows_per_file > self.max_rows_per_file:
raise ValueError(
"min_rows_per_file must be less than or equal to max_rows_per_file"
)
if open_stream_args is not None:
intersecting_keys = UNSUPPORTED_OPEN_STREAM_ARGS.intersection(
set(open_stream_args.keys())
)
if intersecting_keys:
logger.warning(
"open_stream_args contains unsupported arguments: %s. These arguments "
"are not supported by ParquetDatasink. They will be ignored.",
intersecting_keys,
)
if "compression" in open_stream_args:
self.arrow_parquet_args["compression"] = open_stream_args["compression"]
if ("partitioning_flavor" in self.arrow_parquet_args) or (
self.arrow_parquet_args_fn is not None
and "partitioning_flavor" in self.arrow_parquet_args_fn()
):
if self.partition_cols is None:
raise ValueError(
"partition_cols must be provided when partitioning_flavor is set."
)
super().__init__(
path,
filesystem=filesystem,
try_create_dir=try_create_dir,
open_stream_args=open_stream_args,
filename_provider=filename_provider,
dataset_uuid=dataset_uuid,
file_format=FILE_FORMAT,
mode=mode,
)
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> None:
import pyarrow as pa
blocks = list(blocks)
if all(BlockAccessor.for_block(block).num_rows() == 0 for block in blocks):
return
blocks = [
block for block in blocks if BlockAccessor.for_block(block).num_rows() > 0
]
filename = self.filename_provider.get_filename_for_task(
ctx.kwargs[WRITE_UUID_KWARG_NAME], ctx.task_idx
)
write_kwargs = _resolve_kwargs(
self.arrow_parquet_args_fn, **self.arrow_parquet_args
)
user_schema = write_kwargs.pop("schema", None)
# For partitioning_flavor, if it's not provided, the default is "hive"
# Otherwise, it follows pyarrow's behavior: None for directory,
# "hive" for hive, and "filename" for FilenamePartitioning.
partitioning_flavor = write_kwargs.pop(
"partitioning_flavor", DEFAULT_PARTITIONING_FLAVOR
)
def write_blocks_to_path():
tables = [BlockAccessor.for_block(block).to_arrow() for block in blocks]
if user_schema is None:
output_schema = pa.unify_schemas([table.schema for table in tables])
# Coalescing many blocks into one row group can push a
# `string`/`binary` column past Arrow's 2 GiB int32-offset
# limit; promote such columns to their `large_*` variant so the
# contiguous row-group array can address all of its bytes.
output_schema = _widen_offset_overflowing_columns(tables, output_schema)
else:
output_schema = user_schema
self._write_parquet_files(
tables,
filename,
output_schema,
ctx.kwargs[WRITE_UUID_KWARG_NAME],
write_kwargs,
partitioning_flavor,
)
logger.debug(f"Writing {filename} file to {self.path}.")
call_with_retry(
write_blocks_to_path,
description=f"write '{filename}' to '{self.path}'",
match=self._data_context.retried_io_errors,
max_attempts=WRITE_FILE_MAX_ATTEMPTS,
max_backoff_s=WRITE_FILE_RETRY_MAX_BACKOFF_SECONDS,
)
def _get_basename_template(self, filename: str, write_uuid: str) -> str:
# Check if write_uuid is present in filename, add if missing
if write_uuid not in filename and self.mode == SaveMode.APPEND:
raise ValueError(
f"Write UUID '{write_uuid}' missing from filename template '{filename}'. This could result in files being overwritten."
f"Modify your FileNameProvider implementation to include the `write_uuid` into the filename template or change your write mode to SaveMode.OVERWRITE. "
)
# Check if filename is already templatized
if "{i}" in filename:
# Filename is already templatized, but may need file extension
if FILE_FORMAT not in filename:
# Add file extension to templatized filename
basename_template = f"{filename}.{FILE_FORMAT}"
else:
# Already has extension, use as-is
basename_template = filename
elif FILE_FORMAT not in filename:
# No extension and not templatized, add extension and template
basename_template = f"{filename}-{{i}}.{FILE_FORMAT}"
else:
# TODO(@goutamvenkat-anyscale): Add a warning if you pass in a custom
# filename provider and it isn't templatized.
# Use pathlib.Path to properly handle filenames with dots
filename_path = Path(filename)
stem = filename_path.stem # filename without extension
assert "." not in stem, "Filename should not contain a dot"
suffix = filename_path.suffix # extension including the dot
basename_template = f"{stem}-{{i}}{suffix}"
return basename_template
def _write_parquet_files(
self,
tables: List["pyarrow.Table"],
filename: str,
output_schema: "pyarrow.Schema",
write_uuid: str,
write_kwargs: Dict[str, Any],
partitioning_flavor: Optional[str],
) -> None:
import pyarrow.dataset as ds
# Make every incoming batch conform to the final schema *before* writing.
# `pa.unify_schemas` above fixed column order from the first block.
for idx, table in enumerate(tables):
if output_schema and not table.schema.equals(output_schema):
table = reorder_columns_by_schema(table, output_schema)
table = table.cast(output_schema)
tables[idx] = table
row_group_size = write_kwargs.pop("row_group_size", None)
# We set this to "overwrite_or_ignore", to avoid the race condition seen in parallel writes when this is set to "error". The driver already handles the save mode check in on_write_start.
existing_data_behavior = "overwrite_or_ignore"
(
min_rows_per_group,
max_rows_per_group,
max_rows_per_file,
) = choose_row_group_limits(
row_group_size,
min_rows_per_file=self.min_rows_per_file,
max_rows_per_file=self.max_rows_per_file,
)
basename_template = self._get_basename_template(filename, write_uuid)
# Note that the driver already handles the save mode logic, checking if the directory exists and raising an error if it does on SaveMode.ERROR
ds.write_dataset(
data=tables,
base_dir=self.path,
schema=output_schema,
basename_template=basename_template,
filesystem=self.filesystem,
partitioning=self.partition_cols,
format=FILE_FORMAT,
existing_data_behavior=existing_data_behavior,
partitioning_flavor=partitioning_flavor,
use_threads=True,
min_rows_per_group=min_rows_per_group,
max_rows_per_group=max_rows_per_group,
max_rows_per_file=max_rows_per_file,
file_options=ds.ParquetFileFormat().make_write_options(**write_kwargs),
create_dir=self.try_create_dir,
)
@property
def min_rows_per_write(self) -> Optional[int]:
return self.min_rows_per_file
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
import builtins
import functools
from typing import Iterable, List, Optional, Tuple
import numpy as np
from ray.data._internal.util import _check_pyarrow_version
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.data.context import DataContext
from ray.data.datasource import Datasource, ReadTask
class RangeDatasource(Datasource):
"""An example datasource that generates ranges of numbers from [0..n)."""
def __init__(
self,
n: int,
block_format: str = "arrow",
tensor_shape: Tuple = (1,),
column_name: Optional[str] = None,
):
self._n = int(n)
self._block_format = block_format
self._tensor_shape = tensor_shape
self._column_name = column_name
def estimate_inmemory_data_size(self) -> Optional[int]:
if self._block_format == "tensor":
element_size = int(np.prod(self._tensor_shape))
else:
element_size = 1
return 8 * self._n * element_size
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
if self._n == 0:
return []
read_tasks: List[ReadTask] = []
n = self._n
block_format = self._block_format
tensor_shape = self._tensor_shape
block_size = max(1, n // parallelism)
# TODO(swang): This target block size may not match the driver's
# context if it was overridden. Set target max block size during
# optimizer stage to fix this.
ctx = DataContext.get_current()
if ctx.target_max_block_size is None:
# If target_max_block_size is ``None``, treat it as unlimited and
# avoid further splitting.
target_rows_per_block = n # whole block in one shot
else:
row_size_bytes = self.estimate_inmemory_data_size() // self._n
row_size_bytes = max(row_size_bytes, 1)
target_rows_per_block = max(1, ctx.target_max_block_size // row_size_bytes)
# Example of a read task. In a real datasource, this would pull data
# from an external system instead of generating dummy data.
def make_block(start: int, count: int) -> Block:
if block_format == "arrow":
import pyarrow as pa
return pa.Table.from_arrays(
[np.arange(start, start + count)],
names=[self._column_name or "value"],
)
elif block_format == "tensor":
import pyarrow as pa
tensor = np.ones(tensor_shape, dtype=np.int64) * np.expand_dims(
np.arange(start, start + count),
tuple(range(1, 1 + len(tensor_shape))),
)
return BlockAccessor.batch_to_block(
{self._column_name: tensor} if self._column_name else tensor
)
else:
return list(builtins.range(start, start + count))
def make_blocks(
start: int, count: int, target_rows_per_block: int
) -> Iterable[Block]:
while count > 0:
num_rows = min(count, target_rows_per_block)
yield make_block(start, num_rows)
start += num_rows
count -= num_rows
if block_format == "tensor":
element_size = int(np.prod(tensor_shape))
else:
element_size = 1
i = 0
while i < n:
count = min(block_size, n - i)
meta = BlockMetadata(
num_rows=count,
size_bytes=8 * count * element_size,
input_files=None,
exec_stats=None,
)
read_tasks.append(
ReadTask(
lambda i=i, count=count: make_blocks(
i, count, target_rows_per_block
),
meta,
schema=self._schema,
per_task_row_limit=per_task_row_limit,
)
)
i += block_size
return read_tasks
@functools.cached_property
def _schema(self):
if self._n == 0:
return None
if self._block_format == "arrow":
_check_pyarrow_version()
import pyarrow as pa
schema = pa.Table.from_pydict({self._column_name or "value": [0]}).schema
elif self._block_format == "tensor":
_check_pyarrow_version()
import pyarrow as pa
tensor = np.ones(self._tensor_shape, dtype=np.int64) * np.expand_dims(
np.arange(0, 10), tuple(range(1, 1 + len(self._tensor_shape)))
)
schema = BlockAccessor.batch_to_block(
{self._column_name: tensor} if self._column_name else tensor
).schema
elif self._block_format == "list":
schema = int
else:
raise ValueError("Unsupported block type", self._block_format)
return schema
@@ -0,0 +1,35 @@
from typing import Callable, Iterable
from ray.data._internal.datasource.sql_datasource import Connection, _connect
from ray.data._internal.execution.interfaces import TaskContext
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink
class SQLDatasink(Datasink[None]):
_MAX_ROWS_PER_WRITE = 128
def __init__(self, sql: str, connection_factory: Callable[[], Connection]):
self.sql = sql
self.connection_factory = connection_factory
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> None:
with _connect(self.connection_factory) as cursor:
for block in blocks:
block_accessor = BlockAccessor.for_block(block)
values = []
for row in block_accessor.iter_rows(public_row_format=False):
values.append(tuple(row.values()))
assert len(values) <= self._MAX_ROWS_PER_WRITE, len(values)
if len(values) == self._MAX_ROWS_PER_WRITE:
cursor.executemany(self.sql, values)
values = []
if values:
cursor.executemany(self.sql, values)
@@ -0,0 +1,211 @@
import logging
import math
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, List, Optional
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
Connection = Any # A Python DB API2-compliant `Connection` object.
Cursor = Any # A Python DB API2-compliant `Cursor` object.
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
def _cursor_to_block(cursor) -> Block:
import pyarrow as pa
rows = cursor.fetchall()
# Each `column_description` is a 7-element sequence. The first element is the column
# name. To learn more, read https://peps.python.org/pep-0249/#description.
columns = [column_description[0] for column_description in cursor.description]
pydict = {column: [row[i] for row in rows] for i, column in enumerate(columns)}
return pa.Table.from_pydict(pydict)
def _check_connection_is_dbapi2_compliant(connection) -> None:
for attr in "close", "commit", "cursor":
if not hasattr(connection, attr):
raise ValueError(
"Your `connection_factory` created a `Connection` object without a "
f"{attr!r} method, but this method is required by the Python DB API2 "
"specification. Check that your database connector is DB API2-"
"compliant. To learn more, read https://peps.python.org/pep-0249/."
)
def _check_cursor_is_dbapi2_compliant(cursor) -> None:
# These aren't all the methods required by the specification, but it's all the ones
# we care about.
for attr in "execute", "executemany", "fetchone", "fetchall", "description":
if not hasattr(cursor, attr):
raise ValueError(
"Your database connector created a `Cursor` object without a "
f"{attr!r} method, but this method is required by the Python DB API2 "
"specification. Check that your database connector is DB API2-"
"compliant. To learn more, read https://peps.python.org/pep-0249/."
)
@contextmanager
def _connect(connection_factory: Callable[[], Connection]) -> Iterator[Cursor]:
connection = connection_factory()
_check_connection_is_dbapi2_compliant(connection)
try:
cursor = connection.cursor()
_check_cursor_is_dbapi2_compliant(cursor)
yield cursor
connection.commit()
except Exception:
# `rollback` is optional since not all databases provide transaction support.
try:
connection.rollback()
except Exception as e:
# Each connector implements its own `NotSupportError` class, so we check
# the exception's name instead of using `isinstance`.
if (
isinstance(e, AttributeError)
or e.__class__.__name__ == "NotSupportedError"
):
pass
raise
finally:
connection.close()
def _execute(cursor: Cursor, sql: str, params: Optional[Any]) -> None:
if params is None:
cursor.execute(sql)
else:
cursor.execute(sql, params)
class SQLDatasource(Datasource):
MIN_ROWS_PER_READ_TASK = 50
def __init__(
self,
sql: str,
connection_factory: Callable[[], Connection],
shard_hash_fn: str,
shard_keys: Optional[List[str]] = None,
sql_params: Optional[Any] = None,
):
self.sql = sql
if shard_keys and len(shard_keys) > 1:
self.shard_keys = f"CONCAT({','.join(shard_keys)})"
elif shard_keys and len(shard_keys) == 1:
self.shard_keys = f"{shard_keys[0]}"
else:
self.shard_keys = None
self.shard_hash_fn = shard_hash_fn
self.connection_factory = connection_factory
self.sql_params = sql_params
def estimate_inmemory_data_size(self) -> Optional[int]:
return None
def supports_sharding(self, parallelism: int) -> bool:
"""Check if database supports sharding with MOD/ABS/CONCAT operations.
Args:
parallelism: The number of shards to split the read into.
Returns:
bool: True if sharding is supported, False otherwise.
"""
if parallelism <= 1 or self.shard_keys is None:
return False
# Test if database supports required operations (MOD, ABS, MD5, CONCAT)
# by executing a sample query
hash_fn = self.shard_hash_fn
query = (
f"SELECT COUNT(1) FROM ({self.sql}) as T"
f" WHERE MOD(ABS({hash_fn}({self.shard_keys})), {parallelism}) = 0"
)
try:
with _connect(self.connection_factory) as cursor:
_execute(cursor, query, self.sql_params)
return True
except Exception as e:
logger.info(f"Database does not support sharding: {str(e)}.")
return False
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
def fallback_read_fn() -> Iterable[Block]:
"""Read all data in a single block when sharding is not supported."""
with _connect(self.connection_factory) as cursor:
_execute(cursor, self.sql, self.sql_params)
return [_cursor_to_block(cursor)]
# Check if sharding is supported by the database first
# If not, fall back to reading all data in a single task without counting rows
if not self.supports_sharding(parallelism):
logger.info(
"Sharding is not supported. "
"Falling back to reading all data in a single task."
)
metadata = BlockMetadata(None, None, None, None)
return [ReadTask(fallback_read_fn, metadata)]
# Only perform the expensive COUNT(*) query if sharding is supported
num_rows_total = self._get_num_rows()
if num_rows_total == 0:
return []
parallelism = min(
parallelism, math.ceil(num_rows_total / self.MIN_ROWS_PER_READ_TASK)
)
num_rows_per_block = num_rows_total // parallelism
num_blocks_with_extra_row = num_rows_total % parallelism
tasks = []
for i in range(parallelism):
num_rows = num_rows_per_block
if i < num_blocks_with_extra_row:
num_rows += 1
read_fn = self._create_parallel_read_fn(i, parallelism)
metadata = BlockMetadata(
num_rows=num_rows,
size_bytes=None,
input_files=None,
exec_stats=None,
)
tasks.append(
ReadTask(read_fn, metadata, per_task_row_limit=per_task_row_limit)
)
return tasks
def _get_num_rows(self) -> int:
with _connect(self.connection_factory) as cursor:
_execute(cursor, f"SELECT COUNT(*) FROM ({self.sql}) as T", self.sql_params)
return cursor.fetchone()[0]
def _create_parallel_read_fn(self, task_id: int, parallelism: int):
hash_fn = self.shard_hash_fn
query = (
f"SELECT * FROM ({self.sql}) as T "
f"WHERE MOD(ABS({hash_fn}({self.shard_keys})), {parallelism}) = {task_id}"
)
def read_fn() -> Iterable[Block]:
with _connect(self.connection_factory) as cursor:
_execute(cursor, query, self.sql_params)
block = _cursor_to_block(cursor)
return [block]
return read_fn
@@ -0,0 +1,42 @@
from typing import TYPE_CHECKING, Iterator, List
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data.block import Block
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
class TextDatasource(FileBasedDatasource):
"""Text datasource, for reading and writing text files."""
_COLUMN_NAME = "text"
def __init__(
self,
paths: List[str],
*,
drop_empty_lines: bool = False,
encoding: str = "utf-8",
**file_based_datasource_kwargs
):
super().__init__(paths, **file_based_datasource_kwargs)
self.drop_empty_lines = drop_empty_lines
self.encoding = encoding
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
data = f.readall()
builder = DelegatingBlockBuilder()
lines = data.decode(self.encoding).splitlines()
for line in lines:
if self.drop_empty_lines and line.strip() == "":
continue
item = {self._COLUMN_NAME: line}
builder.add(item)
block = builder.build()
yield block
@@ -0,0 +1,237 @@
import struct
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Union
import numpy as np
from .tfrecords_datasource import _get_single_true_type
from ray.data._internal.tensor_extensions.arrow import (
get_arrow_extension_tensor_types,
)
from ray.data._internal.util import _check_import
from ray.data.block import BlockAccessor
from ray.data.datasource.file_datasink import BlockBasedFileDatasink
if TYPE_CHECKING:
import pyarrow
import tensorflow as tf
from tensorflow_metadata.proto.v0 import schema_pb2
class TFRecordDatasink(BlockBasedFileDatasink):
def __init__(
self,
path: str,
*,
tf_schema: Optional["schema_pb2.Schema"] = None,
file_format: str = "tar",
**file_datasink_kwargs,
):
super().__init__(path, file_format=file_format, **file_datasink_kwargs)
_check_import(self, module="crc32c", package="crc32c")
self.tf_schema = tf_schema
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
arrow_table = block.to_arrow()
# It seems like TFRecords are typically row-based,
# https://www.tensorflow.org/tutorials/load_data/tfrecord#writing_a_tfrecord_file_2
# so we must iterate through the rows of the block,
# serialize to tf.train.Example proto, and write to file.
examples = _convert_arrow_table_to_examples(arrow_table, self.tf_schema)
# Write each example to the arrow file in the TFRecord format.
for example in examples:
_write_record(file, example)
def _convert_arrow_table_to_examples(
arrow_table: "pyarrow.Table",
tf_schema: Optional["schema_pb2.Schema"] = None,
) -> Iterable["tf.train.Example"]:
import tensorflow as tf
schema_dict = {}
# Convert user-specified schema into dict for convenient mapping
if tf_schema is not None:
for schema_feature in tf_schema.feature:
schema_dict[schema_feature.name] = schema_feature.type
column_names = arrow_table.column_names
# Validate schema once up-front rather than per row.
if tf_schema is not None:
for name in column_names:
if name not in schema_dict:
raise ValueError(
f"Found extra unexpected feature {name} "
f"not in specified schema: {tf_schema}"
)
# Hoist per-column lookups out of the row loop. Iterating columns in
# lockstep with zip uses ChunkedArray.__iter__ (a C-level loop) instead
# of per-row __getitem__ calls, which avoids Python-side method dispatch
# on every element.
columns = arrow_table.columns
schema_feature_types = [schema_dict.get(name) for name in column_names]
for row_values in zip(*columns):
features: Dict[str, "tf.train.Feature"] = {
name: _value_to_feature(value, sft)
for name, value, sft in zip(column_names, row_values, schema_feature_types)
}
proto = tf.train.Example(features=tf.train.Features(feature=features))
yield proto
def _value_to_feature(
value: Union["pyarrow.Scalar", "pyarrow.Array"],
schema_feature_type: Optional["schema_pb2.FeatureType"] = None,
) -> "tf.train.Feature":
import pyarrow as pa
import tensorflow as tf
from ray.data._internal.utils.transform_pyarrow import _is_native_tensor_type
if isinstance(value, pa.ListScalar):
# Use the underlying type of the ListScalar's value in
# determining the output feature's data type.
value_type = value.type.value_type
value = value.as_py()
elif isinstance(value.type, get_arrow_extension_tensor_types()):
value_type = value.type
py_val = value.as_py()
if _is_native_tensor_type(value_type):
# PyArrow's native FixedShapeTensorType returns a flat list from
# as_py(), so use to_numpy()
value = value.to_numpy()
else:
value = py_val
else:
value_type = value.type
value = value.as_py()
if value is None:
value = []
else:
value = [value]
underlying_value_type = {
"bytes": pa.types.is_binary(value_type),
"string": pa.types.is_string(value_type),
"float": pa.types.is_floating(value_type),
"int": pa.types.is_integer(value_type),
"tensor": isinstance(value_type, get_arrow_extension_tensor_types()),
}
assert sum(bool(value) for value in underlying_value_type.values()) <= 1
if schema_feature_type is not None:
try:
from tensorflow_metadata.proto.v0 import schema_pb2
except ModuleNotFoundError:
raise ModuleNotFoundError(
"To use TensorFlow schemas, please install "
"the tensorflow-metadata package."
)
specified_feature_type = {
# We default anything that is not a string or tensor to be
# a byte array, mostly to deal with the case when we have
# null input, but we specify a schema.
"bytes": schema_feature_type == schema_pb2.FeatureType.BYTES
and not underlying_value_type["string"]
and not underlying_value_type["tensor"],
"string": schema_feature_type == schema_pb2.FeatureType.BYTES
and underlying_value_type["string"],
"float": schema_feature_type == schema_pb2.FeatureType.FLOAT,
"int": schema_feature_type == schema_pb2.FeatureType.INT,
"tensor": schema_feature_type == schema_pb2.FeatureType.BYTES
and underlying_value_type["tensor"],
}
und_type = _get_single_true_type(underlying_value_type)
spec_type = _get_single_true_type(specified_feature_type)
if und_type is not None and und_type != spec_type:
raise ValueError(
"Schema field type mismatch during write: specified type is "
f"{spec_type}, but underlying type is {und_type}",
)
# Override the underlying value type with the type in the user-specified schema.
underlying_value_type = specified_feature_type
if underlying_value_type["int"]:
return tf.train.Feature(int64_list=tf.train.Int64List(value=value))
if underlying_value_type["float"]:
return tf.train.Feature(float_list=tf.train.FloatList(value=value))
if underlying_value_type["bytes"]:
return tf.train.Feature(bytes_list=tf.train.BytesList(value=value))
if underlying_value_type["string"]:
value = [v.encode() for v in value] # casting to bytes
return tf.train.Feature(bytes_list=tf.train.BytesList(value=value))
if underlying_value_type["tensor"]:
if value is None:
value = []
else:
value = [tf.io.serialize_tensor(value).numpy()]
return tf.train.Feature(bytes_list=tf.train.BytesList(value=value))
if pa.types.is_null(value_type):
raise ValueError(
"Unable to infer type from partially missing column. "
"Try setting read parallelism = 1, or use an input data source which "
"explicitly specifies the schema."
)
raise ValueError(
f"Value is of type {value_type}, "
"which we cannot convert to a supported tf.train.Feature storage type "
"(bytes, float, or int)."
)
# Adapted from https://github.com/vahidk/tfrecord/blob/74b2d24a838081356d993ec0e147eaf59ccd4c84/tfrecord/writer.py#L57-L72 # noqa: E501
#
# MIT License
#
# Copyright (c) 2020 Vahid Kazemi
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
def _write_record(
file: "pyarrow.NativeFile",
example: "tf.train.Example",
) -> None:
record = example.SerializeToString()
length = len(record)
length_bytes = struct.pack("<Q", length)
file.write(length_bytes)
file.write(_masked_crc(length_bytes))
file.write(record)
file.write(_masked_crc(record))
def _masked_crc(data: bytes) -> bytes:
"""CRC checksum."""
import crc32c
mask = 0xA282EAD8
crc = crc32c.crc32(data)
masked = ((crc >> 15) | (crc << 17)) + mask
masked = np.uint32(masked & np.iinfo(np.uint32).max)
masked_bytes = struct.pack("<I", masked)
return masked_bytes
@@ -0,0 +1,259 @@
import logging
import struct
from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, Optional, Union
import pyarrow
from ray.data._internal.tensor_extensions.arrow import pyarrow_table_from_pydict
from ray.data.block import Block
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import tensorflow as tf
from tensorflow_metadata.proto.v0 import schema_pb2
logger = logging.getLogger(__name__)
class TFRecordDatasource(FileBasedDatasource):
"""TFRecord datasource, for reading and writing TFRecord files."""
_FILE_EXTENSIONS = ["tfrecords"]
def __init__(
self,
paths: Union[str, List[str]],
tf_schema: Optional["schema_pb2.Schema"] = None,
**file_based_datasource_kwargs,
):
"""Initialize the TFRecord datasource.
Args:
paths: One or more file paths to read TFRecord data from.
tf_schema: Optional TensorFlow Schema which is used to explicitly set
the schema of the underlying Dataset.
**file_based_datasource_kwargs: Additional keyword arguments forwarded
to the underlying :class:`FileBasedDatasource`.
"""
super().__init__(paths, **file_based_datasource_kwargs)
self._tf_schema = tf_schema
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
import tensorflow as tf
from google.protobuf.message import DecodeError
for record in _read_records(f, path):
example = tf.train.Example()
try:
example.ParseFromString(record)
except DecodeError as e:
raise ValueError(
"`TFRecordDatasource` failed to parse `tf.train.Example` "
f"record in '{path}'. This error can occur if your TFRecord "
f"file contains a message type other than `tf.train.Example`: {e}"
)
yield pyarrow_table_from_pydict(
_convert_example_to_dict(example, self._tf_schema)
)
def _convert_example_to_dict(
example: "tf.train.Example",
tf_schema: Optional["schema_pb2.Schema"],
) -> Dict[str, pyarrow.Array]:
record = {}
schema_dict = {}
# Convert user-specified schema into dict for convenient mapping
if tf_schema is not None:
for schema_feature in tf_schema.feature:
schema_dict[schema_feature.name] = schema_feature.type
for feature_name, feature in example.features.feature.items():
if tf_schema is not None and feature_name not in schema_dict:
raise ValueError(
f"Found extra unexpected feature {feature_name} "
f"not in specified schema: {tf_schema}"
)
schema_feature_type = schema_dict.get(feature_name)
record[feature_name] = _get_feature_value(feature, schema_feature_type)
return record
def _get_single_true_type(dct) -> str:
"""Utility function for getting the single key which has a `True` value in
a dict. Used to filter a dict of `{field_type: is_valid}` to get
the field type from a schema or data source."""
filtered_types = iter([_type for _type in dct if dct[_type]])
# In the case where there are no keys with a `True` value, return `None`
return next(filtered_types, None)
def _get_feature_value(
feature: "tf.train.Feature",
schema_feature_type: Optional["schema_pb2.FeatureType"] = None,
) -> pyarrow.Array:
import pyarrow as pa
underlying_feature_type = {
"bytes": feature.HasField("bytes_list"),
"float": feature.HasField("float_list"),
"int": feature.HasField("int64_list"),
}
# At most one of `bytes_list`, `float_list`, and `int64_list`
# should contain values. If none contain data, this indicates
# an empty feature value.
assert sum(bool(value) for value in underlying_feature_type.values()) <= 1
if schema_feature_type is not None:
try:
from tensorflow_metadata.proto.v0 import schema_pb2
except ModuleNotFoundError:
raise ModuleNotFoundError(
"To use TensorFlow schemas, please install "
"the tensorflow-metadata package."
)
# If a schema is specified, compare to the underlying type
specified_feature_type = {
"bytes": schema_feature_type == schema_pb2.FeatureType.BYTES,
"float": schema_feature_type == schema_pb2.FeatureType.FLOAT,
"int": schema_feature_type == schema_pb2.FeatureType.INT,
}
und_type = _get_single_true_type(underlying_feature_type)
spec_type = _get_single_true_type(specified_feature_type)
if und_type is not None and und_type != spec_type:
raise ValueError(
"Schema field type mismatch during read: specified type is "
f"{spec_type}, but underlying type is {und_type}",
)
# Override the underlying value type with the type in the user-specified schema.
underlying_feature_type = specified_feature_type
if underlying_feature_type["bytes"]:
value = feature.bytes_list.value
type_ = pa.binary()
elif underlying_feature_type["float"]:
value = feature.float_list.value
type_ = pa.float32()
elif underlying_feature_type["int"]:
value = feature.int64_list.value
type_ = pa.int64()
else:
value = []
type_ = pa.null()
value = list(value)
if len(value) == 1 and schema_feature_type is None:
# Use the value itself if the features contains a single value.
# This is to give better user experience when writing preprocessing UDF on
# these single-value lists.
value = value[0]
else:
# If the feature value is empty and no type is specified in the user-provided
# schema, set the type to null for now to allow pyarrow to construct a valid
# Array; later, infer the type from other records which have non-empty values
# for the feature.
if len(value) == 0:
type_ = pa.null()
type_ = pa.list_(type_)
return pa.array([value], type=type_)
# Adapted from https://github.com/vahidk/tfrecord/blob/74b2d24a838081356d993ec0e147eaf59ccd4c84/tfrecord/reader.py#L16-L96 # noqa: E501
#
# MIT License
#
# Copyright (c) 2020 Vahid Kazemi
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
def _read_records(
file: "pyarrow.NativeFile",
path: str,
) -> Iterable[memoryview]:
"""
Read records from TFRecord file.
A TFRecord file contains a sequence of records. The file can only be read
sequentially. Each record is stored in the following formats:
uint64 length
uint32 masked_crc32_of_length
byte data[length]
uint32 masked_crc32_of_data
See https://www.tensorflow.org/tutorials/load_data/tfrecord#tfrecords_format_details
for more details.
"""
length_bytes = bytearray(8)
crc_bytes = bytearray(4)
datum_bytes = bytearray(1024 * 1024)
row_count = 0
while True:
try:
# Read "length" field.
num_length_bytes_read = file.readinto(length_bytes)
if num_length_bytes_read == 0:
break
elif num_length_bytes_read != 8:
raise ValueError(
"Failed to read the length of record data. Expected 8 bytes but "
"got {num_length_bytes_read} bytes."
)
# Read "masked_crc32_of_length" field.
num_length_crc_bytes_read = file.readinto(crc_bytes)
if num_length_crc_bytes_read != 4:
raise ValueError(
"Failed to read the length of CRC-32C hashes. Expected 4 bytes "
"but got {num_length_crc_bytes_read} bytes."
)
# Read "data[length]" field.
(data_length,) = struct.unpack("<Q", length_bytes)
if data_length > len(datum_bytes):
datum_bytes = datum_bytes.zfill(int(data_length * 1.5))
datum_bytes_view = memoryview(datum_bytes)[:data_length]
num_datum_bytes_read = file.readinto(datum_bytes_view)
if num_datum_bytes_read != data_length:
raise ValueError(
f"Failed to read the record. Exepcted {data_length} bytes but got "
f"{num_datum_bytes_read} bytes."
)
# Read "masked_crc32_of_data" field.
# TODO(chengsu): ideally we should check CRC-32C against the actual data.
num_crc_bytes_read = file.readinto(crc_bytes)
if num_crc_bytes_read != 4:
raise ValueError(
"Failed to read the CRC-32C hashes. Expected 4 bytes but got "
f"{num_crc_bytes_read} bytes."
)
# Return the data.
yield datum_bytes_view
row_count += 1
data_length = None
except Exception as e:
error_message = (
f"Failed to read TFRecord file {path}. Please ensure that the "
f"TFRecord file has correct format. Already read {row_count} rows."
)
if data_length is not None:
error_message += f" Byte size of current record data is {data_length}."
raise RuntimeError(error_message) from e
@@ -0,0 +1,82 @@
from typing import TYPE_CHECKING, Optional
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data.block import BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
if TYPE_CHECKING:
import torch
from ray.data.context import DataContext
TORCH_DATASOURCE_READER_BATCH_SIZE = 32
class TorchDatasource(Datasource):
"""Torch datasource, for reading from `Torch
datasets <https://pytorch.org/docs/stable/data.html/>`_.
This datasource implements a streaming read using a single read task.
"""
def __init__(
self,
dataset: "torch.utils.data.Dataset",
):
self._dataset = dataset
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
):
assert parallelism == 1
meta = BlockMetadata(
# Note: avoid len(self._dataset) because it will trigger
# iterating through IterableDataset, which can cause OOM.
num_rows=None,
size_bytes=None,
input_files=None,
exec_stats=None,
)
read_task = ReadTask(
lambda subset=self._dataset: _read_subset(
subset,
),
metadata=meta,
per_task_row_limit=per_task_row_limit,
)
return [read_task]
def estimate_inmemory_data_size(self):
return None
def _read_subset(subset: "torch.utils.data.Subset"):
batch = []
# Get items from dataset based on its type
if hasattr(subset, "__iter__"):
# IterableDataset: Use the iterator directly
items = subset
else:
# Map-style dataset: Respect __len__
items = (subset[i] for i in range(len(subset)))
# Process items in batches
for item in items:
batch.append(item)
if len(batch) == TORCH_DATASOURCE_READER_BATCH_SIZE:
builder = DelegatingBlockBuilder()
builder.add_batch({"item": batch})
yield builder.build()
batch.clear()
# Handle any remaining items
if len(batch) > 0:
builder = DelegatingBlockBuilder()
builder.add_batch({"item": batch})
yield builder.build()
@@ -0,0 +1,427 @@
"""
TurbopufferDatasink - Ray Data datasink for Turbopuffer vector database
Implementation following the pattern of MongoDatasink and Daft's Turbopuffer sink.
This is based on [Turbopuffer Write API](https://turbopuffer.com/docs/write)
"""
import logging
import os
from typing import TYPE_CHECKING, Iterable, Literal, Optional, Union
import pyarrow as pa
import pyarrow.compute as pc
from ray._common.retry import call_with_retry
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink
if TYPE_CHECKING:
import turbopuffer
logger = logging.getLogger(__name__)
# Reserved column names for Turbopuffer
_ID_COLUMN = "id"
_VECTOR_COLUMN = "vector"
TURBOPUFFER_API_KEY_ENV_VAR = "TURBOPUFFER_API_KEY"
class TurbopufferDatasink(Datasink):
"""Turbopuffer Ray Datasink.
A Ray :class:`~ray.data.datasource.Datasink` for writing data into the
`Turbopuffer <https://turbopuffer.com/>`_ vector database.
Supports two modes of operation:
* **Single namespace** -- provide ``namespace`` to write all rows into one
Turbopuffer namespace.
* **Multi-namespace** -- provide ``namespace_column`` to route each row to
the namespace whose name is stored in that column. The column is
automatically dropped before the data is sent to Turbopuffer.
Exactly one of ``namespace`` or ``namespace_column`` must be supplied.
Args:
namespace: Name of the Turbopuffer namespace to write into.
Mutually exclusive with ``namespace_column``.
namespace_column: Name of a column whose values determine the
target namespace for each row. Rows are grouped by this column
and each group is written to its corresponding namespace. The
column is removed from the data before writing. Mutually
exclusive with ``namespace``.
region: Turbopuffer region identifier (for example,
``"gcp-us-central1"``). Mutually exclusive with ``base_url``.
Exactly one of ``region`` or ``base_url`` must be supplied.
base_url: Base URL for the Turbopuffer API (for example,
``"https://gcp-us-central1.turbopuffer.com"``). Mutually
exclusive with ``region``. Exactly one of ``region`` or
``base_url`` must be supplied.
api_key: Turbopuffer API key. If omitted, the value is read from the
``TURBOPUFFER_API_KEY`` environment variable.
schema: Optional Turbopuffer schema definition to pass along with
writes. If provided, it is forwarded as the ``schema`` argument
to ``namespace.write``.
id_column: Name of the column to treat as the document identifier.
Rows with null IDs are dropped before writing. Defaults to ``"id"``.
vector_column: Name of the column containing embedding vectors.
If this differs from ``"vector"``, it is renamed to ``"vector"``
before writing. Defaults to ``"vector"``.
batch_size: Maximum number of rows to include in a single Turbopuffer
write call (logical row batching; subject to Turbopuffer's
256MiB request-size limit). Defaults to ``10000``.
distance_metric: Distance metric for the namespace. Passed to
``namespace.write`` as the ``distance_metric`` argument.
Defaults to ``"cosine_distance"``.
concurrency: Unused; Ray Data controls write parallelism via
:meth:`~ray.data.Dataset.write_datasink` ``concurrency``.
Examples:
Write to a single namespace using a region:
.. testcode::
:skipif: True
import ray
from ray.data._internal.datasource.turbopuffer_datasink import (
TurbopufferDatasink,
)
ds = ray.data.range(100)
ds = ds.map_batches(lambda batch: {"id": batch["id"], "vector": ...})
ds.write_datasink(
TurbopufferDatasink(
namespace="my-namespace",
api_key="<YOUR_API_KEY>",
region="gcp-us-central1",
)
)
Write using a base URL instead of a region:
.. testcode::
:skipif: True
ds.write_datasink(
TurbopufferDatasink(
namespace="my-namespace",
api_key="<YOUR_API_KEY>",
base_url="https://gcp-us-central1.turbopuffer.com",
)
)
Write to multiple namespaces driven by a column:
.. testcode::
:skipif: True
ds.write_datasink(
TurbopufferDatasink(
namespace_column="tenant",
api_key="<YOUR_API_KEY>",
region="gcp-us-central1",
)
)
"""
def __init__(
self,
namespace: Optional[str] = None,
*,
namespace_column: Optional[str] = None,
region: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
schema: Optional[dict] = None,
id_column: str = "id",
vector_column: str = "vector",
batch_size: int = 10000,
distance_metric: Literal[
"cosine_distance", "euclidean_distance"
] = "cosine_distance",
concurrency: Optional[int] = None,
):
_check_import(self, module="turbopuffer", package="turbopuffer")
# Validate namespace / namespace_column mutual exclusivity.
if namespace and namespace_column:
raise ValueError(
"Specify exactly one of 'namespace' or 'namespace_column', " "not both."
)
if not namespace and not namespace_column:
raise ValueError(
"Either 'namespace' or 'namespace_column' must be provided."
)
# Validate region / base_url mutual exclusivity.
if region is not None and base_url is not None:
raise ValueError("Specify exactly one of 'region' or 'base_url', not both.")
if region is None and base_url is None:
raise ValueError("Either 'region' or 'base_url' must be provided.")
# Store configuration
self.namespace = namespace
self.namespace_column = namespace_column
self.api_key = api_key or os.getenv(TURBOPUFFER_API_KEY_ENV_VAR)
self.region = region
self.base_url = base_url
self.schema = schema
self.id_column = id_column
self.vector_column = vector_column
self.batch_size = batch_size
self.distance_metric = distance_metric
# Validate column configuration
if self.id_column == self.vector_column:
raise ValueError(
"id_column and vector_column refer to the same column "
f"'{self.id_column}'. They must be distinct."
)
if self.namespace_column and self.namespace_column in (
self.id_column,
self.vector_column,
):
raise ValueError(
f"namespace_column '{self.namespace_column}' must not be the "
f"same as id_column ('{self.id_column}') or vector_column "
f"('{self.vector_column}')."
)
# Validate API key
if not self.api_key:
raise ValueError(
"API key is required. Provide via api_key parameter or "
"TURBOPUFFER_API_KEY environment variable"
)
# Initialize client
self._client = None
def __getstate__(self) -> dict:
"""Exclude `_client` during pickling."""
state = self.__dict__.copy()
state.pop("_client", None)
return state
def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)
self._client = None
def _get_client(self):
"""Lazy initialize Turbopuffer client."""
if self._client is None:
import turbopuffer
kwargs = {"api_key": self.api_key}
if self.region is not None:
kwargs["region"] = self.region
else:
kwargs["base_url"] = self.base_url
self._client = turbopuffer.Turbopuffer(**kwargs)
return self._client
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> None:
"""
Write blocks to Turbopuffer in a streaming fashion.
For memory efficiency, blocks are processed one at a time rather than
concatenating all blocks into a single large table. This follows the
pattern used by ClickHouseDatasink.
Each block is prepared (columns renamed, null IDs filtered), then
written in batches of ``batch_size``.
When ``namespace_column`` is set, each block is grouped by the
namespace column and each group is written to its corresponding
Turbopuffer namespace.
"""
client = self._get_client()
for block in blocks:
accessor = BlockAccessor.for_block(block)
table = accessor.to_arrow()
if table.num_rows == 0:
continue
if self.namespace_column:
# Multi-namespace: group by namespace column, write to each.
self._write_multi_namespace(client, table)
else:
# Single namespace.
table = self._prepare_arrow_table(table)
if table.num_rows == 0:
continue
ns = client.namespace(self.namespace)
for batch in table.to_batches(max_chunksize=self.batch_size):
self._write_batch_with_retry(ns, batch, self.namespace)
def _rename_column_if_needed(
self,
table: pa.Table,
source_column: str,
target_column: str,
column_type: str,
) -> pa.Table:
"""
Rename a column in the table if it differs from the target name.
Args:
table: The Arrow table to modify.
source_column: The current column name in the table.
target_column: The required column name for Turbopuffer.
column_type: Human-readable type for error messages (e.g., "ID", "Vector").
Returns:
The table with the column renamed, or the original table if no rename needed.
Raises:
ValueError: If source column is missing or target column already exists.
"""
if source_column not in table.column_names:
raise ValueError(
f"{column_type} column '{source_column}' not found in table"
)
# No rename needed if source and target are the same
if source_column == target_column:
return table
if target_column in table.column_names:
raise ValueError(
f"Table already has a '{target_column}' column; cannot also rename "
f"'{source_column}' to '{target_column}'. Please disambiguate your schema."
)
return BlockAccessor.for_block(table).rename_columns(
{source_column: target_column}
)
def _prepare_arrow_table(self, table: pa.Table) -> pa.Table:
"""
Prepare Arrow table for Turbopuffer write.
1. Rename ID column to "id" if needed
2. Rename vector column to "vector" if needed
3. Filter out rows with null IDs
"""
table = self._rename_column_if_needed(table, self.id_column, _ID_COLUMN, "ID")
table = self._rename_column_if_needed(
table, self.vector_column, _VECTOR_COLUMN, "Vector"
)
# Filter out rows with null IDs
if _ID_COLUMN in table.column_names:
table = table.filter(pc.is_valid(table.column(_ID_COLUMN)))
return table
def _write_multi_namespace(
self, client: "turbopuffer.Turbopuffer", table: pa.Table
) -> None:
"""Group rows by ``namespace_column`` and write each group to its namespace.
Uses :meth:`BlockAccessor._iter_groups_sorted` for efficient
zero-copy slicing by group.
"""
group_col_name = self.namespace_column
if group_col_name not in table.column_names:
raise ValueError(
f"Namespace column '{group_col_name}' not found in table. "
f"Available columns: {table.column_names}"
)
# Reject null namespace values early -- we cannot route them.
ns_col = table.column(group_col_name)
if pc.any(pc.is_null(ns_col)).as_py():
raise ValueError(
f"Namespace column '{group_col_name}' contains null values; "
"fill or drop them before writing with namespace_column."
)
# Sort by the namespace column so _iter_groups_sorted can yield
# contiguous zero-copy slices for each unique namespace value.
sort_key = SortKey(key=group_col_name, descending=False)
block_accessor = BlockAccessor.for_block(
BlockAccessor.for_block(table).sort(sort_key)
)
for (namespace_name,), group_table in block_accessor._iter_groups_sorted(
sort_key
):
# Drop the namespace column -- it is routing metadata, not data.
group_table = group_table.drop(group_col_name)
# Prepare (rename id/vector columns, filter null IDs).
group_table = self._prepare_arrow_table(group_table)
if group_table.num_rows == 0:
continue
ns = client.namespace(namespace_name)
for batch in group_table.to_batches(max_chunksize=self.batch_size):
self._write_batch_with_retry(ns, batch, namespace_name)
def _transform_to_turbopuffer_format(
self, table: Union[pa.Table, pa.RecordBatch]
) -> dict:
if _ID_COLUMN not in table.column_names:
raise ValueError(f"Table must have '{_ID_COLUMN}' column")
# Cast 16-byte binary ID column to native UUID type for Turbopuffer performance.
# Native UUIDs are 16 bytes vs 36 bytes for string-encoded UUIDs.
# See: https://turbopuffer.com/docs/performance
id_col = table.column(_ID_COLUMN)
if pa.types.is_fixed_size_binary(id_col.type) and id_col.type.byte_width == 16:
# Cast fixed_size_binary(16) to uuid type
uuid_col = id_col.cast(pa.uuid())
table = table.set_column(
table.schema.get_field_index(_ID_COLUMN), _ID_COLUMN, uuid_col
)
# to_pydict() on UuidArray automatically returns uuid.UUID objects
return table.to_pydict()
def _write_batch_with_retry(
self,
namespace: "turbopuffer.Namespace",
batch: pa.Table,
namespace_name: Optional[str] = None,
):
"""Write a single batch with exponential backoff retry.
Args:
namespace: The Turbopuffer namespace object to write to.
batch: Arrow table or record-batch to write.
namespace_name: Human-readable namespace name for log messages.
Falls back to ``self.namespace`` when not provided.
"""
ns_label = namespace_name or self.namespace
try:
batch_data = self._transform_to_turbopuffer_format(batch)
call_with_retry(
lambda: namespace.write(
upsert_columns=batch_data,
schema=self.schema,
distance_metric=self.distance_metric,
),
description=f"write batch to namespace '{ns_label}'",
max_attempts=5,
max_backoff_s=32,
)
except Exception as e:
logger.error(f"Write failed for namespace '{ns_label}' after retries: {e}")
raise
@@ -0,0 +1,70 @@
import logging
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.util import _check_import
from ray.data.datasource.file_based_datasource import FileBasedDatasource
if TYPE_CHECKING:
import pyarrow
logger = logging.getLogger(__name__)
class VideoDatasource(FileBasedDatasource):
_FILE_EXTENSIONS = [
"mp4",
"mkv",
"mov",
"avi",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
"mpg",
"ts",
"ogv",
"rm",
"rmvb",
"vob",
"asf",
"f4v",
"m2ts",
"mts",
"divx",
"xvid",
"mxf",
]
def __init__(
self,
paths: Union[str, List[str]],
include_timestamps=False,
decord_load_args: Optional[Dict[str, Any]] = None,
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
_check_import(self, module="decord", package="decord")
self.include_timestamps = include_timestamps
if decord_load_args is None:
self.decord_load_args = {}
else:
self.decord_load_args = decord_load_args
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
from decord import VideoReader
reader = VideoReader(f, **self.decord_load_args)
for frame_index, frame in enumerate(reader):
item = {"frame": frame.asnumpy(), "frame_index": frame_index}
if self.include_timestamps is True:
item["frame_timestamp"] = reader.get_frame_timestamp(frame_index)
builder = DelegatingBlockBuilder()
builder.add(item)
yield builder.build()
@@ -0,0 +1,53 @@
import io
import tarfile
import time
import uuid
from typing import Optional, Union
import pyarrow
from ray.data._internal.datasource.webdataset_datasource import (
_apply_list,
_default_encoder,
_make_iterable,
)
from ray.data.block import BlockAccessor
from ray.data.datasource.file_datasink import BlockBasedFileDatasink
class WebDatasetDatasink(BlockBasedFileDatasink):
def __init__(
self,
path: str,
encoder: Optional[Union[bool, str, callable, list]] = True,
*,
file_format: str = "tar",
**file_datasink_kwargs,
):
super().__init__(path, file_format="tar", **file_datasink_kwargs)
self.encoder = encoder
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
stream = tarfile.open(fileobj=file, mode="w|")
samples = _make_iterable(block)
for sample in samples:
if not isinstance(sample, dict):
sample = sample.as_pydict()
if self.encoder is not None:
sample = _apply_list(self.encoder, sample, default=_default_encoder)
if "__key__" not in sample:
sample["__key__"] = uuid.uuid4().hex
key = sample["__key__"]
for k, v in sample.items():
if v is None or k.startswith("__"):
continue
assert isinstance(v, bytes) or isinstance(v, str)
if not isinstance(v, bytes):
v = v.encode("utf-8")
ti = tarfile.TarInfo(f"{key}.{k}")
ti.size = len(v)
ti.mtime = time.time()
ti.mode, ti.uname, ti.gname = 0o644, "data", "data"
stream.addfile(ti, io.BytesIO(v))
stream.close()
@@ -0,0 +1,442 @@
# Copyright NVIDIA Corporation 2023
# SPDX-License-Identifier: Apache-2.0
import fnmatch
import io
import json
import re
import tarfile
from functools import partial
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
from ray._common.utils import env_bool
from ray.data._internal.util import iterate_with_retry
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.file_based_datasource import FileBasedDatasource
ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR = (
"RAY_DATA_WEBDATASET_ALLOW_UNSAFE_DESERIALIZATION"
)
if TYPE_CHECKING:
import pyarrow
def _base_plus_ext(path: str):
"""Split off all file extensions.
Returns base, allext.
Args:
path: path with extensions
Returns:
str: path with all extensions removed
"""
match = re.match(r"^((?:.*/|)[^.]+)[.]([^/]*)$", path)
if not match:
return None, None
return match.group(1), match.group(2)
def _valid_sample(sample: Dict[str, Any]):
"""Check whether a sample is valid.
Args:
sample: sample to be checked
Returns:
``True`` if the sample is a non-empty dict without the ``__bad__`` flag.
"""
return (
sample is not None
and isinstance(sample, dict)
and len(list(sample.keys())) > 0
and not sample.get("__bad__", False)
)
def _apply_list(
f: Union[Callable, List[Callable]], sample: Dict[str, Any], default: Callable = None
):
"""Apply a list of functions to a sample.
Args:
f: function or list of functions
sample: sample to be modified
default: default function to be applied to all keys.
Defaults to None.
Returns:
modified sample
"""
if f is None:
return sample
if not isinstance(f, list):
f = [f]
for g in f:
if default is not None and not callable(g):
g = partial(default, format=g)
sample = g(sample)
return sample
def _check_suffix(suffix: str, suffixes: Union[list, callable]):
"""Check whether a suffix is valid.
Suffixes can be either None (=accept everything), a callable,
or a list of patterns. If the pattern contains */? it is treated
as a glob pattern, otherwise it is treated as a literal.
Args:
suffix: suffix to be checked
suffixes: list of valid suffixes
Returns:
``True`` if the suffix matches the allowed patterns.
"""
if suffixes is None:
return True
if callable(suffixes):
return suffixes(suffix)
for pattern in suffixes:
if "*" in pattern or "?" in pattern:
if fnmatch.fnmatch("." + suffix, pattern):
return True
elif suffix == pattern or "." + suffix == pattern:
return True
return False
def _tar_file_iterator(
fileobj: Any,
fileselect: Optional[Union[bool, callable, list]] = None,
filerename: Optional[Union[bool, callable, list]] = None,
verbose_open: bool = False,
meta: dict = None,
) -> Iterator[Dict[str, Any]]:
"""Iterate over tar file, yielding filename, content pairs for the given tar stream.
Args:
fileobj: file object
fileselect: patterns or function selecting
files to be selected
filerename: patterns or function used to rename selected files
before yielding them.
verbose_open: if ``True``, print progress messages when starting
and finishing iteration over the tar stream.
meta: metadata to be added to each sample
Yields:
Dict[str, Any]: Dictionaries with ``fname`` and ``data`` keys for each
selected file in the tar stream.
"""
meta = meta or {}
stream = tarfile.open(fileobj=fileobj, mode="r|*")
if verbose_open:
print(f"start {meta}")
for tarinfo in stream:
fname = tarinfo.name
if not tarinfo.isreg() or fname is None:
continue
data = stream.extractfile(tarinfo).read()
fname = _apply_list(filerename, fname)
assert isinstance(fname, str)
if not _check_suffix(fname, fileselect):
continue
result = dict(fname=fname, data=data)
yield result
if verbose_open:
print(f"done {meta}")
def _group_by_keys(
data: List[Dict[str, Any]],
keys: callable = _base_plus_ext,
suffixes: Optional[Union[list, callable]] = None,
meta: dict = None,
) -> Iterator[Dict[str, Any]]:
"""Return function over iterator that groups key, value pairs into samples.
Args:
data: iterator over key, value pairs
keys: function that returns key, suffix for a given key
suffixes: list of suffixes to be included in the sample
meta: metadata to be added to each sample
Yields:
Dict[str, Any]: Grouped samples, where files sharing the same key prefix are
combined into a single dictionary.
"""
meta = meta or {}
current_sample = None
for filesample in data:
assert isinstance(filesample, dict)
fname, value = filesample["fname"], filesample["data"]
prefix, suffix = keys(fname)
if prefix is None:
continue
if current_sample is None or prefix != current_sample["__key__"]:
if _valid_sample(current_sample):
current_sample.update(meta)
yield current_sample
current_sample = dict(__key__=prefix)
if "__url__" in filesample:
current_sample["__url__"] = filesample["__url__"]
if suffix in current_sample:
raise ValueError(
f"{fname}: duplicate file name in tar file "
+ f"{suffix} {current_sample.keys()}, tar is {meta['__url__']}"
)
if suffixes is None or _check_suffix(suffix, suffixes):
current_sample[suffix] = value
if _valid_sample(current_sample):
current_sample.update(meta)
yield current_sample
def _default_decoder(
sample: Dict[str, Any],
format: Optional[Union[bool, str]] = True,
allow_unsafe: bool = False,
):
"""A default decoder for webdataset.
This handles common file extensions: .txt, .cls, .cls2,
.jpg, .png, .json, .npy, .mp, .pt, .pth, .pickle, .pkl.
These are the most common extensions used in webdataset.
For other extensions, users can provide their own decoder.
Args:
sample: sample, modified in place
format: optional image format hint (e.g. ``"PIL"`` to return PIL
images instead of numpy arrays).
allow_unsafe: if True, allow pickle/torch deserialization
Returns:
The sample with values decoded according to their key extension.
"""
sample = dict(sample)
for key, value in sample.items():
extension = key.split(".")[-1]
if key.startswith("__"):
continue
elif extension in ["txt", "text"]:
sample[key] = value.decode("utf-8")
elif extension in ["cls", "cls2"]:
sample[key] = int(value.decode("utf-8"))
elif extension in ["jpg", "png", "ppm", "pgm", "pbm", "pnm"]:
import numpy as np
import PIL.Image
if format == "PIL":
sample[key] = PIL.Image.open(io.BytesIO(value))
else:
sample[key] = np.asarray(PIL.Image.open(io.BytesIO(value)))
elif extension == "json":
sample[key] = json.loads(value)
elif extension == "npy":
import numpy as np
sample[key] = np.load(io.BytesIO(value))
elif extension == "mp":
import msgpack
sample[key] = msgpack.unpackb(value, raw=False)
elif extension in ["pt", "pth"]:
if not allow_unsafe:
raise ValueError(
f"Refusing to load .{extension} member {key!r} from "
f"WebDataset with weights_only=False (arbitrary code "
f"execution risk). Provide a custom decoder or set "
f"{ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR}=1 "
f"for trusted sources."
)
import torch
sample[key] = torch.load(io.BytesIO(value), weights_only=False)
elif extension in ["pickle", "pkl"]:
if not allow_unsafe:
raise ValueError(
f"Refusing to unpickle WebDataset member {key!r} "
f"(arbitrary code execution risk). Provide a custom "
f"decoder or set "
f"{ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR}=1 "
f"for trusted sources."
)
import pickle
sample[key] = pickle.loads(value)
return sample
extension_to_format = {"jpg": "jpeg"}
def _default_encoder(sample: Dict[str, Any], format: Optional[Union[str, bool]] = True):
"""A default encoder for webdataset.
This handles common file extensions: .txt, .cls, .cls2, .jpg,
.png, .json, .npy, .mp, .pt, .pth, .pickle, .pkl
These are the most common extensions used in webdataset.
For other extensions, users can provide their own encoder.
Args:
sample: sample to encode.
format: optional image format hint forwarded to the underlying
image encoder.
Returns:
The sample with values encoded according to their key extension.
"""
sample = dict(sample)
for key, value in sample.items():
extension = key.split(".")[-1]
if key.startswith("__"):
continue
elif extension in ["txt"]:
sample[key] = value.encode("utf-8")
elif extension in ["cls", "cls2"]:
sample[key] = str(value).encode("utf-8")
elif extension in ["jpg", "jpeg", "png", "ppm", "pgm", "pbm", "pnm"]:
import numpy as np
import PIL.Image
if isinstance(value, np.ndarray):
value = PIL.Image.fromarray(value)
assert isinstance(value, PIL.Image.Image)
stream = io.BytesIO()
value.save(
stream, format=extension_to_format.get(extension.lower(), extension)
)
sample[key] = stream.getvalue()
elif extension == "json":
sample[key] = json.dumps(value).encode("utf-8")
elif extension == "npy":
import numpy as np
stream = io.BytesIO()
np.save(stream, value)
sample[key] = stream.getvalue()
elif extension == "mp":
import msgpack
sample[key] = msgpack.dumps(value)
elif extension in ["pt", "pth"]:
import torch
stream = io.BytesIO()
torch.save(value, stream)
sample[key] = stream.getvalue()
elif extension in ["pickle", "pkl"]:
import pickle
stream = io.BytesIO()
pickle.dump(value, stream)
sample[key] = stream.getvalue()
return sample
def _make_iterable(block: BlockAccessor):
"""Make a block iterable.
This is a placeholder for dealing with more complex blocks.
Args:
block: Ray Dataset block
Returns:
Iterable[Dict[str,Any]]: Iterable of samples
"""
return block.iter_rows(public_row_format=False)
class WebDatasetDatasource(FileBasedDatasource):
"""A Datasource for WebDataset datasets (tar format with naming conventions)."""
_FILE_EXTENSIONS = ["tar"]
def __init__(
self,
paths: Union[str, List[str]],
decoder: Optional[Union[bool, str, callable, list]] = True,
fileselect: Optional[Union[bool, callable, list]] = None,
filerename: Optional[Union[bool, callable, list]] = None,
suffixes: Optional[Union[bool, callable, list]] = None,
verbose_open: bool = False,
expand_json: bool = False,
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
self.decoder = decoder
self.fileselect = fileselect
self.filerename = filerename
self.suffixes = suffixes
self.verbose_open = verbose_open
self.expand_json = expand_json
self._allow_unsafe_deserialization = env_bool(
ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR, False
)
def _read_stream(self, stream: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
"""Read and decode samples from a stream.
Note that fileselect selects files during reading, while suffixes
selects files during the grouping step.
Args:
stream: File descriptor to read from.
path: Path to the data.
Yields:
Block: Single-row blocks (one per WebDataset sample).
"""
import pandas as pd
def get_tar_file_iterator():
return _tar_file_iterator(
stream,
fileselect=self.fileselect,
filerename=self.filerename,
verbose_open=self.verbose_open,
)
# S3 can raise transient errors during iteration
files = iterate_with_retry(
get_tar_file_iterator,
"iterate tar file",
match=self._data_context.retried_io_errors,
)
samples = _group_by_keys(files, meta=dict(__url__=path), suffixes=self.suffixes)
default_decoder = partial(
_default_decoder, allow_unsafe=self._allow_unsafe_deserialization
)
for sample in samples:
if self.decoder is not None:
sample = _apply_list(self.decoder, sample, default=default_decoder)
if self.expand_json:
if isinstance(sample["json"], bytes):
parsed_json = json.loads(sample["json"].decode("utf-8"))
elif isinstance(sample["json"], str):
parsed_json = json.loads(sample["json"])
elif isinstance(sample["json"], dict):
parsed_json = sample["json"]
else:
raise TypeError(
f"Unsupported data type" f" {type(sample['json'])} for sample"
)
for k, v in parsed_json.items():
if k not in sample:
sample[k] = []
sample[k].append(v)
yield pd.DataFrame(
{
k: v if isinstance(v, list) and len(v) == 1 else [v]
for k, v in sample.items()
}
)
@@ -0,0 +1,685 @@
from __future__ import annotations
import logging
import math
import numbers
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional
import numpy as np
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.util import (
_check_import,
_is_local_scheme,
iterate_with_retry,
)
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from fsspec.spec import AbstractFileSystem
from pyarrow import fs as pyarrow_fs
from zarr import Array as ZarrArray
from zarr.hierarchy import Group as ZarrGroup
from ray.data.context import DataContext
ZarrRoot = ZarrGroup | ZarrArray
@dataclass(frozen=True)
class ZarrArrayMeta:
"""``shape``/``chunks``/``dtype`` for a single Zarr v2 array."""
shape: tuple[int, ...]
chunks: tuple[int, ...]
dtype: str
@classmethod
def from_zarr_array(cls, arr: "ZarrArray") -> ZarrArrayMeta:
return cls(
shape=tuple(int(s) for s in arr.shape),
chunks=tuple(int(c) for c in arr.chunks),
dtype=str(arr.dtype),
)
@property
def rank(self) -> int:
return len(self.shape)
@property
def itemsize(self) -> int:
return np.dtype(self.dtype).itemsize
def effective_chunks(
self,
array_name: str,
user_chunk_shape: tuple[int, ...] | dict[str, tuple[int, ...]] | None,
) -> tuple[int, ...]:
"""Resolve the user's ``chunk_shapes`` override(s) against this array's chunks.
A single sequence overrides the leading axes (trailing axes keep the
native chunks), so one ``chunk_shapes=[16]`` applies across arrays of
different ranks. A dict maps array path → that array's override prefix;
arrays absent from it keep native chunks. ``None`` keeps native chunks;
an override longer than the array's rank raises ``ValueError``.
"""
if user_chunk_shape is None:
return self.chunks
if isinstance(user_chunk_shape, dict):
user_chunk_shape = user_chunk_shape.get(array_name)
if user_chunk_shape is None:
return self.chunks
if len(user_chunk_shape) > self.rank:
raise ValueError(
f"chunk_shapes override for array {array_name!r} has "
f"{len(user_chunk_shape)} axes but array of shape "
f"{self.shape!r} has rank {self.rank}. Each chunk_shapes "
f"override may not be longer than its target array's rank."
)
return user_chunk_shape + self.chunks[len(user_chunk_shape) :]
def grid_shape(self, chunks: tuple[int, ...]) -> tuple[int, ...]:
"""Number of chunks along each axis under the given chunk shape."""
return tuple(math.ceil(s / c) for s, c in zip(self.shape, chunks))
def chunk_slices(
self, chunk_index: tuple[int, ...], chunks: tuple[int, ...]
) -> tuple[tuple[int, int], ...]:
"""Per-axis ``(start, stop)`` for ``array[chunk_index]`` under ``chunks``.
Trailing-edge chunks are clamped to ``shape[i]``, so they may be
shorter than ``chunks[i]``. No padding is applied.
"""
return tuple(
(i * c, min((i + 1) * c, s))
for i, c, s in zip(chunk_index, chunks, self.shape)
)
# ---------------------------------------------------------------------------
# Chunk reading
# ---------------------------------------------------------------------------
def _read_chunk(
root: ZarrRoot,
array_name: str,
chunk_slices: tuple[tuple[int, int], ...],
retry_match: Optional[List[str]] = None,
) -> np.ndarray:
"""Read ``array[chunk_slices]`` as an ndarray.
The underlying filesystem's own retry policy still applies underneath.
"""
def _read() -> np.ndarray:
indexer = tuple(slice(s, e) for s, e in chunk_slices)
arr = root if array_name == "" else root[array_name]
return np.asarray(arr[indexer])
if not retry_match:
return _read()
# TODO(Artur): This would be more elegant with a general retry helper for non-iterables.
return next(
iterate_with_retry(
lambda: [_read()], description="read a Zarr chunk", match=retry_match
)
)
@dataclass(frozen=True)
class _ChunkRange:
"""A contiguous slice ``[flat_start, flat_stop)`` of an array's chunk grid.
The flat indices address the row-major flattening of the chunk grid; the
read fn unravels each to an N-D ``chunk_index`` lazily on the worker. Keeping
a range (not a materialized per-chunk list) makes read-task planning
O(parallelism) rather than O(total chunks) -- important for stores with very
many chunks.
"""
array_name: str
meta: ZarrArrayMeta
chunks: tuple[int, ...]
grid: tuple[int, ...]
flat_start: int
flat_stop: int
@dataclass(frozen=True)
class _AlignedChunkDescriptor:
"""One wide row: a global axis-0 range ``[t_start, t_stop)`` across the
aligned arrays. With ``overlap > 0`` the row's data extends to
``t_stop_data = min(t_stop + overlap, shape[0])`` (lookahead so windows
starting in this row reach their tail without crossing a row boundary).
"""
chunk_index: int
t_start: int
t_stop: int
t_stop_data: int
def _create_read_fn(
chunk_range: _ChunkRange,
root: ZarrRoot,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> Callable[[], Iterable[Block]]:
"""Build a callable that materializes one block for a chunk-grid range.
This is the case where arrays are not aligned. Chunks are enumerated lazily
(on the worker) from ``chunk_range``. ``per_task_row_limit`` caps how many
chunks this task reads so a downstream ``limit`` reads only what it needs
(``None`` reads the whole range).
"""
cr = chunk_range
stop = cr.flat_stop
if per_task_row_limit is not None:
stop = min(stop, cr.flat_start + per_task_row_limit)
def read_fn() -> Iterable[Block]:
builder = DelegatingBlockBuilder()
for flat_index in range(cr.flat_start, stop):
chunk_index = tuple(int(i) for i in np.unravel_index(flat_index, cr.grid))
chunk_slices = cr.meta.chunk_slices(chunk_index, cr.chunks)
builder.add(
{
"array": cr.array_name,
"chunk_index": chunk_index,
"chunk_slices": chunk_slices,
"chunk": _read_chunk(
root, cr.array_name, chunk_slices, retry_match
),
}
)
yield builder.build()
return read_fn
def _create_aligned_read_fn(
batch: list[_AlignedChunkDescriptor],
aligned_array_names: list[str],
root: ZarrRoot,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> Callable[[], Iterable[Block]]:
"""Build a callable for aligned (wide-row) reads.
Each output row carries ``t_start``, ``t_stop``, and one column per
aligned array holding that array's ``[t_start:t_stop, ...]`` slice at
its natural shape (edge rows may be shorter). All arrays in one row
share the same axis-0 range.
This is the case where arrays are aligned on axis 0. ``per_task_row_limit``
caps how many rows this task reads (``None`` reads the whole batch).
"""
batch = batch[:per_task_row_limit]
def read_fn() -> Iterable[Block]:
builder = DelegatingBlockBuilder()
for d in batch:
row: dict[str, Any] = {"t_start": d.t_start, "t_stop": d.t_stop}
for name in aligned_array_names:
row[name] = _read_chunk(
root, name, ((d.t_start, d.t_stop_data),), retry_match
)
builder.add(row)
yield builder.build()
return read_fn
def _is_positive_int(x) -> bool:
"""True for a positive integer, including NumPy integers; False for bool."""
return not isinstance(x, bool) and isinstance(x, numbers.Integral) and int(x) > 0
def _validate_chunk_shapes_dict(chunk_shapes: dict) -> dict[str, tuple[int, ...]]:
"""Normalize chunk_shapes keys to store paths and validate their values."""
from zarr.util import normalize_storage_path
normalized: dict[str, tuple[int, ...]] = {}
for k, v in chunk_shapes.items():
if (
not isinstance(v, (tuple, list))
or not v
or not all(_is_positive_int(x) for x in v)
):
raise ValueError(
f"chunk_shapes[{k!r}] must be a non-empty sequence of positive "
f"integers (list or tuple), got {v!r}"
)
normalized[normalize_storage_path(k)] = tuple(int(x) for x in v)
return normalized
# ---------------------------------------------------------------------------
# Datasource
# ---------------------------------------------------------------------------
class ZarrV2Datasource(Datasource):
"""Reads one or more Zarr v2 arrays into a Ray Data ``Dataset``.
Emits long-form rows (one per chunk per array) or, with
``align_axis_0=True``, wide rows (one per axis-0 chunk, one column per
array). See :func:`ray.data.read_zarr` for the row schemas and full API.
"""
def __init__(
self,
path: str,
filesystem: pyarrow_fs.FileSystem | AbstractFileSystem | None = None,
chunk_shapes: dict[str, list] | list | None = None,
array_paths: list[str] | None = None,
allow_full_metadata_scan: bool = False,
align_axis_0: bool = False,
overlap: int = 0,
) -> None:
super().__init__()
_check_import(self, module="zarr", package="zarr")
import zarr
_check_import(self, module="fsspec", package="fsspec")
from fsspec.spec import AbstractFileSystem
if int(zarr.__version__.split(".")[0]) >= 3:
raise ImportError(
f"read_zarr supports zarr-python 2.x (Zarr v2 stores), but found "
f"zarr=={zarr.__version__}. Install a compatible version with "
f"`pip install 'zarr<3'`."
)
self.allow_full_metadata_scan = allow_full_metadata_scan
self.paths = [str(path)]
# ``local://`` stores live on the driver's local disk, so pin reads to
# the driver node (workers on other nodes can't see those files).
self._supports_distributed_reads = not _is_local_scheme(self.paths)
# Resolve filesystem + store path. The order of precedence:
# 1. Explicit ``filesystem=`` always wins.
# 2. ``.zip`` URL/path: auto-wrap with fsspec's ZipFileSystem.
# 3. Otherwise delegate to Ray Data's standard URL to filesystem
# helper (the same one every other ``read_*`` API uses).
# "store path" is the path to the Zarr store, relative to the filesystem root.
# It is used to construct the Zarr root object.
if filesystem is None and self.paths[0].endswith(".zip"):
import fsspec
self._fs = fsspec.filesystem("zip", fo=self.paths[0])
self._store_path = ""
elif filesystem is None:
from fsspec.implementations.arrow import ArrowFSWrapper
from ray.data.datasource.path_util import (
_resolve_paths_and_filesystem,
)
resolved_paths, pa_fs = _resolve_paths_and_filesystem([self.paths[0]])
self._fs = ArrowFSWrapper(pa_fs)
self._store_path = resolved_paths[0].rstrip("/")
else:
from pyarrow.fs import FileSystem
if isinstance(filesystem, AbstractFileSystem):
self._fs = filesystem
elif isinstance(filesystem, FileSystem):
from fsspec.implementations.arrow import ArrowFSWrapper
self._fs = ArrowFSWrapper(filesystem)
else:
raise TypeError(
f"filesystem must be pyarrow.fs.FileSystem or "
f"fsspec.spec.AbstractFileSystem, got "
f"{type(filesystem).__name__}"
)
from fsspec.implementations.zip import ZipFileSystem
if isinstance(self._fs, ZipFileSystem) and self.paths[0].endswith(".zip"):
# An explicit archive filesystem: the store is the archive root,
# not a ``.zip``-named entry inside it.
self._store_path = ""
else:
from fsspec.core import split_protocol
_, store_path = split_protocol(self.paths[0])
self._store_path = store_path.rstrip("/")
if chunk_shapes is not None and not isinstance(
chunk_shapes, (tuple, list, dict)
):
raise ValueError(
f"chunk_shapes must be a non-empty sequence of positive "
f"integers (list or tuple), or a dict, got {chunk_shapes!r}"
)
self.chunk_shapes: tuple[int, ...] | dict[str, tuple[int, ...]] | None = None
if chunk_shapes is not None:
if isinstance(chunk_shapes, dict):
self.chunk_shapes = _validate_chunk_shapes_dict(chunk_shapes)
else:
if not chunk_shapes or not all(
_is_positive_int(x) for x in chunk_shapes
):
raise ValueError(
"chunk_shapes must be a non-empty sequence of positive integers "
f"(list or tuple), got {chunk_shapes!r}"
)
self.chunk_shapes = tuple(int(x) for x in chunk_shapes)
# Open the store with zarr (consolidated metadata when available).
# Detect consolidation by *trying* ``open_consolidated``.
store = self._fs.get_mapper(self._store_path)
try:
self.root = zarr.open_consolidated(store, mode="r")
self._consolidated = True
except KeyError:
self.root = zarr.open(store, mode="r")
self._consolidated = False
self._metadata_by_path = self._load_metadata(array_paths)
if not self._metadata_by_path:
raise ValueError(
f"No arrays discovered in Zarr store at {self.paths[0]!r}."
)
# Reject per-array overrides that do not correspond to any selected
# array in this read.
if isinstance(self.chunk_shapes, dict):
unknown_chunk_shape_keys = sorted(
set(self.chunk_shapes) - set(self._metadata_by_path)
)
if unknown_chunk_shape_keys:
raise ValueError(
f"Unknown array path(s) in chunk_shapes: {unknown_chunk_shape_keys}"
)
if not align_axis_0:
self._aligned_array_names = None
else:
scalar_arrays = sorted(
name for name, meta in self._metadata_by_path.items() if not meta.shape
)
if scalar_arrays:
raise ValueError(
f"align_axis_0=True requires every selected array to have "
f"at least one axis, but these are 0-D (scalar): "
f"{scalar_arrays}. Drop them with array_paths=[...]."
)
shape0_by_array = {
name: meta.shape[0] for name, meta in self._metadata_by_path.items()
}
if len(set(shape0_by_array.values())) > 1:
raise ValueError(
f"All selected arrays must share shape[0] when "
f"align_axis_0=True. Got: {shape0_by_array}. Pass a "
f"shape-compatible subset via array_paths=[...]."
)
self._aligned_array_names = list(self._metadata_by_path.keys())
# Validate overlap. Only meaningful when arrays are co-iterated as
# wide rows, since the trailing lookahead is exposed via the
# per-array column being longer than ``t_stop - t_start``.
if not isinstance(overlap, int) or overlap < 0:
raise ValueError(f"overlap must be a non-negative integer, got {overlap!r}")
if overlap and self._aligned_array_names is None:
raise ValueError(
"overlap requires align_axis_0=True. In the default long-form "
"(chunk-per-row) mode, there's no wide row to extend forward — "
"the ``chunk_slices`` column on each chunk row already exposes "
"the global axis-0 range."
)
self.overlap = overlap
# Resolve per-array chunk geometry. ``effective_chunks`` raises a
# ``ValueError`` if a shared ``chunk_shapes`` prefix or any per-array
# ``chunk_shapes`` override is longer than the target array's rank —
# so this loop is also where rank validation happens.
self._array_chunks: dict[str, tuple[int, ...]] = {}
self._array_grids: dict[str, tuple[int, ...]] = {}
for name, meta in self._metadata_by_path.items():
chunks = meta.effective_chunks(name, self.chunk_shapes)
self._array_chunks[name] = chunks
self._array_grids[name] = meta.grid_shape(chunks)
# If aligned, all listed arrays must share the same axis-0 chunk size
# so each wide row corresponds to one axis-0 step across every array.
if self._aligned_array_names is not None:
axis_0_chunks = {
name: self._array_chunks[name][0] for name in self._aligned_array_names
}
unique = set(axis_0_chunks.values())
if len(unique) > 1:
raise ValueError(
f"Aligned arrays must share the same axis-0 chunk size. "
f"Got: {axis_0_chunks}. Pass chunk_shapes=[N] (or a "
f"per-array chunk_shapes dict that resolves all aligned "
f"arrays to the same axis-0 prefix) to re-tile them."
)
@property
def supports_distributed_reads(self) -> bool:
return self._supports_distributed_reads
def estimate_inmemory_data_size(self) -> Optional[int]:
"""Total bytes = sum over selected arrays of ``prod(shape) * itemsize``."""
return sum(
math.prod(meta.shape) * meta.itemsize
for meta in self._metadata_by_path.values()
)
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
"""Enumerate every chunk and wrap it (or batches of chunks) in ReadTasks."""
from ray.data.context import DataContext
retry_match = (data_context or DataContext.get_current()).retried_io_errors
if self._aligned_array_names is not None:
return self._get_aligned_read_tasks(
parallelism, per_task_row_limit, retry_match
)
return self._get_long_form_read_tasks(
parallelism, per_task_row_limit, retry_match
)
def _get_long_form_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> List[ReadTask]:
read_tasks: List[ReadTask] = []
for name, meta in self._metadata_by_path.items():
chunks = self._array_chunks[name]
grid = self._array_grids[name]
n_chunks = math.prod(grid)
if n_chunks == 0:
continue
# Split the chunk grid into contiguous flat-index ranges. This is
# O(n_tasks), not O(n_chunks): we never materialize a per-chunk list
# on the driver -- the read fn unravels chunks lazily on the worker.
n_tasks = max(1, min(parallelism, n_chunks))
batch_size = math.ceil(n_chunks / n_tasks)
for flat_start in range(0, n_chunks, batch_size):
flat_stop = min(flat_start + batch_size, n_chunks)
chunk_range = _ChunkRange(
name, meta, chunks, grid, flat_start, flat_stop
)
read_tasks.append(
ReadTask(
_create_read_fn(
chunk_range, self.root, per_task_row_limit, retry_match
),
BlockMetadata(
num_rows=flat_stop - flat_start,
size_bytes=self._estimate_range_mem_size(chunk_range),
input_files=(self.paths[0],),
exec_stats=None,
),
per_task_row_limit=per_task_row_limit,
)
)
return read_tasks
def _estimate_range_mem_size(self, chunk_range: _ChunkRange) -> int:
"""Upper-bound in-memory bytes for a chunk-grid range.
Assumes a full-size chunk per index; trailing-edge chunks are smaller,
so this slightly over-estimates. O(1) -- it does not enumerate the range.
"""
n = chunk_range.flat_stop - chunk_range.flat_start
return n * math.prod(chunk_range.chunks) * chunk_range.meta.itemsize
def _get_aligned_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int],
retry_match: Optional[List[str]],
) -> List[ReadTask]:
"""Aligned read tasks. See :meth:`get_read_tasks` for semantics."""
assert self._aligned_array_names is not None
# All aligned arrays share the same axis-0 chunk size (validated in
# ``__init__``) and the same shape[0]. Read the geometry off the first.
first_name = self._aligned_array_names[0]
axis_0_chunk = self._array_chunks[first_name][0]
shape0 = self._metadata_by_path[first_name].shape[0]
descriptors = [
_AlignedChunkDescriptor(
chunk_index=i,
t_start=i * axis_0_chunk,
t_stop=min((i + 1) * axis_0_chunk, shape0),
t_stop_data=min((i + 1) * axis_0_chunk + self.overlap, shape0),
)
for i in range(math.ceil(shape0 / axis_0_chunk))
]
if not descriptors:
return []
n_tasks = max(1, min(parallelism, len(descriptors)))
batch_size = math.ceil(len(descriptors) / n_tasks)
read_tasks: List[ReadTask] = []
for start in range(0, len(descriptors), batch_size):
batch = descriptors[start : start + batch_size]
read_tasks.append(
ReadTask(
_create_aligned_read_fn(
batch,
self._aligned_array_names,
self.root,
per_task_row_limit,
retry_match,
),
BlockMetadata(
num_rows=len(batch),
size_bytes=self._estimate_aligned_batch_mem_size(batch),
input_files=(self.paths[0],),
exec_stats=None,
),
per_task_row_limit=per_task_row_limit,
)
)
return read_tasks
def _estimate_aligned_batch_mem_size(
self, batch: list[_AlignedChunkDescriptor]
) -> int:
"""Sum bytes across all (row, aligned-array) pairs in a wide-row batch.
Accounts for the trailing overlap data each row carries: the row's
per-array slice covers ``[t_start, t_stop_data)``, not just
``[t_start, t_stop)``.
"""
assert self._aligned_array_names is not None
return sum(
(desc.t_stop_data - desc.t_start)
* (math.prod(meta.shape[1:]) if len(meta.shape) > 1 else 1)
* meta.itemsize
for desc in batch
for meta in (
self._metadata_by_path[name] for name in self._aligned_array_names
)
)
def _load_metadata(self, array_paths) -> dict[str, ZarrArrayMeta]:
"""Read ``shape``/``chunks``/``dtype`` for the selected arrays off ``self.root``.
zarr validated the store's metadata when it was opened, so this only
adapts the resulting ``zarr.Array`` objects. Discovery uses consolidated
metadata when present, then explicit ``array_paths``, then an optional
full scan (``allow_full_metadata_scan``). If ``array_paths`` is given,
the discovered set is filtered down to it.
"""
import zarr
from zarr.util import normalize_storage_path
root = self.root
requested = (
{normalize_storage_path(p) for p in array_paths} if array_paths else None
)
if isinstance(root, zarr.Array):
# A store that is itself an array exposes exactly one path: "" (root).
# Reject any requested path that isn't the root so a bad ``array_paths``
# fails loudly here instead of silently returning the root array.
if requested is not None and requested != {""}:
raise ValueError(
f"This Zarr store is a single root-level array (path ''), "
f"but array_paths={array_paths!r} requested other path(s). "
f"Pass array_paths=[''] or omit it."
)
return {"": ZarrArrayMeta.from_zarr_array(root)}
if not self._consolidated and not self.allow_full_metadata_scan:
if requested is None:
raise ValueError(
"No array_paths were provided and this Zarr store does not "
"contain .zmetadata. Pass array_paths=[...] or set "
"allow_full_metadata_scan=True."
)
out: dict[str, ZarrArrayMeta] = {}
for raw in array_paths:
name = normalize_storage_path(raw)
try:
arr = root[name]
except KeyError as e:
raise ValueError(
f"Array path {raw!r} not found in Zarr store."
) from e
if not isinstance(arr, zarr.Array):
raise ValueError(f"Array path {raw!r} is a group, not an array.")
out[name] = ZarrArrayMeta.from_zarr_array(arr)
return out
all_arrays: dict[str, ZarrArrayMeta] = {}
def _collect(name: str, obj) -> None:
if isinstance(obj, zarr.Array):
all_arrays[name] = ZarrArrayMeta.from_zarr_array(obj)
root.visititems(_collect)
if requested is not None:
missing = sorted(requested - all_arrays.keys())
if missing:
raise ValueError(
f"Array(s) not found: {', '.join(repr(m) for m in missing)}. "
f"Available: {', '.join(repr(a) for a in sorted(all_arrays))}"
)
all_arrays = {k: v for k, v in all_arrays.items() if k in requested}
return all_arrays
@@ -0,0 +1,3 @@
from typing import TypeVar
InputSplit = TypeVar("InputSplit")
@@ -0,0 +1,286 @@
"""File chunkers for DataSourceV2.
A ``FileChunker`` decides how a single listed file is split into one or
more parallel-read units. The indexer drives the chunker once per file
and emits one manifest row per chunk; downstream the partitioner /
reader carry the per-chunk metadata through to the read task.
"""
import abc
import logging
import math
from typing import (
TYPE_CHECKING,
Iterable,
Optional,
Tuple,
Type,
TypedDict,
TypeVar,
cast,
get_type_hints,
)
from ray.data._internal.util import MiB, infer_compression
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from pyarrow.fs import FileSystem
logger = logging.getLogger(__name__)
class ChunkMetadata(TypedDict):
"""Base interface for chunk metadata types."""
pass
_ChunkMetadataT = TypeVar("_ChunkMetadataT", bound=ChunkMetadata)
def create_chunk_metadata(cls: Type[_ChunkMetadataT], **kwargs) -> _ChunkMetadataT:
"""Create a metadata instance with validation, ensure the keys are correct."""
required_keys = list(get_type_hints(cls).keys())
missing_keys = [key for key in required_keys if key not in kwargs]
if missing_keys:
raise ValueError(f"Missing required keys: {missing_keys}")
extra_keys = [key for key in kwargs if key not in required_keys]
if extra_keys:
raise ValueError(f"Unexpected keys: {extra_keys}")
return cast(_ChunkMetadataT, kwargs)
class LineDelimitedFileChunkMetadata(ChunkMetadata):
"""Metadata for line-delimited file chunks."""
chunk_byte_start_idx: int
chunk_byte_end_idx: int
class ParquetFileChunkMetadata(ChunkMetadata):
"""Metadata for Parquet file chunks.
A chunk is an explicit, half-open range of consecutive row groups
``[row_group_start, row_group_end)`` within a single file, computed at
listing time from the file's footer. The reader slices the fragment to
exactly this range — no estimation or read-time reconciliation.
"""
row_group_start: int # inclusive
row_group_end: int # exclusive
@DeveloperAPI
class FileChunker(abc.ABC):
"""Abstract base class for chunking files into smaller pieces for parallel processing.
File chunkers determine how large files should be split into chunks that can be
processed in parallel. Different file formats may require different chunking strategies.
For example:
- Line-delimited files (JSONL, CSV) can be chunked by byte ranges
- Parquet files can be chunked by row groups
"""
# Whether ``generate_chunk_metadatas`` performs file I/O (e.g. reading a
# Parquet footer). When True, the indexer fans chunking across its thread
# pool so the per-file reads parallelize even for a single input
# directory. When False, the indexer chunks inline (no thread hand-off).
reads_file_metadata: bool = False
@abc.abstractmethod
def generate_chunk_metadatas(
self,
path: str,
file_size: int,
filesystem: Optional["FileSystem"] = None,
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
"""Generate metadata for file chunks.
Args:
path: The file path being chunked.
file_size: The total size in bytes of the file to be chunked.
filesystem: PyArrow filesystem used to read per-file metadata
(e.g. the Parquet footer). Ignored by chunkers that do not
read file metadata.
Returns:
An iterable of tuples containing (metadata, chunk_size) where metadata
describes the chunk and chunk_size is the size of the chunk in bytes.
Metadata can be None for chunks that don't require metadata
(e.g., whole file processing).
"""
...
@DeveloperAPI
class WholeFileChunker(FileChunker):
"""File chunker that treats the whole file as a single chunk.
This chunker is used when files should be processed as a single unit,
typically for smaller files or when the file format doesn't support
efficient chunking (e.g., compressed files).
Yields a single chunk with no metadata, indicating the entire file
should be processed as one unit.
"""
def generate_chunk_metadatas(
self,
path: str,
file_size: int,
filesystem: Optional["FileSystem"] = None,
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
yield None, file_size
@DeveloperAPI
class LineDelimitedFileChunker(FileChunker):
"""File chunker for line-delimited files (JSONL, CSV, TSV, etc.).
This chunker splits files into fixed-size byte chunks (default: 256 MiB)
and provides metadata about the byte ranges for each chunk. The actual
line boundaries are handled by the reader to ensure complete records.
"""
_CHUNK_BYTE_SIZE = 256 * MiB # 256 MiB
def generate_chunk_metadatas(
self,
path: str,
file_size: int,
filesystem: Optional["FileSystem"] = None,
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
compression = infer_compression(path)
if compression is not None:
yield None, file_size
else:
num_chunks = math.ceil(file_size / self._CHUNK_BYTE_SIZE)
for chunk_idx in range(num_chunks):
chunk_start = self._CHUNK_BYTE_SIZE * chunk_idx
chunk_end = min(self._CHUNK_BYTE_SIZE * (chunk_idx + 1), file_size)
chunk_size = chunk_end - chunk_start
yield (
create_chunk_metadata(
LineDelimitedFileChunkMetadata,
chunk_byte_start_idx=chunk_start,
chunk_byte_end_idx=chunk_end,
),
chunk_size,
)
@DeveloperAPI
class ParquetFileChunker(FileChunker):
"""File chunker for Parquet files.
Reads each file's footer at listing time and chunks on **true row-group
boundaries**: consecutive row groups are bundled into a chunk until the
bundle's on-disk size reaches ``target_chunk_size`` (always at least one
row group per chunk). Each chunk carries an explicit half-open row-group
range, so the reader slices to exactly those row groups with no
estimation or read-time reconciliation, and the listing stage never
produces empty read tasks.
The row group is Parquet's atomic read unit, so a chunk can never be
smaller than a single row group. With the default target (which falls
back to ``DataContext.target_min_block_size``), a file's row groups map
1:1 to chunks unless they are smaller than the target, in which case
consecutive small row groups are bundled to avoid an excessive number of
tiny chunks.
"""
# Hard fallback used only when neither an explicit target nor the
# DataContext size knobs are set.
_FALLBACK_TARGET_CHUNK_SIZE = 1 * MiB
# Footer reads are file I/O — let the indexer parallelize them.
reads_file_metadata: bool = True
def __init__(self, target_chunk_size: Optional[int] = None):
from ray.data.context import DataContext
ctx = DataContext.get_current()
# Resolve with explicit ``is not None`` checks rather than ``or`` so an
# explicit ``0`` (e.g. to force one row group per chunk) isn't treated as
# "unset" and silently overridden by a falsy-coalescing fallback.
if target_chunk_size is not None:
self._target_chunk_size = target_chunk_size
elif ctx.parquet_chunker_target_chunk_size is not None:
self._target_chunk_size = ctx.parquet_chunker_target_chunk_size
elif ctx.target_min_block_size is not None:
self._target_chunk_size = ctx.target_min_block_size
else:
self._target_chunk_size = self._FALLBACK_TARGET_CHUNK_SIZE
def generate_chunk_metadatas(
self,
path: str,
file_size: int,
filesystem: Optional["FileSystem"] = None,
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
import pyarrow.parquet as pq
try:
# Reads only the Parquet footer (file metadata), not data.
metadata = pq.read_metadata(path, filesystem=filesystem)
except Exception as e:
# Corrupt / unreadable footer (or a non-Parquet file that slipped
# through). Fall back to a single whole-file chunk so the file is
# still read rather than dropped.
logger.debug(
"Could not read Parquet footer for chunking (%s): %s; "
"falling back to a whole-file chunk.",
path,
e,
)
yield None, file_size
return
num_row_groups = metadata.num_row_groups
if num_row_groups == 0:
yield None, file_size
return
# Greedily bundle consecutive row groups until the running on-disk
# size reaches the target. Always emit at least one row group per
# chunk (the atomic read unit).
start = 0
running_size = 0
for rg_idx in range(num_row_groups):
rg_meta = metadata.row_group(rg_idx)
# On-disk (compressed) row-group size. ``RowGroupMetaData`` exposes
# only the *uncompressed* ``total_byte_size``; the on-disk size lives
# on each ``ColumnChunkMetaData``, so sum the per-column compressed
# sizes. Keeping chunk sizes in on-disk units matches the manifest's
# ``file_sizes`` and the ``×encoding_ratio`` in-memory estimator.
rg_size = sum(
rg_meta.column(c).total_compressed_size
for c in range(rg_meta.num_columns)
)
if running_size > 0 and running_size + rg_size > self._target_chunk_size:
yield (
create_chunk_metadata(
ParquetFileChunkMetadata,
row_group_start=start,
row_group_end=rg_idx,
),
running_size,
)
start = rg_idx
running_size = 0
running_size += rg_size
# Flush the final bundle.
yield (
create_chunk_metadata(
ParquetFileChunkMetadata,
row_group_start=start,
row_group_end=num_row_groups,
),
running_size,
)
@@ -0,0 +1,49 @@
"""Parquet file-level chunking helpers for DataSourceV2.
Maps planner chunk metadata (``ParquetFileChunkMetadata``) to PyArrow
``ParquetFileFragment`` subsets for parallel reads. Chunk metadata carries
an explicit half-open row-group range computed at listing time from the
file's footer, so no estimation or reconciliation is needed here.
"""
from typing import List, Tuple
import pyarrow.dataset as pds
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
ParquetFileChunkMetadata,
)
def _fragments_from_chunk_metadata(
fragment: pds.ParquetFileFragment,
chunk_metadata: ParquetFileChunkMetadata,
) -> List[Tuple[pds.ParquetFileFragment, int]]:
"""Slice ``fragment`` into per-row-group sub-fragments for the chunk's range.
The chunk carries an explicit ``[row_group_start, row_group_end)`` range.
Returns one ``(ParquetFileFragment, file_row_offset)`` pair per row group
in that range, where ``file_row_offset`` is the sum of ``num_rows`` across
all row groups that precede the sub-fragment in the underlying file.
Callers seed per-fragment hashing offsets with this value so sub-fragments
of the same file don't collide on ``(path, 0, n)``.
The range is defensively clamped to the file's actual row-group count;
since ranges are computed from the same footer the reader sees, the clamp
is a no-op in practice and never drops real row groups.
"""
start = chunk_metadata["row_group_start"]
end = chunk_metadata["row_group_end"]
metadata = fragment.metadata
total_row_groups = metadata.num_row_groups
start = min(start, total_row_groups)
end = min(end, total_row_groups)
file_row_offset = sum(metadata.row_group(i).num_rows for i in range(start))
sub_fragments: List[Tuple[pds.ParquetFileFragment, int]] = []
for row_group_index in range(start, end):
sub_fragments.append(
(fragment.subset(row_group_ids=[row_group_index]), file_row_offset)
)
file_row_offset += metadata.row_group(row_group_index).num_rows
return sub_fragments
@@ -0,0 +1,191 @@
"""
DataSourceV2 API - Unified Abstraction for Reading Data Sources
This module defines a unified, extensible API for reading data from diverse sources
in Ray Data. The API provides a common abstraction layer that enables datasources to
declaratively expose their capabilities—such as filter pushdown, projection pruning,
and parallel reads—while allowing the execution engine to leverage these capabilities
transparently.
Core Principles:
- Modularity: Separate concerns (indexing, scanning, reading)
- Expressivity: Declarative capability exposure via mixins
- Extensibility: Easy to add new datasources with custom optimizations
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
Generic,
Optional,
)
import pyarrow as pa
from ray.data._internal.datasource_v2 import InputSplit
from ray.data._internal.datasource_v2.listing.file_indexer import FileIndexer
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from pyarrow.fs import FileSystem
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
InMemorySizeEstimator,
)
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
@DeveloperAPI
class DatasourceCategory(Enum):
"""Categories of datasources with different capability profiles.
Each category has a distinct set of applicable optimizations:
- FILE_BASED: Local/cloud files (parquet, csv, json, images)
- DATABASE: SQL databases (postgres, mysql, snowflake)
- DATA_LAKE: Table formats (iceberg, delta, hudi)
- IN_MEMORY: In-process data (pandas, numpy, arrow)
- SYNTHETIC: Generated data (range, range_tensor)
- STREAMING: Unbounded sources (kafka, kinesis)
"""
FILE_BASED = "file_based"
DATABASE = "database"
DATA_LAKE = "data_lake"
IN_MEMORY = "in_memory"
SYNTHETIC = "synthetic"
STREAMING = "streaming"
@DeveloperAPI
class DataSourceV2(ABC, Generic[InputSplit]):
"""Abstract base class for V2 datasources.
DataSourceV2 is the entry point for reading data from a source. It provides:
1. File listing (for file-based sources) - via _get_file_indexer()
2. Schema inference
3. Size estimation
4. Scanner creation
Subclasses should implement the abstract methods and can optionally
override _get_file_indexer() and get_size_estimator() for file-based sources.
Example::
datasource = ParquetDatasourceV2()
indexer = datasource._get_file_indexer()
# List files with optional sampling
for manifest in indexer.list_files(paths, filesystem=fs):
schema = datasource.infer_schema(manifest)
break # Just need first manifest for schema
scanner = datasource.create_scanner(schema)
scanner = scanner.prune_columns(["col1", "col2"])
reader = scanner.create_reader()
for table in reader.read(manifest):
process(table)
"""
def __init__(self, name: str, category: DatasourceCategory):
"""Initialize the datasource.
Args:
name: Human-readable name for this datasource.
category: Category of this datasource.
"""
self._name = name
self._category = category
# File-based subclasses set this to ``False`` in their ``__init__``
# when the user-supplied paths are in the ``local://`` scheme —
# the driver node is the only one that can read those files.
# ``_read_datasource_v2`` consults the flag to decide whether to
# pin read tasks via a ``label_selector``.
self._supports_distributed_reads: bool = True
@property
def name(self) -> str:
"""Human-readable name for this datasource."""
return self._name
@property
def category(self) -> DatasourceCategory:
"""Category of this datasource."""
return self._category
@property
def supports_distributed_reads(self) -> bool:
"""Whether read tasks may run on any cluster node.
Defaults to ``True``. File-based subclasses (e.g.
:class:`ParquetDatasourceV2`) flip this to ``False`` when the
user supplies ``local://``-scheme paths so ``_read_datasource_v2``
can pin reads to the driver node via a ``ray.io/node-id``
label selector. Mirrors V1 ``Datasource.supports_distributed_reads``.
"""
return self._supports_distributed_reads
def _get_file_indexer(self) -> Optional[FileIndexer]:
"""Return FileIndexer component if applicable.
Override this for file-based datasources to provide file discovery.
Returns:
FileIndexer instance, or None for non-file-based sources.
"""
return None
def get_size_estimator(self) -> Optional[InMemorySizeEstimator]:
"""Return size estimator for this datasource.
Override this to provide format-specific size estimation.
Returns:
InMemorySizeEstimator instance, or None if not supported.
"""
return None
@abstractmethod
def infer_schema(self, sample: InputSplit) -> pa.Schema:
"""Infer schema from a sample of data.
Args:
sample: Sample data to infer schema from.
Returns:
PyArrow Schema inferred from the sample.
Raises:
ValueError: If schema cannot be inferred from the sample.
"""
...
@abstractmethod
def create_scanner(
self,
schema: pa.Schema,
filesystem: Optional["FileSystem"] = None,
**options: Any,
) -> Scanner[InputSplit]:
"""Create a Scanner for reading data.
Args:
schema: Schema for the data to read.
filesystem: Optional filesystem for file-based sources.
**options: Additional datasource-specific options.
Returns:
Configured Scanner instance.
"""
...
def resolve_partitioning(self, sample: InputSplit) -> Optional[Any]:
"""Return a partitioning descriptor derived from ``sample``, or ``None``.
Override this for file-based sources whose partition keys must be
discovered from a sample path (e.g. hive layouts where field names
are not known up front). The resolved descriptor is passed into
:meth:`create_scanner`.
"""
return None
@@ -0,0 +1,376 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Iterable, Iterator, List, Optional, Tuple, Union
from pyarrow.fs import FileSystem
from ray._common.utils import env_integer
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
ChunkMetadata,
FileChunker,
WholeFileChunker,
)
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
from ray.data._internal.datasource_v2.listing.file_pruners import FilePruner
from ray.data._internal.datasource_v2.listing.indexing_utils import (
_get_file_infos,
_get_path_contents,
)
from ray.data._internal.dynamic_work_queue import parallel_process_work_stealing
from ray.data._internal.util import make_async_gen
from ray.data.block import BlockColumn
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
logger = logging.getLogger(__name__)
class FileIndexer(ABC):
@property
@abstractmethod
def file_chunker(self) -> FileChunker:
"""The file chunker that this indexer uses."""
...
@abstractmethod
def list_files(
self,
paths: "BlockColumn",
*,
filesystem: "FileSystem",
pruners: Optional[List[FilePruner]] = None,
preserve_order: bool = False,
) -> Iterable[FileManifest]:
"""List files and their on-disk sizes for the given path.
Args:
paths: A column of paths pointing to files or directories.
filesystem: A PyArrow filesystem object.
pruners: A list of file pruners to apply.
preserve_order: Whether to preserve order in file listing.
Returns:
An iterator of `FileManifest` objects, each of which contains a file path
and the on-disk size of the file in bytes.
"""
...
@dataclass(frozen=True)
class FileInfo:
"""File information for file listing."""
path: str
size: Optional[int]
@dataclass(frozen=True)
class _TraversalWorkItem:
"""Work item for parallel directory traversal. Distinguishes seed paths
(user-provided, need resolution) from subdir paths (from filesystem listing,
use directly to avoid redundant resolution that breaks non-local
filesystems)."""
# Could be a file path or a directory path.
path: str
# True for subdirectories discovered during traversal; False for seed input paths.
is_discovered_subdir: bool = False
# Original seed-path index used to restore deterministic ordering when requested.
input_path_index: Optional[int] = None
# Top-level path the traversal started from, used to scope hidden-prefix
# exclusion to entries whose path relative to the root is hidden.
root_path: Optional[str] = None
@dataclass(frozen=True)
class OrderedFileResult:
"""File result with order information for sorting when preserve_order is True."""
input_path_index: int
# The leaf file path.
file_path: str
file_info: FileInfo
class NonSamplingFileIndexer(FileIndexer):
"""A file indexer that exhaustively lists files.
This implementation works with paths that point to files or directories,
although it's slow if you try to list lots of paths pointing to files
rather than a single directory.
"""
_DEFAULT_MAX_PATHS_PER_OUTPUT = env_integer(
"RAY_DATA_MAX_PATHS_PER_LIST_FILES_OUTPUT", 1000
)
_DEFAULT_NUM_WORKERS = env_integer("RAY_DATA_LIST_FILES_THREADED_NUM_WORKERS", 8)
def __init__(
self,
*,
ignore_missing_paths: bool,
num_workers: Optional[int] = None,
max_paths_per_output: Optional[int] = None,
file_chunker: Optional[FileChunker] = None,
):
self._ignore_missing_paths = ignore_missing_paths
self._max_paths_per_output = (
max_paths_per_output
if max_paths_per_output is not None
else self._DEFAULT_MAX_PATHS_PER_OUTPUT
)
self._num_workers = (
num_workers if num_workers is not None else self._DEFAULT_NUM_WORKERS
)
self._queue_size_per_thread = env_integer(
"RAY_DATA_LIST_FILES_QUEUE_SIZE_PER_THREAD",
self._max_paths_per_output * 4,
)
self._file_chunker: FileChunker = (
file_chunker if file_chunker is not None else WholeFileChunker()
)
@property
def file_chunker(self) -> FileChunker:
"""The file chunker that this indexer uses.
Exposed primarily for tests and shuffle-aware planning code that needs
to introspect or override the chunking strategy.
"""
return self._file_chunker
def list_files(
self,
paths: "BlockColumn",
*,
filesystem: "FileSystem",
pruners: Optional[List[FilePruner]] = None,
preserve_order: bool = False,
) -> Iterable[FileManifest]:
file_info_iterator = (
self._get_file_info_iterator_threaded(paths, filesystem, preserve_order)
if self._num_workers > 1
else self._get_file_info_iterator_sequential(paths, filesystem)
)
# Stage pipeline: list → prune (cheap, inline) → chunk (may read
# per-file metadata) → batch into manifests. Pruning runs *before*
# chunking so we never read a footer for a file we'd discard.
pruned = self._filter_file_infos(file_info_iterator, pruners or [])
chunk_records = self._generate_chunk_records(pruned, filesystem, preserve_order)
yield from self._batch_chunk_records_to_manifests(chunk_records)
def _get_file_info_iterator_sequential(
self,
paths: "BlockColumn",
filesystem: "FileSystem",
) -> Iterable[FileInfo]:
for input_path in paths.to_pylist():
resolved_paths, _ = _resolve_paths_and_filesystem(input_path, filesystem)
assert len(resolved_paths) == 1
for path, file_size in _get_file_infos(
resolved_paths[0], filesystem, self._ignore_missing_paths
):
yield FileInfo(path=path, size=file_size)
def _get_file_info_iterator_threaded(
self,
paths: "BlockColumn",
filesystem: "FileSystem",
preserve_order: bool = False,
) -> Iterable[FileInfo]:
"""Threaded file info iterator with work stealing for parallel directory
traversal. Subdirectories are added as work items for idle workers to
process."""
paths_list = paths.to_pylist()
if len(paths_list) == 0:
return
num_workers = self._num_workers
seed_items = [
_TraversalWorkItem(
path=p,
is_discovered_subdir=False,
input_path_index=i if preserve_order else None,
)
for i, p in enumerate(paths_list)
]
def process_fn(
item: _TraversalWorkItem,
add_work: Callable[[_TraversalWorkItem], None],
add_result: Callable[[Union[OrderedFileResult, FileInfo]], None],
) -> None:
"""Process a single item, adding discovered subdirs as work and
files as results."""
input_path_index = item.input_path_index
if item.is_discovered_subdir:
# Subdir paths from filesystem listing: use directly. Re-resolution
# would infer LocalFileSystem for scheme-less paths on S3/GCS,
# and add redundant overhead.
path = item.path
root_path = item.root_path
else:
# Seed paths from user: resolve once to get normalized path + fs.
resolved_paths, _ = _resolve_paths_and_filesystem(item.path, filesystem)
assert len(resolved_paths) == 1
path = resolved_paths[0]
root_path = path
contents = _get_path_contents(
path, filesystem, self._ignore_missing_paths, root_path=root_path
)
for file_path, file_size in contents.files:
file_info_result = FileInfo(path=file_path, size=file_size)
if preserve_order:
add_result(
OrderedFileResult(
input_path_index=input_path_index,
file_path=file_path,
file_info=file_info_result,
)
)
else:
add_result(file_info_result)
for subdir_path in contents.subdirs:
add_work(
_TraversalWorkItem(
path=subdir_path,
is_discovered_subdir=True,
input_path_index=input_path_index,
root_path=root_path,
)
)
def _ordered_result_key(result: OrderedFileResult) -> Tuple[int, str]:
return (result.input_path_index, result.file_path)
if preserve_order:
for result in parallel_process_work_stealing(
seed_items=seed_items,
process_fn=process_fn,
num_workers=num_workers,
preserve_order=True,
order_key=_ordered_result_key,
):
# Ordered mode returns `OrderedFileResult` for sorting, so unwrap.
yield result.file_info
else:
yield from parallel_process_work_stealing(
seed_items=seed_items,
process_fn=process_fn,
num_workers=num_workers,
)
def _filter_file_infos(
self,
file_infos: Iterable[FileInfo],
pruners: List[FilePruner],
) -> Iterator[FileInfo]:
"""Drop zero-size and pruned files before any per-file metadata read."""
for file_info in file_infos:
if file_info.size is None or file_info.size == 0:
logger.warning(f"Skipping zero-size file: {file_info.path!r}")
continue
if not all(pruner.should_include(file_info.path) for pruner in pruners):
continue
yield file_info
def _generate_chunk_records(
self,
file_infos: Iterable[FileInfo],
filesystem: "FileSystem",
preserve_order: bool,
) -> Iterator[Tuple[str, int, Optional[ChunkMetadata]]]:
"""Drive the chunker per file, yielding ``(path, chunk_size, metadata)``.
When the chunker reads per-file metadata (e.g. ``ParquetFileChunker``
reading footers), fan the work across the indexer's thread pool so the
I/O parallelizes even for a single input directory — ``make_async_gen``
over the *discovered files*, not the input paths. Chunkers that don't
read metadata (whole-file / line-delimited) are driven inline to avoid
a pointless thread hand-off.
"""
chunker = self._file_chunker
def chunk_file(
fi: FileInfo,
) -> List[Tuple[str, int, Optional[ChunkMetadata]]]:
return [
(fi.path, chunk_size, chunk_metadata)
for chunk_metadata, chunk_size in chunker.generate_chunk_metadatas(
fi.path, fi.size, filesystem
)
]
if chunker.reads_file_metadata and self._num_workers > 1:
# Fan per-file footer reads across the thread pool. ``make_async_gen``
# only preserves ordering for a 1:1 map (one output per input item), so
# emit ONE record list per file and flatten here. Yielding chunk rows
# individually would let its round-robin merge interleave chunks from
# the files processed concurrently -- breaking per-file contiguity and
# discovery order under ``preserve_order=True``.
def chunk_files(
infos: Iterator[FileInfo],
) -> Iterator[List[Tuple[str, int, Optional[ChunkMetadata]]]]:
for fi in infos:
yield chunk_file(fi)
for records in make_async_gen(
# ``iter(...)`` so a non-iterator iterable (e.g. a list from a
# test or subclass) is still consumed correctly by the helper.
base_iterator=iter(file_infos),
fn=chunk_files,
preserve_ordering=preserve_order,
num_workers=self._num_workers,
buffer_size=self._queue_size_per_thread,
):
yield from records
else:
for fi in file_infos:
yield from chunk_file(fi)
def _batch_chunk_records_to_manifests(
self,
chunk_records: Iterable[Tuple[str, int, Optional[ChunkMetadata]]],
) -> Iterable[FileManifest]:
"""Batch chunk records into ``FileManifest`` blocks of bounded size."""
running_paths: List[str] = []
running_file_sizes: List[int] = []
running_chunk_metadatas: List[Optional[ChunkMetadata]] = []
manifests_count = 0
chunks_count = 0
for path, chunk_size, chunk_metadata in chunk_records:
running_paths.append(path)
running_file_sizes.append(chunk_size)
running_chunk_metadatas.append(chunk_metadata)
chunks_count += 1
if len(running_paths) >= self._max_paths_per_output:
manifests_count += 1
yield FileManifest.construct_manifest(
running_paths,
running_file_sizes,
running_chunk_metadatas,
)
running_paths = []
running_file_sizes = []
running_chunk_metadatas = []
if running_paths:
manifests_count += 1
yield FileManifest.construct_manifest(
running_paths,
running_file_sizes,
running_chunk_metadatas,
)
logger.debug(
f"Listing files: constructed {manifests_count} manifests "
f"with {chunks_count} file chunks"
)
@@ -0,0 +1,141 @@
from functools import cached_property
from typing import List, Optional
import numpy as np
import pyarrow as pa
from ray.data._internal.datasource_v2.chunkers.file_chunker import ChunkMetadata
from ray.data.block import Block, BlockAccessor, BlockColumnAccessor
# File manifest column names
PATH_COLUMN_NAME = "__path"
FILE_SIZE_COLUMN_NAME = "__file_size"
FILE_CHUNK_METADATA_COLUMN_NAME = "__file_chunk_metadata"
class FileManifest:
"""Structured view over file paths, sizes, and per-chunk metadata.
Provides structured access to file paths, sizes, and chunk metadata. This avoids
making implicit assumptions about block structure as data moves between file
listing, partitioning, and reading stages.
All extracted views (i.e., `paths`, `file_sizes`, `file_chunk_metadatas`) share
the same row order as the underlying block. Any transformation must preserve this.
Each row represents a single chunk of a file. For unchunked files (whole-file
reads), the chunk-metadata entry is ``None`` and ``file_sizes`` equals the
on-disk file size. For chunked files, multiple rows can share the same path
but carry different chunk metadata.
"""
def __init__(self, block: Block):
"""Create a new `FileManifest` from a block.
Args:
block: Block with `PATH_COLUMN_NAME`, `FILE_SIZE_COLUMN_NAME`, and
`FILE_CHUNK_METADATA_COLUMN_NAME` columns. Any other columns are
optional and treated as input data.
"""
column_names = BlockAccessor.for_block(block).column_names()
assert FILE_SIZE_COLUMN_NAME in column_names
assert PATH_COLUMN_NAME in column_names
assert FILE_CHUNK_METADATA_COLUMN_NAME in column_names
self._block = block
self._paths = block[PATH_COLUMN_NAME]
self._file_sizes = block[FILE_SIZE_COLUMN_NAME]
self._file_chunk_metadatas = block[FILE_CHUNK_METADATA_COLUMN_NAME]
def __len__(self) -> int:
return len(self._block)
def __repr__(self):
return f"<{self.__class__.__name__} length={len(self._block)}>"
# TODO Use arrow arrays instead of numpy for these properties.
@cached_property
def paths(self) -> np.ndarray:
return BlockColumnAccessor.for_column(self._paths).to_numpy()
@cached_property
def file_sizes(self) -> np.ndarray:
return BlockColumnAccessor.for_column(self._file_sizes).to_numpy()
@cached_property
def file_chunk_metadatas(self) -> np.ndarray:
return BlockColumnAccessor.for_column(self._file_chunk_metadatas).to_numpy()
def as_block(self) -> Block:
"""Return the underlying block for the `FileManifest`.
This doesn't make a copy of the underlying data.
"""
return self._block
@classmethod
def concat(cls, manifests: List["FileManifest"]) -> "FileManifest":
"""Return a new `FileManifest` whose rows are the concatenation of
``manifests`` in order.
Row alignment of ``paths`` / ``file_sizes`` is preserved because
each input already satisfies it.
"""
assert len(manifests) > 0, "concat requires at least one manifest"
if len(manifests) == 1:
return manifests[0]
merged = pa.concat_tables(
[
BlockAccessor.for_block(manifest._block).to_arrow()
for manifest in manifests
]
)
return cls(merged)
def shuffle(self, seed: Optional[int]) -> "FileManifest":
"""Return a new `FileManifest` with rows permuted.
Args:
seed: Random seed. ``None`` for non-deterministic shuffling.
When set, input rows are first sorted by path so the shuffle
is reproducible regardless of upstream listing order
(the threaded ``FileIndexer`` doesn't preserve order).
Returns:
A new `FileManifest` with the same rows in a shuffled order. The
underlying row alignment between `paths` and `file_sizes` is
preserved because the permutation is applied to the block as a
whole.
"""
n = len(self)
if n <= 1:
return self
block = self._block
if seed is not None:
sort_indices = pa.compute.sort_indices(
BlockAccessor.for_block(block).to_arrow(),
sort_keys=[(PATH_COLUMN_NAME, "ascending")],
)
block = block.take(sort_indices)
permutation = np.random.default_rng(seed).permutation(n)
return FileManifest(block.take(permutation))
@classmethod
def construct_manifest(
cls,
paths: List[str],
sizes: List[int],
chunk_metadatas: List[Optional[ChunkMetadata]],
) -> "FileManifest":
assert len(paths) == len(sizes) == len(chunk_metadatas)
block = pa.table(
{
PATH_COLUMN_NAME: paths,
FILE_SIZE_COLUMN_NAME: sizes,
FILE_CHUNK_METADATA_COLUMN_NAME: chunk_metadatas,
}
)
return cls(block)
@@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from typing import List
from ray.data.datasource import PathPartitionFilter
from ray.data.datasource.path_util import _has_file_extension
class FilePruner(ABC):
"""Generic file-level filter applied during listing."""
@abstractmethod
def should_include(self, path: str) -> bool:
"""Return True if this file should be included, False to skip it."""
...
class FileExtensionPruner(FilePruner):
"""Skip files that don't match the expected extensions."""
def __init__(self, file_extensions: List[str]):
self._file_extensions = file_extensions
def should_include(self, path: str) -> bool:
return _has_file_extension(path, self._file_extensions)
class PartitionPruner(FilePruner):
"""Skip files based on partition column predicates (e.g., hive partitioning)."""
def __init__(self, partition_filter: PathPartitionFilter):
self._filter = partition_filter
def should_include(self, path: str) -> bool:
return self._filter.apply(path)
@@ -0,0 +1,123 @@
import logging
from dataclasses import dataclass
from typing import Iterable, List, Optional, Tuple
import pyarrow as pa
from pyarrow.fs import FileSelector, FileType
from ray.data.datasource.file_meta_provider import _handle_read_os_error
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PathContents:
"""Contents of a path: files (path, size) and subdirectories to expand."""
files: List[Tuple[str, Optional[int]]]
subdirs: List[str]
def _expand_directory(
base_path: str,
filesystem: pa.fs.FileSystem,
ignore_missing_path: bool,
*,
root_path: Optional[str] = None,
) -> PathContents:
"""List one level of a directory.
Hidden-prefix (``.``/``_``) exclusion is applied relative to ``root_path``
(the top-level path the traversal started from), not the immediate parent,
so a nested entry is only excluded when its path *relative to the root*
begins with an excluded prefix. When ``root_path`` is ``None`` it defaults
to ``base_path``.
"""
exclude_prefixes = [".", "_"]
if root_path is None:
root_path = base_path
selector = FileSelector(
base_path, recursive=False, allow_not_found=ignore_missing_path
)
children = filesystem.get_file_info(selector)
# Lineage reconstruction doesn't work if tasks aren't deterministic, and
# `filesystem.get_file_info` might return files in a non-deterministic order. So, we
# sort the files.
assert isinstance(children, list), type(children)
children.sort(key=lambda file_: file_.path)
files: List[Tuple[str, Optional[int]]] = []
subdirs: List[str] = []
for child in children:
if not child.path.startswith(root_path):
continue
relative = child.path[len(root_path) :].lstrip("/")
if any(relative.startswith(prefix) for prefix in exclude_prefixes):
continue
if child.type == FileType.File:
files.append((child.path, child.size))
elif child.type == FileType.Directory:
subdirs.append(child.path)
elif child.type == FileType.UNKNOWN:
logger.warning(f"Discovered file with unknown type: '{child.path}'")
continue
else:
assert child.type == FileType.NotFound
raise FileNotFoundError(child.path)
return PathContents(files=files, subdirs=subdirs)
def _get_path_contents(
path: str,
filesystem: pa.fs.FileSystem,
ignore_missing_path: bool,
*,
root_path: Optional[str] = None,
) -> PathContents:
"""Get files and subdirs for a path. Handles File, Directory, and NotFound.
Only one level of a directory is expanded; discovered subdirectories are
returned in :attr:`PathContents.subdirs` for the caller to expand.
"""
try:
file_info = filesystem.get_file_info(path)
except OSError as e:
_handle_read_os_error(e, path)
if file_info.type == FileType.File:
return PathContents(files=[(path, file_info.size)], subdirs=[])
elif file_info.type == FileType.Directory:
return _expand_directory(
path, filesystem, ignore_missing_path, root_path=root_path
)
elif file_info.type == FileType.NotFound and ignore_missing_path:
return PathContents(files=[], subdirs=[])
else:
raise FileNotFoundError(path)
def _get_file_infos(
path: str,
filesystem: pa.fs.FileSystem,
ignore_missing_path: bool,
*,
_root_path: Optional[str] = None,
) -> Iterable[Tuple[str, Optional[int]]]:
"""Recursively expand a path (file or directory) into ``(path, size)`` tuples."""
if _root_path is None:
_root_path = path
contents = _get_path_contents(
path, filesystem, ignore_missing_path, root_path=_root_path
)
yield from contents.files
for subdir in contents.subdirs:
yield from _get_file_infos(
subdir, filesystem, ignore_missing_path, _root_path=_root_path
)
@@ -0,0 +1,159 @@
from typing import TYPE_CHECKING, Iterable, List, Optional
import pyarrow as pa
from ray.data._internal.datasource_v2.listing.file_manifest import (
PATH_COLUMN_NAME,
FileManifest,
)
from ray.data._internal.datasource_v2.listing.file_pruners import (
FileExtensionPruner,
FilePruner,
PartitionPruner,
)
from ray.data._internal.datasource_v2.partitioners.file_partitioner import (
FilePartitioner,
)
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data._internal.execution.interfaces.task_context import TaskContext
from ray.data.block import Block, BlockAccessor
if TYPE_CHECKING:
from pyarrow.fs import FileSystem
from ray.data._internal.datasource_v2.listing.file_indexer import FileIndexer
from ray.data.datasource.file_based_datasource import FileShuffleConfig
from ray.data.datasource.partitioning import PathPartitionFilter
def partition_files(
blocks: Iterable[Block],
_: TaskContext,
partitioner: FilePartitioner,
) -> Iterable[Block]:
for block in blocks:
partitioner.add_input(FileManifest(block))
while partitioner.has_partition():
yield partitioner.next_partition().as_block()
partitioner.finalize()
while partitioner.has_partition():
yield partitioner.next_partition().as_block()
def _build_pruners(
file_extensions: Optional[List[str]],
partition_filter: Optional["PathPartitionFilter"],
) -> List[FilePruner]:
pruners: List[FilePruner] = []
if file_extensions is not None:
pruners.append(FileExtensionPruner(file_extensions))
if partition_filter is not None:
pruners.append(PartitionPruner(partition_filter))
return pruners
def list_files_for_each_block(
blocks: Iterable[Block],
_: TaskContext,
*,
indexer: "FileIndexer",
filesystem: "FileSystem",
file_extensions: Optional[List[str]] = None,
partition_filter: Optional["PathPartitionFilter"] = None,
preserve_order: bool = False,
) -> Iterable[Block]:
"""Expand path blocks into ``FileManifest`` blocks.
Each input block carries a single ``__path`` column of path strings.
For every path, the indexer is invoked to produce a stream of
``FileManifest`` objects; each manifest's backing block is yielded.
Pruners are constructed once per task from ``file_extensions`` /
``partition_filter`` — keeps pruner construction out of the
``_read_datasource_v2`` entry point.
"""
pruners = _build_pruners(file_extensions, partition_filter)
for block in blocks:
for manifest in indexer.list_files(
block[PATH_COLUMN_NAME],
filesystem=filesystem,
pruners=pruners,
preserve_order=preserve_order,
):
if len(manifest) > 0:
yield manifest.as_block()
def shuffle_files(
blocks: Iterable[Block],
_: TaskContext,
*,
shuffle_config: "FileShuffleConfig",
execution_idx: int,
) -> Iterable[Block]:
"""Concatenate manifest blocks and shuffle rows with the seeded RNG.
Runs in a single task (`plan_list_files_op` sets `should_parallelize=False`
when shuffle is requested) so we have the full manifest before
shuffling. Emits one merged manifest block. Determinism comes from
``FileManifest.shuffle`` which sorts by path before applying the
permutation — this protects against non-deterministic upstream
indexer yield order.
"""
builder = DelegatingBlockBuilder()
for block in blocks:
if len(block) > 0:
builder.add_block(block)
combined = builder.build()
if len(combined) == 0:
return
seed = shuffle_config.get_seed(execution_idx)
yield FileManifest(combined).shuffle(seed).as_block()
def sample_files(
indexer: "FileIndexer",
paths: List[str],
filesystem: "FileSystem",
pruners: Optional[List[FilePruner]] = None,
max_files: int = 16,
) -> FileManifest:
"""Drive the indexer until up to ``max_files`` files arrive; return them.
Used for driver-side schema inference in ``_read_datasource_v2``.
Sampling more than one file lets callers unify schemas (e.g., if the
first file has an all-null column, later files' non-null types can
promote it). No caching — the returned manifest is discarded after
schema inference, and the ``ListFiles`` op lists the same paths
again on workers at execution time.
"""
assert max_files >= 1
paths_column = pa.array(paths, type=pa.string())
collected: List[FileManifest] = []
collected_rows = 0
for manifest in indexer.list_files(
paths_column,
filesystem=filesystem,
pruners=pruners or [],
preserve_order=True,
):
if len(manifest) == 0:
continue
remaining = max_files - collected_rows
if len(manifest) <= remaining:
collected.append(manifest)
collected_rows += len(manifest)
else:
collected.append(
FileManifest(
BlockAccessor.for_block(manifest.as_block()).slice(0, remaining)
)
)
collected_rows = max_files
if collected_rows >= max_files:
break
if not collected:
return FileManifest.construct_manifest(paths=[], sizes=[], chunk_metadatas=[])
return FileManifest.concat(collected)
@@ -0,0 +1,122 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
from ray.data.expressions import Expr
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
@DeveloperAPI
class SupportsFilterPushdown(ABC):
"""Mixin for scanners that support filter/predicate pushdown.
Filter pushdown allows predicates to be evaluated at the data source level,
reducing the amount of data that needs to be read and transferred.
"""
@abstractmethod
def push_filters(self, predicate: "Expr") -> Tuple["Scanner", Optional["Expr"]]:
"""Push a filter predicate down to the scanner.
Args:
predicate: Expression representing the filter condition.
Returns:
Tuple of (new_scanner, residual_predicate) where:
- new_scanner: New Scanner instance with the filter applied
- residual_predicate: Any part of the predicate that couldn't be
pushed down and must be applied post-scan. None if fully pushed.
"""
...
@DeveloperAPI
class SupportsColumnPruning(ABC):
"""Mixin for scanners that support column pruning/projection pushdown.
Column pruning allows reading only the columns needed by the query,
which is especially beneficial for columnar formats like Parquet.
"""
@abstractmethod
def prune_columns(self, columns: List[str]) -> "Scanner":
"""Prune the scanner to only read the specified columns.
Args:
columns: List of column names to read.
Returns:
New Scanner instance configured to read only the specified columns.
"""
...
@abstractmethod
def pruned_column_names(self) -> Optional[Tuple[str, ...]]:
"""Physical column names selected after pruning, if any.
Returns:
``None`` when no pruning has been applied (read all columns).
A tuple (possibly empty) after :meth:`prune_columns` has been
applied, listing on-disk / reader column names in read order.
"""
...
@DeveloperAPI
class SupportsLimitPushdown(ABC):
"""Mixin for scanners that support limit pushdown.
Limit pushdown allows the scanner to stop early once the required number
of rows has been read.
"""
@abstractmethod
def push_limit(self, limit: int) -> "Scanner":
"""Push a row limit down to the scanner.
Args:
limit: Maximum number of rows to read.
Returns:
New Scanner instance with the limit applied.
"""
...
@DeveloperAPI
class SupportsPartitionPruning(ABC):
"""Mixin for scanners that support partition pruning.
Partition pruning allows skipping entire files/partitions based on
predicates that reference partition columns.
"""
@property
@abstractmethod
def partition_columns(self) -> Set[str]:
"""Names of columns that are partition keys.
Callers (e.g. the predicate-pushdown rule) use this to decide
whether a predicate should be routed through :meth:`push_filters`
(data columns) or :meth:`prune_partitions` (partition columns).
Must be fully populated by schema inference at planning time.
"""
...
@abstractmethod
def prune_partitions(self, predicate: "Expr") -> "Scanner":
"""Prune partitions based on a predicate.
The scanner determines its partition columns from its
``Partitioning`` configuration, which is fully populated
by schema inference at planning time.
Args:
predicate: Expression to evaluate against partition values.
Returns:
New Scanner instance with partition pruning applied.
"""
...
@@ -0,0 +1,309 @@
"""Concrete ``DataSourceV2`` for Parquet files.
Wires the V2 listing (`NonSamplingFileIndexer`, driven by the upstream
`ListFiles` op), scanning (`ParquetScanner`), and reading
(`ParquetFileReader`) components against a user-supplied path set.
Constructed from `read_api.read_parquet` when
`DataContext.use_datasource_v2` is set.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, List, Literal, Optional, Union
import pyarrow as pa
from typing_extensions import override
from ray.data._internal.datasource.parquet_datasource import (
ParquetDatasource,
check_for_legacy_tensor_type,
)
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
FileChunker,
ParquetFileChunker,
)
from ray.data._internal.datasource_v2.datasource_v2 import (
DatasourceCategory,
DataSourceV2,
)
from ray.data._internal.datasource_v2.listing.file_indexer import (
FileIndexer,
NonSamplingFileIndexer,
)
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
from ray.data._internal.datasource_v2.readers.file_reader import (
INCLUDE_PATHS_COLUMN_NAME,
)
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
ParquetInMemorySizeEstimator,
)
from ray.data._internal.datasource_v2.scanners.parquet_scanner import ParquetScanner
from ray.data._internal.util import _is_local_scheme
from ray.data.context import DataContext
from ray.data.datasource.partitioning import (
Partitioning,
PartitionStyle,
PathPartitionParser,
_partition_field_types_to_pa_schema,
)
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from pyarrow.fs import FileSystem
from ray.data.datasource.file_based_datasource import FileShuffleConfig
@DeveloperAPI
class ParquetDatasourceV2(DataSourceV2[FileManifest]):
"""V2 Parquet datasource.
Listing is delegated to :class:`NonSamplingFileIndexer` driven by the
``ListFiles`` logical op; scanning and reading are delegated to
:class:`ParquetScanner` and :class:`ParquetFileReader`. Schema
inference reads the first file's footer only and augments with
partition/path columns as configured.
"""
def __init__(
self,
paths: List[str],
*,
filesystem: Optional["FileSystem"] = None,
partitioning: Optional[Partitioning] = Partitioning(PartitionStyle.HIVE),
file_extensions: Optional[List[str]] = None,
ignore_missing_paths: bool = False,
include_paths: bool = False,
include_row_hash: bool = False,
shuffle: Optional[Union[Literal["files"], "FileShuffleConfig"]] = None,
arrow_parquet_args: Optional[dict] = None,
schema: Optional[pa.Schema] = None,
parquet_format_kwargs: Optional[dict] = None,
file_chunker: Optional[FileChunker] = None,
):
super().__init__(name="ParquetV2", category=DatasourceCategory.FILE_BASED)
# Capture the ``local://`` check against the *original* paths;
# ``_resolve_paths_and_filesystem`` below strips the scheme, so
# introspecting ``self._paths`` after construction can't tell a
# plain local path from a ``local://`` one. ``_supports_distributed_reads``
# is exposed by the base-class ``supports_distributed_reads``
# property and consumed by ``_read_datasource_v2``.
self._supports_distributed_reads = not _is_local_scheme(paths)
resolved_paths, resolved_filesystem = _resolve_paths_and_filesystem(
paths, filesystem
)
self._paths: List[str] = resolved_paths
self._filesystem = resolved_filesystem
self._partitioning = partitioning
self._file_extensions = file_extensions or ParquetDatasource._FILE_EXTENSIONS
self._ignore_missing_paths = ignore_missing_paths
self._include_paths = include_paths
self._include_row_hash = include_row_hash
self._shuffle = shuffle
self._arrow_parquet_args = arrow_parquet_args or {}
# ``pds.ParquetFileFormat`` kwargs forwarded from the deprecated
# ``read_parquet(dataset_kwargs=...)`` arg. Spread into the format
# built by ``ParquetFileReader._make_format``.
self._parquet_format_kwargs = parquet_format_kwargs or {}
# User-supplied schema override. When set, ``infer_schema`` returns
# it verbatim (plus partition/path augmentation) rather than reading
# footers, and the scanner pins it on the pyarrow dataset so files
# are cast to these types at scan time.
self._user_schema = schema
# Chunker that splits each listed Parquet file into one or more
# row-group-aligned read units. Defaults to ``ParquetFileChunker``
# (1 GiB target chunk size, or whatever ``DataContext`` configures).
# Callers can inject an alternative for tests or shuffle-aware
# planning code that wants whole-file reads.
self._file_chunker: FileChunker = (
file_chunker if file_chunker is not None else ParquetFileChunker()
)
@property
def paths(self) -> List[str]:
return self._paths
@property
def filesystem(self) -> Optional["FileSystem"]:
return self._filesystem
@property
def partitioning(self) -> Optional[Partitioning]:
return self._partitioning
@property
def file_extensions(self) -> List[str]:
return self._file_extensions
@property
def ignore_missing_paths(self) -> bool:
return self._ignore_missing_paths
@property
def include_paths(self) -> bool:
return self._include_paths
@property
def shuffle(self) -> Optional[Union[Literal["files"], "FileShuffleConfig"]]:
return self._shuffle
def _get_file_indexer(self) -> FileIndexer:
return NonSamplingFileIndexer(
ignore_missing_paths=self._ignore_missing_paths,
file_chunker=self._file_chunker,
)
def get_size_estimator(self) -> ParquetInMemorySizeEstimator:
return ParquetInMemorySizeEstimator()
@override
def resolve_partitioning(self, sample: FileManifest) -> Optional[Partitioning]:
"""Return ``self._partitioning`` with path-discovered field names.
Hive partitioning ships with ``field_names=None`` by default and
discovers keys from the file path at plan time. Directory
partitioning already carries ``field_names`` at construction and
needs no discovery. Returns a fresh ``Partitioning`` rather than
mutating ``self`` so schema inference stays side-effect-free.
"""
import copy
if self._partitioning is None or len(sample) == 0:
return copy.deepcopy(self._partitioning)
if self._partitioning.field_names:
return copy.deepcopy(self._partitioning)
first_path = sample.paths.tolist()[0]
parser = PathPartitionParser(self._partitioning)
partition_kv = parser(first_path)
if not partition_kv:
return copy.deepcopy(self._partitioning)
return Partitioning(
style=self._partitioning.style,
base_dir=self._partitioning.base_dir,
field_names=list(partition_kv.keys()),
field_types=self._partitioning.field_types,
filesystem=self._partitioning.filesystem,
)
def infer_schema(self, sample: FileManifest) -> pa.Schema:
"""Read Parquet footers from the sample manifest; unify and augment.
When the sample has multiple files, their schemas are unified via
``unify_schemas_with_validation`` so a first file with all-null
columns doesn't lock in ``null`` types that can't be cast to the
actual types in later files.
Pure: does not mutate ``self``. Partitioning field-name discovery
is delegated to :meth:`resolve_partitioning` so the discovered
``Partitioning`` can flow through ``_read_datasource_v2`` into
:meth:`create_scanner` without side effects.
"""
from concurrent.futures import ThreadPoolExecutor
import pyarrow.parquet as pq
from ray.data._internal.util import unify_schemas_with_validation
# Empty sample — typically means the user pointed ``read_parquet``
# at an empty directory. Return an empty schema so the rest of
# the plan stays valid; downstream ops produce zero blocks and
# the executor runs through without error (matches V1).
if len(sample) == 0:
return self._user_schema if self._user_schema is not None else pa.schema([])
sample_paths: List[str] = sample.paths.tolist()
# Parquet footer reads against high-latency object stores
# (S3, GCS) are ~50-100 ms each. Reading the sample's footers in
# parallel keeps driver-side schema inference bounded by the
# slowest single read rather than the sum. ``executor.map``
# preserves input order, which matters because the unified
# schema's field order follows the first schema's and
# ``sample_paths[0]`` drives partition discovery below.
#
# NOTE: ``pq.read_schema`` only accepts a ``filesystem=`` kwarg in
# recent pyarrow releases; older wheels in CI don't have it. Open
# the file through the configured filesystem and hand the file
# handle to ``read_schema`` for cross-version compatibility.
filesystem = self._filesystem
if self._user_schema is not None:
# Caller pinned the schema — skip footer reads. Partition/path
# augmentation below still applies so downstream ops see the
# synthesized columns.
schema = self._user_schema
else:
def _read_schema(path: str):
if filesystem is None:
return pq.read_schema(path)
with filesystem.open_input_file(path) as handle:
return pq.read_schema(handle)
with ThreadPoolExecutor(max_workers=min(len(sample_paths), 16)) as executor:
per_file_schemas = list(executor.map(_read_schema, sample_paths))
schema = (
unify_schemas_with_validation(per_file_schemas) or per_file_schemas[0]
)
assert isinstance(schema, pa.Schema)
resolved_partitioning = self.resolve_partitioning(sample)
if resolved_partitioning is not None:
first_path = sample_paths[0]
parser = PathPartitionParser(resolved_partitioning)
partition_kv = parser(first_path)
# For hive partitioning the parser discovers key names from the
# path itself; for directory partitioning it uses ``field_names``.
# In both cases ``partition_kv`` is the authoritative list of
# partition columns for the first sample file.
partition_pa_schema = _partition_field_types_to_pa_schema(
field_names=list(partition_kv.keys()),
field_types=resolved_partitioning.field_types or {},
)
for field_name in partition_kv.keys():
if schema.get_field_index(field_name) == -1:
pa_type = partition_pa_schema.field(field_name).type
schema = schema.append(pa.field(field_name, pa_type))
if (
self._include_paths
and schema.get_field_index(INCLUDE_PATHS_COLUMN_NAME) == -1
):
schema = schema.append(pa.field(INCLUDE_PATHS_COLUMN_NAME, pa.string()))
if self._include_row_hash:
# ``row_hash`` is synthesized post-read as ``uint64``. Replace
# the field type when the file already has a ``row_hash``
# column (matches V1 ``_derive_schema``); otherwise append.
idx = schema.get_field_index("row_hash")
if idx == -1:
schema = schema.append(pa.field("row_hash", pa.uint64()))
elif schema.field(idx).type != pa.uint64():
schema = schema.set(idx, pa.field("row_hash", pa.uint64()))
check_for_legacy_tensor_type(schema)
return schema
def create_scanner(
self,
schema: pa.Schema,
filesystem: Optional["FileSystem"] = None,
**options: Any,
) -> ParquetScanner:
# Callers (``_read_datasource_v2``) supply the sample-resolved
# ``Partitioning`` via ``options["partitioning"]`` so the
# datasource itself stays immutable — fall back to the
# constructor-provided one for direct users of this API.
partitioning = options.get("partitioning", self._partitioning)
return ParquetScanner(
schema=schema,
filesystem=filesystem or self._filesystem,
partitioning=partitioning,
include_paths=self._include_paths,
include_row_hash=self._include_row_hash,
shuffle=self._shuffle,
ignore_prefixes=options.get("ignore_prefixes"),
target_block_size=DataContext.get_current().target_max_block_size,
parquet_format_kwargs=dict(self._parquet_format_kwargs),
)
@@ -0,0 +1,51 @@
from abc import ABC, abstractmethod
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
class FilePartitioner(ABC):
"""Abstract base class for partitioning file manifests.
A ``FilePartitioner`` groups file paths and their associated metadata into new
file manifests based on a specific partitioning strategy.
Implementations must be deterministic to ensure consistent partitioning across
retries.
"""
@abstractmethod
def add_input(self, input_manifest: FileManifest):
"""Add a file manifest to be partitioned.
Args:
input_manifest: A ``FileManifest`` containing paths and metadata to partition.
"""
...
@abstractmethod
def has_partition(self) -> bool:
"""Check if there are any partitions available.
Returns:
``True`` if there are partitions ready to be retrieved via
``next_partition()``, ``False`` otherwise.
"""
...
@abstractmethod
def next_partition(self) -> FileManifest:
"""Get the next available partition.
Returns:
A ``FileManifest`` containing the paths and metadata for the next partition.
"""
...
@abstractmethod
def finalize(self):
"""Process any remaining files and complete the partitioning.
This method is called after all inputs have been added via ``add_input()`` to
ensure any buffered files are properly partitioned.
"""
...
@@ -0,0 +1,89 @@
import logging
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
from ray.data._internal.datasource_v2.partitioners.file_partitioner import (
FilePartitioner,
)
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
InMemorySizeEstimator,
)
from ray.data._internal.weighted_round_robin import WeightedRoundRobinPartitioner
logger = logging.getLogger(__name__)
class RoundRobinPartitioner(FilePartitioner):
"""Partitions input paths into blocks based on the in-memory size of files.
This partitioning ensures read tasks effectively utilize the cluster and
produce appropriately-sized blocks
**Steps:**
1. Initialize empty buckets.
2. Iterate through input blocks and add paths to buckets. For each path:
- If the current bucket falls below `min_bucket_size`, add the path and don't move
to the next bucket.
- If the current bucket exceeds `min_bucket_size` but not `max_bucket_size`,
add the path and move to the next bucket.
- If the current bucket exceeds `max_bucket_size`, yield the paths as a block, clear
the bucket, and move to the next bucket.
3. Yield any remaining paths in the buckets as blocks.
This algorithm ensures that each block contains [min_bucket_size, max_bucket_size]
worth of files. It's a deterministic algorithm, but it doesn't maintain the order
of the input paths.
"""
def __init__(
self,
in_memory_size_estimator: InMemorySizeEstimator,
*,
min_bucket_size: int,
max_bucket_size: int,
num_buckets: int,
):
self._in_memory_size_estimator = in_memory_size_estimator
self._partitioner = WeightedRoundRobinPartitioner(
min_bucket_size=min_bucket_size,
max_bucket_size=max_bucket_size,
num_buckets=num_buckets,
)
def add_input(self, input_manifest: FileManifest):
in_memory_size_estimates = (
self._in_memory_size_estimator.estimate_in_memory_sizes(input_manifest)
)
for (
file_path,
file_size,
file_chunk_metadata,
in_memory_size_estimate,
) in zip(
input_manifest.paths,
input_manifest.file_sizes,
input_manifest.file_chunk_metadatas,
in_memory_size_estimates,
):
self._partitioner.add_item(
(file_path, file_size, file_chunk_metadata),
in_memory_size_estimate,
)
def has_partition(self) -> bool:
return self._partitioner.has_partition()
@property
def num_buckets(self) -> int:
return self._partitioner.num_buckets
def next_partition(self) -> FileManifest:
partition = self._partitioner.next_partition()
paths, file_sizes, file_chunk_metadatas = zip(*partition)
return FileManifest.construct_manifest(
list(paths),
list(file_sizes),
list(file_chunk_metadatas),
)
def finalize(self):
self._partitioner.finalize()
@@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from typing import Generic, Iterator
import pyarrow as pa
from ray.data._internal.datasource_v2 import InputSplit
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class Reader(ABC, Generic[InputSplit]):
"""Abstract base class for reading data from input buckets.
Readers execute on workers to actually read data. They receive an InputSplit
(e.g., FileManifest for file-based sources) and yield Arrow tables.
The Reader is created by Scanner.create_reader() and is configured with all
pushdown optimizations (columns, predicates, limits) that were applied.
"""
@abstractmethod
def read(self, input_split: InputSplit) -> Iterator[pa.Table]:
"""Read data from the input bucket and yield Arrow tables.
This method is called on workers to perform the actual read operation.
It should respect all pushdowns configured on this reader.
Args:
input_split: Work unit describing what data to read.
Returns:
Iterator[pa.Table]: Iterator of PyArrow Tables containing the read data.
"""
...
@@ -0,0 +1,513 @@
from enum import Enum
from functools import cached_property, partial
from typing import Any, Iterator, List, Optional, Set, Tuple
import pyarrow as pa
import pyarrow.dataset as pds
from pyarrow.fs import FileSystem, LocalFileSystem
from ray._common.utils import env_integer
from ray.data._internal.arrow_block import _BATCH_SIZE_PRESERVING_STUB_COL_NAME
from ray.data._internal.datasource.parquet_datasource import _compute_row_hashes
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
from ray.data._internal.datasource_v2.readers.base_reader import Reader
from ray.data._internal.util import iterate_with_retry, make_async_gen
from ray.data.context import DataContext
from ray.data.datasource.partitioning import Partitioning, PathPartitionParser
from ray.data.expressions import Expr
from ray.util.annotations import DeveloperAPI
# Synthetic column name produced when ``include_paths=True``. Shared with
# the V2 datasource and scanner layers so all references are spelled the
# same way.
INCLUDE_PATHS_COLUMN_NAME = "path"
# https://arrow.apache.org/docs/python/generated/pyarrow.dataset.Scanner.html#pyarrow.dataset.Scanner.from_batches
# Default is specified by PyArrow.
_ARROW_DEFAULT_BATCH_SIZE = 131_072
# Number of batches read ahead per scanner. PyArrow's default is 16,
# which can retain a multi-GB working set when scanning jumbo tensor
# columns. 8 keeps I/O pipelined on remote filesystems for typical
# Parquet workloads without doubling memory peak. Drop to 1 via the
# env var when reading wide tensor columns.
_ARROW_SCANNER_BATCH_READAHEAD = env_integer(
"RAY_DATA_ARROW_SCANNER_BATCH_READAHEAD", 8
)
# Number of worker threads used to read fragments concurrently per task.
# Defaults to 4 to overlap remote-filesystem I/O latency across multiple
# fragments. ``_read_fragment_batches`` caps this to ``len(fragments)``
# at runtime so single-fragment tasks don't spin up extra workers, and
# falls back to the sequential path entirely when
# ``DataContext.execution_options.preserve_order`` is set.
_DEFAULT_NUM_THREADS = env_integer("RAY_DATA_READ_FILES_NUM_THREADS", 4)
ROW_HASH_COLUMN_NAME = "row_hash"
class FileFormat(str, Enum):
PARQUET = "parquet"
CSV = "csv"
FEATHER = "feather"
JSON = "json"
ARROW = "arrow"
IPC = "ipc"
@DeveloperAPI
class FileReader(Reader[FileManifest]):
"""Reader for file-based sources.
This reader uses PyArrow's Dataset API which automatically handles:
- Column pruning
- Filter pushdown (row group pruning)
- Batch-level filtering
"""
def __init__(
self,
format: FileFormat,
batch_size: int = _ARROW_DEFAULT_BATCH_SIZE,
columns: Optional[List[str]] = None,
predicate: Optional[Expr] = None,
limit: Optional[int] = None,
filesystem: Optional[FileSystem] = None,
partitioning: Optional[Partitioning] = None,
ignore_prefixes: Optional[List[str]] = None,
include_paths: bool = False,
include_row_hash: bool = False,
schema: Optional[pa.Schema] = None,
):
"""Initialize the reader.
Refer to https://arrow.apache.org/docs/python/generated/pyarrow.dataset.dataset.html for more details.
Args:
format: Format of the files to read.
batch_size: Number of rows per batch.
columns: Columns to read. None means all columns.
predicate: Ray Data expression for filtering. Converted to a
PyArrow expression at the scanner-kwargs boundary.
limit: Maximum number of rows to read.
filesystem: Filesystem for reading files.
partitioning: Ray ``Partitioning`` object. Partition columns are
synthesized per-path via ``PathPartitionParser`` after each
batch is read, producing string-typed columns (V1 parity).
ignore_prefixes: Prefixes to ignore when reading files. Default is ['.', '_'] set by PyArrow.
include_paths: If True, include the source file path in a
``'path'`` column for each row.
include_row_hash: If True, include a deterministic uint64 hash
per row in a ``'row_hash'`` column. The hash is derived from
the source file path and the row's post-filter output
position within the fragment, matching V1 semantics. If a
``'row_hash'`` column already exists in the file, it is
overwritten.
schema: Caller-supplied unified schema used both to override
pyarrow's per-fragment inference (so a file whose column
is all-null doesn't pin the type to ``null``) and to cast
path-derived partition values to their target types when
``Partitioning(field_types=...)`` is set.
"""
self._format = format
self._columns = columns
self._predicate = predicate
self._batch_size = batch_size
self._limit = limit
self._filesystem = filesystem
self._partition_parser: Optional[PathPartitionParser] = (
PathPartitionParser(partitioning) if partitioning is not None else None
)
self._ignore_prefixes = ignore_prefixes
self._include_paths = include_paths
self._include_row_hash = include_row_hash
self._schema = schema
@cached_property
def _file_dataset_schema(self) -> Optional[pa.Schema]:
"""Schema passed to ``pds.dataset`` — partition keys and ``path``
stripped out since those are synthesized post-read.
Pinning the caller-supplied schema at the pyarrow layer is how
we cover the "first file has an all-null column, later files
have the real type" case (e.g.
``test_read_null_data_in_first_file``): without the pin,
pyarrow locks column X to ``null`` across the fragment group
and the later string-typed file fails the cast.
But pyarrow refuses extension-to-extension casts (e.g.
``ArrowTensorTypeV2(shape=X)`` → ``ArrowVariableShapedTensor``),
and files with different per-file tensor shapes only unify
through ``ArrowVariableShapedTensor``. When the caller schema
contains *any* extension column we skip the pin entirely and
let pyarrow infer per-file — downstream concat handles the
heterogeneous blocks. Losing the all-null promotion in this
narrow case is acceptable; the combination of an all-null
first file *and* an extension column is uncommon, whereas
reading multiple files with variable-shape tensors is a
supported V1 feature.
"""
if self._schema is None:
return None
if any(isinstance(f.type, pa.ExtensionType) for f in self._schema):
return None
partition_keys = (
set(self._partition_parser._scheme.field_names or [])
if self._partition_parser is not None
else set()
)
synthesized = {INCLUDE_PATHS_COLUMN_NAME}
if self._include_row_hash:
# ``row_hash`` is synthesized post-read, and the schema's type
# (``uint64``) may not match the on-disk column's type when a
# file already carries a ``row_hash`` column. Strip it from the
# dataset schema so pyarrow doesn't try to cast.
synthesized.add(ROW_HASH_COLUMN_NAME)
fields = [
f
for f in self._schema
if f.name not in partition_keys and f.name not in synthesized
]
return pa.schema(fields) if fields else None
def _broadcast_partition_value(
self, name: str, value: Any, num_rows: int
) -> pa.Array:
"""Broadcast a single path-derived partition value to ``num_rows``,
casting to the caller-supplied schema's field type if set.
Values are stringified first (``PathPartitionParser`` in
``explicit`` mode can return arrow-scalar-like non-strings) and
then cast to the target type, so ``Partitioning(field_types=
{"year": int})`` still promotes them correctly.
"""
str_val = None if value is None else str(value)
arr = pa.repeat(pa.scalar(str_val, type=pa.string()), num_rows)
if self._schema is not None:
idx = self._schema.get_field_index(name)
if idx != -1 and self._schema.field(idx).type != pa.string():
arr = arr.cast(self._schema.field(idx).type)
return arr
def read(self, input_split: FileManifest) -> Iterator[pa.Table]:
"""Read data from the input bucket and yield Arrow tables.
This method is called on workers to perform the actual read operation.
It should respect all pushdowns configured on this reader.
Args:
input_split: Work unit describing what data to read.
Yields:
pa.Table: PyArrow Tables containing the read data.
"""
if len(input_split) == 0:
return
# Dedupe paths before handing them to pyarrow. When chunking is on,
# a manifest can carry multiple rows per file (each describing a
# different row-group slice); pyarrow only needs one fragment per
# file, and ``_get_fragments_to_read`` then fans out chunk-level
# sub-fragments using the per-row chunk metadata.
paths = list(dict.fromkeys(list(input_split.paths)))
filesystem = self._filesystem or LocalFileSystem()
# Build a ``pds.Dataset`` over *all* manifest paths so pyarrow's
# listing + column metadata is shared, but then iterate its
# fragments one at a time. ``dataset.scanner(fragments=...)``
# at the aggregate level would force a cross-fragment cast —
# which breaks variable-shape tensor extensions where each
# file has its own ``ArrowTensorTypeV2(shape=...)``. Per-
# fragment scanners let pyarrow use the native per-file type,
# and downstream concat handles unification.
dataset = pds.dataset(
source=paths,
format=self._make_format(),
filesystem=filesystem,
schema=self._file_dataset_schema,
ignore_prefixes=self._ignore_prefixes,
)
# Split the requested columns into ones the on-disk file has
# (pyarrow reads these) and ones we need to synthesize post-read
# (hive partition keys, "path"). ``self._columns is None`` means
# "no projection" — read every file column and synthesize every
# available partition/path column.
on_disk_column_names = set(dataset.schema.names)
if self._columns is None:
columns_to_read_from_file: Optional[List[str]] = None
columns_to_synthesize: Optional[Set[str]] = None
else:
columns_to_read_from_file = [
c for c in self._columns if c in on_disk_column_names
]
columns_to_synthesize = set(self._columns) - on_disk_column_names
scanner_kwargs = {
"columns": columns_to_read_from_file,
"filter": (
self._predicate.to_pyarrow() if self._predicate is not None else None
),
"batch_size": self._resolve_batch_size(dataset),
"batch_readahead": _ARROW_SCANNER_BATCH_READAHEAD,
}
scanner_kwargs.update(self._arrow_scanner_kwargs())
rows_read = 0
for table, fragment_path, fragment_row_offset in self._read_fragment_batches(
dataset, scanner_kwargs, input_split
):
if self._limit is not None:
if rows_read >= self._limit:
break
if len(table) > self._limit - rows_read:
table = table.slice(0, self._limit - rows_read)
# Build the list of (name, value) pairs to synthesize from
# the fragment path: hive partitions + optional ``path``.
derived_items: List[Tuple[str, Any]] = []
if self._partition_parser is not None:
derived_items.extend(self._partition_parser(fragment_path).items())
if self._include_paths:
derived_items.append((INCLUDE_PATHS_COLUMN_NAME, fragment_path))
for name, value in derived_items:
if (
columns_to_synthesize is not None
and name not in columns_to_synthesize
):
continue
if name in table.column_names:
# When the caller schema names a partition key, pyarrow
# expects it in every file and fills it with nulls when
# absent (the hive-typical case). Drop that placeholder
# so the path-derived value below replaces it.
table = table.drop([name])
table = table.append_column(
name,
self._broadcast_partition_value(name, value, table.num_rows),
)
# Skip when projection pushdown has narrowed ``columns`` to
# exclude ``row_hash`` — the projection below would just drop it.
if self._include_row_hash and (
columns_to_synthesize is None
or ROW_HASH_COLUMN_NAME in columns_to_synthesize
):
hashes = _compute_row_hashes(
fragment_path, fragment_row_offset, table.num_rows
)
if ROW_HASH_COLUMN_NAME in table.column_names:
table = table.drop([ROW_HASH_COLUMN_NAME])
table = table.append_column(
ROW_HASH_COLUMN_NAME, pa.array(hashes, type=pa.uint64())
)
if self._columns is not None:
# Project/reorder to the caller's requested column order;
# drop any that weren't produced (matches V1's lenient
# behavior). Always select — an empty projection must
# narrow the table to zero columns so the stub-column
# guard below handles row preservation.
produced = set(table.column_names)
projected = [c for c in self._columns if c in produced]
table = table.select(projected)
if table.num_columns == 0 and table.num_rows > 0:
# Guards against ``pa.concat_tables`` collapsing rows
# when a batch has zero columns (e.g., empty projection
# for a count query). The stub column is dropped by
# downstream projections.
table = table.append_column(
_BATCH_SIZE_PRESERVING_STUB_COL_NAME,
pa.nulls(table.num_rows),
)
self._on_batch_read(table)
rows_read += len(table)
yield table
def _resolve_batch_size(self, dataset: pds.Dataset) -> int:
"""Return the batch size to use for scanning.
Subclasses can override this to implement adaptive batch sizing.
"""
return self._batch_size
def _on_batch_read(self, table: pa.Table) -> None:
"""Hook called after each batch is read.
Subclasses can override this to update internal state (e.g., refine
batch size estimates from actual data).
"""
pass
def _arrow_scanner_kwargs(self) -> dict:
"""Additional keyword arguments passed to ``pds.Dataset.scanner()``.
Subclasses override this to inject format-specific options.
"""
return {}
def _make_format(self) -> Any:
"""Format passed to ``pds.dataset(format=...)``.
Defaults to the format string (e.g. ``"parquet"``); subclasses
override to return a configured ``pds.FileFormat`` instance when
format-specific options (read options, fragment scan options) need
to be threaded through.
"""
return self._format.value
def _get_fragments_to_read(
self,
dataset: pds.Dataset,
manifest: FileManifest,
) -> List[Tuple[pds.Fragment, int]]:
"""Return ``(fragment, file_row_offset)`` pairs to scan for this
manifest.
``file_row_offset`` is the cumulative pre-filter row count of all
rows in the underlying file that precede this fragment. It seeds
the per-fragment hashing offset so chunked sub-fragments of the
same file produce unique ``_compute_row_hashes`` keys instead of
colliding on ``(path, 0, n)``.
Default impl returns one ``(fragment, 0)`` per file in the dataset
(paths are deduped in :meth:`read` before the dataset is built).
Subclasses that support per-row chunk metadata
(e.g. :class:`ParquetFileReader`) override this to fan a single
file fragment out into N sub-fragments — one per row-group slice —
based on :attr:`FileManifest.file_chunk_metadatas`, each paired
with its starting row offset in the file.
"""
return [(fragment, 0) for fragment in dataset.get_fragments()]
def _read_fragment_batches(
self,
dataset: pds.Dataset,
scanner_kwargs: dict,
manifest: FileManifest,
) -> Iterator[Tuple[pa.Table, str, int]]:
"""Yield non-empty (table, fragment_path, fragment_row_offset) triples.
``fragment_row_offset`` is the post-filter row position of the first
row of ``table`` within its fragment. ``iterate_with_retry`` skips
already-yielded items on retry, so ``offset`` reflects only the
rows that actually surface to the caller — matching V1 row-hash
semantics even when a fragment fails partway through.
Retry is scoped per-fragment: if a fragment fails mid-read, only
that fragment is re-read (skipping batches already yielded).
Wrapping the whole manifest in a single retry would re-iterate
fragments that already succeeded and double-emit their batches.
Each fragment gets its own scanner so pyarrow uses the native
per-file schema. A cross-fragment scanner would force a unified
schema cast, which refuses extension-to-extension conversion
(e.g. variable-shape tensors). V1 ``ParquetDatasource`` follows
the same per-fragment pattern via ``fragment.to_batches``.
When ``RAY_DATA_READ_FILES_NUM_THREADS > 1`` and
``execution_options.preserve_order`` is False, fragments are
read concurrently via :func:`make_async_gen`. We still pass
``preserve_ordering=True`` so concurrent reads emit blocks in
fragment order; otherwise Ray Data task retries (block
reconstruction) could produce a different block sequence.
``make_async_gen`` consumes the whole input iterator up front
when preserving order. That is acceptable here because the input
is the finite fragment manifest from ``_get_fragments_to_read``,
which we materialize below anyway. File data is still read lazily
by the worker threads.
"""
ctx = DataContext.get_current()
# ``preserve_ordering=True`` would drain the input iterator
# eagerly anyway, so materialize once here to (a) cap
# ``num_workers`` at the actual fragment count and (b) avoid
# an early-fallback when the manifest has a single fragment.
# Subclasses (e.g. ``ParquetFileReader``) override
# ``_get_fragments_to_read`` to fan out chunk-level
# sub-fragments from the manifest's chunk metadata.
fragments_with_offsets = self._get_fragments_to_read(dataset, manifest)
if not fragments_with_offsets:
return
num_workers = min(_DEFAULT_NUM_THREADS, len(fragments_with_offsets))
if num_workers <= 1 or ctx.execution_options.preserve_order:
yield from self._read_fragments_sequential(
iter(fragments_with_offsets), scanner_kwargs
)
return
# Set `preserve_ordering=True` to ensure deterministic output ordering.
# This is required so that Ray Data task retries (block reconstruction)
yield from make_async_gen(
base_iterator=iter(fragments_with_offsets),
fn=partial(self._read_fragments_sequential, scanner_kwargs=scanner_kwargs),
preserve_ordering=True,
num_workers=num_workers,
)
def _read_fragments_sequential(
self,
fragments_with_offsets: Iterator[Tuple[pds.Fragment, int]],
scanner_kwargs: dict,
) -> Iterator[Tuple[pa.Table, str, int]]:
"""Read each fragment in ``fragments_with_offsets`` in order, yielding
``(table, fragment_path, fragment_row_offset)`` triples.
Each input pair is ``(fragment, file_row_offset)``. The yielded
``fragment_row_offset`` starts at ``file_row_offset`` (the row
position of the fragment's first row within its underlying file)
and accumulates per yielded batch, so the per-fragment row-hash
math in :meth:`read` keys off the right window even when chunking
fans one file into multiple sub-fragments sharing ``fragment.path``.
``iterate_with_retry`` is scoped to a single fragment so a
transient I/O failure only re-reads the failing file (skipping
batches already yielded), not the whole input.
This is the per-worker body for the threaded path in
:meth:`_read_fragment_batches` (one thread per call, each
consuming a disjoint slice of fragments via ``make_async_gen``)
and is also the entire read loop for the sequential path.
"""
ctx = DataContext.get_current()
for fragment, file_row_offset in fragments_with_offsets:
offset = file_row_offset
for table in iterate_with_retry(
partial(self._iter_fragment_tables, fragment, scanner_kwargs),
f"read fragment {fragment.path}",
match=ctx.retried_io_errors,
):
if table.num_rows > 0:
yield table, fragment.path, offset
offset += table.num_rows
def _iter_fragment_tables(
self,
fragment: pds.Fragment,
scanner_kwargs: dict,
) -> Iterator[pa.Table]:
"""Yield Arrow tables for a single fragment.
Subclasses override this to swap in a format-specific reader for
fragments that don't fit the default scanner-based path (e.g.
Parquet's ARROW-5030 nested-type fallback).
When a non-extension caller schema is available we pin it at the
scanner so pyarrow null-fills any column the unified schema names
but the fragment lacks (V1 parity — ``ParquetDatasource`` passes
``read_schema`` to ``fragment.to_batches``). Falling back to the
per-fragment ``physical_schema`` preserves the variable-shape
tensor escape hatch already encoded in ``_file_dataset_schema``.
"""
fragment_schema = (
self._file_dataset_schema
if self._file_dataset_schema is not None
else fragment.physical_schema
)
scanner = fragment.scanner(**scanner_kwargs, schema=fragment_schema)
for tagged in scanner.scan_batches():
yield pa.Table.from_batches(batches=[tagged.record_batch])
@@ -0,0 +1,146 @@
from abc import ABC, abstractmethod
from typing import Optional
import numpy as np
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
from ray.data._internal.datasource_v2.readers.file_reader import FileReader
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
from ray.data.block import BlockAccessor
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class InMemorySizeEstimator(ABC):
@abstractmethod
def estimate_in_memory_sizes(self, manifest: FileManifest) -> np.ndarray:
"""Estimate the in-memory sizes of the paths in the given manifest.
Some `FilePartitioner` implementations use this method to ensure that each
read task receives an appropriate amount of data. To ensure that file listing
is efficient, this method must be cheap to call, on average.
Args:
manifest: A manifest containing the paths and on-disk sizes of the files.
Returns:
The estimated in-memory sizes of the data in bytes.
"""
...
@DeveloperAPI
class SamplingInMemorySizeEstimator(InMemorySizeEstimator):
"""Estimates in-memory sizes by reading files.
This class estimates the in-memory size of files by multiplying the on-disk
size by an estimated encoding ratio. If an instance hasn't estimated an encoding
ratio yet, it'll read a file to estimate it. Otherwise, it'll use the previously
estimated encoding ratio.
TODO: This approach doesn't work well for formats that produce multiple batches
(because we assume a 1:1 encoding ratio) or for formats that vary in encoding
ratios (e.g. videos).
"""
def __init__(self, reader: "FileReader"):
self._reader = reader
self._encoding_ratio = None
def estimate_in_memory_sizes(self, manifest: FileManifest) -> np.ndarray:
assert np.all(manifest.file_sizes >= 0)
for path, file_size in zip(manifest.paths, manifest.file_sizes):
if self._encoding_ratio is None:
# Estimating the encoding ratio can be expensive since it requires
# reading the file. So, we only estimate the encoding ratio if we don't
# already have one.
self._encoding_ratio = self._estimate_encoding_ratio(path, file_size)
break
if self._encoding_ratio is None:
# If we couldn't estimate the encoding ratio, assume a 1:1 encoding ratio.
return manifest.file_sizes
else:
return manifest.file_sizes * self._encoding_ratio
def _estimate_encoding_ratio(
self,
path: str,
file_size: int,
) -> Optional[float]:
"""
Estimate the encoding ratio (in-memory size / on-disk size) for a file.
Args:
path: The path to the file.
file_size: The on-disk size of the file/chunk in bytes.
Returns:
The estimated encoding ratio of the file, or `None` if the ratio can't
be estimated.
"""
# If the file is empty, we can't estimate the encoding ratio.
if not file_size:
return None
# Use ``None`` chunk metadata: the size estimator reads the file whole
# to estimate the encoding ratio; chunk-level splitting is irrelevant here.
manifest = FileManifest.construct_manifest(
[path],
[file_size],
[None],
)
batches = self._reader.read(manifest)
try:
first_batch = next(batches)
except StopIteration:
# If there's no data, we can't estimate the encoding ratio.
return None
try:
# Try to read a second batch. If it succeeds, it means the file contains
# multiple batches.
next(batches)
except StopIteration:
# Each file contains exactly one batch.
builder = DelegatingBlockBuilder()
builder.add_batch(first_batch)
block = builder.build()
in_memory_size = BlockAccessor.for_block(block).size_bytes()
else:
# Each file contains multiple batches.
#
# NOTE: To avoid reading the entire file to estimate the encoding ratio,
# we assume the file is 1:1 encoded. We can't return `None` because if
# all files contain multiple batches, then we'd try to re-estimate the
# encoding ratio for every file, and that'd be very expensive.
in_memory_size = file_size
return in_memory_size / file_size
# Default Parquet encoding ratio: in-memory is ~5x on-disk size.
# Parquet uses columnar compression and encoding, so Arrow in-memory
# representation is significantly larger than the on-disk format.
PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT = 5
@DeveloperAPI
class ParquetInMemorySizeEstimator(InMemorySizeEstimator):
"""Estimates in-memory sizes for Parquet files using a fixed encoding ratio.
Parquet files are typically much smaller on disk than in memory due to
columnar compression and encoding. This estimator applies a constant
ratio (default 5x) to avoid the overhead of reading file metadata or
sampling data, which can be slow for Parquet files and hurt startup time.
"""
def __init__(self, encoding_ratio: float = PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT):
self._encoding_ratio = encoding_ratio
def estimate_in_memory_sizes(self, manifest: FileManifest) -> np.ndarray:
return self._encoding_ratio * manifest.file_sizes

Some files were not shown because too many files have changed in this diff Show More