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
@@ -0,0 +1,93 @@
"""Expose logical operator classes in ray.data._internal.logical.operators."""
from ray.data._internal.logical.operators.all_to_all_operator import (
AbstractAllToAll,
Aggregate,
RandomizeBlocks,
RandomShuffle,
Repartition,
Sort,
)
from ray.data._internal.logical.operators.count_operator import Count
from ray.data._internal.logical.operators.from_operators import (
AbstractFrom,
FromArrow,
FromBlocks,
FromItems,
FromNumpy,
FromPandas,
)
from ray.data._internal.logical.operators.input_data_operator import InputData
from ray.data._internal.logical.operators.join_operator import Join, JoinSide, JoinType
from ray.data._internal.logical.operators.map_operator import (
CSE_TEMP_COLUMN_PREFIX,
AbstractMap,
AbstractUDFMap,
Filter,
FlatMap,
MapBatches,
MapRows,
Project,
StreamingRepartition,
)
from ray.data._internal.logical.operators.n_ary_operator import (
Mix,
MixStoppingCondition,
NAry,
Union,
Zip,
)
from ray.data._internal.logical.operators.one_to_one_operator import (
AbstractOneToOne,
Download,
Limit,
)
from ray.data._internal.logical.operators.read_operator import (
ListFiles,
Read,
ReadFiles,
)
from ray.data._internal.logical.operators.streaming_split_operator import StreamingSplit
from ray.data._internal.logical.operators.write_operator import Write
__all__ = [
"AbstractAllToAll",
"AbstractFrom",
"AbstractMap",
"AbstractOneToOne",
"AbstractUDFMap",
"Aggregate",
"CSE_TEMP_COLUMN_PREFIX",
"Count",
"Download",
"Filter",
"FlatMap",
"FromArrow",
"FromBlocks",
"FromItems",
"FromNumpy",
"FromPandas",
"InputData",
"Join",
"JoinSide",
"JoinType",
"Limit",
"ListFiles",
"MapBatches",
"MapRows",
"Mix",
"MixStoppingCondition",
"NAry",
"Project",
"RandomShuffle",
"RandomizeBlocks",
"Read",
"ReadFiles",
"Repartition",
"Sort",
"StreamingRepartition",
"StreamingSplit",
"Union",
"Write",
"Zip",
]
@@ -0,0 +1,265 @@
from dataclasses import InitVar, dataclass, field, replace
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data._internal.planner.exchange.interfaces import ExchangeTaskSpec
from ray.data._internal.planner.exchange.shuffle_task_spec import ShuffleTaskSpec
from ray.data._internal.planner.exchange.sort_task_spec import SortKey, SortTaskSpec
from ray.data._internal.random_config import RandomSeedConfig
from ray.data.aggregate import AggregateFn
from ray.data.block import BlockMetadata
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"AbstractAllToAll",
"Aggregate",
"RandomShuffle",
"RandomizeBlocks",
"Repartition",
"Sort",
]
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractAllToAll(LogicalOperator):
"""Abstract class for logical operators should be converted to physical
AllToAllOperator.
"""
def __init__(
self,
input_dependencies: List[LogicalOperator],
sub_progress_bar_names: Optional[List[str]] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
*,
name: Optional[str] = None,
):
"""Initialize an ``AbstractAllToAll`` logical operator.
Args:
input_dependencies: The operators preceding this operator in the plan DAG.
The outputs of these operators will be the inputs to this operator.
sub_progress_bar_names: Optional sub-stage progress bar names for this
operator.
ray_remote_args: Args to provide to :func:`ray.remote`.
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
"""
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
if name is not None:
object.__setattr__(self, "_name", name)
object.__setattr__(self, "ray_remote_args", ray_remote_args or {})
object.__setattr__(self, "sub_progress_bar_names", sub_progress_bar_names)
@dataclass(frozen=True, repr=False, eq=False)
class RandomizeBlocks(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for randomize_block_order."""
seed_config: Optional[RandomSeedConfig] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.seed_config is None:
object.__setattr__(self, "seed_config", RandomSeedConfig())
object.__setattr__(self, "_name", "RandomizeBlockOrder")
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Randomizing block order doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class RandomShuffle(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for random_shuffle."""
name: InitVar[str] = "RandomShuffle"
seed_config: Optional[RandomSeedConfig] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self, name: str):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.seed_config is None:
object.__setattr__(self, "seed_config", RandomSeedConfig())
if self.sub_progress_bar_names is None:
object.__setattr__(
self,
"sub_progress_bar_names",
[
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
],
)
object.__setattr__(self, "_name", name)
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return replace(self, input_dependencies=input_dependencies, name=self._name)
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Random shuffle doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Repartition(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for repartition."""
shuffle: bool = False
keys: Optional[List[str]] = None
sort: bool = False
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
num_outputs: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.shuffle:
sub_progress_bar_names = [
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
]
else:
sub_progress_bar_names = [
ShuffleTaskSpec.SPLIT_REPARTITION_SUB_PROGRESS_BAR_NAME,
]
object.__setattr__(self, "sub_progress_bar_names", sub_progress_bar_names)
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return replace(
self,
input_dependencies=input_dependencies,
num_outputs=self.num_outputs,
)
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Repartition doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Sort(
AbstractAllToAll,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for sort."""
sort_key: SortKey
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
object.__setattr__(
self,
"sub_progress_bar_names",
[
SortTaskSpec.SORT_SAMPLE_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
],
)
def infer_metadata(self) -> "BlockMetadata":
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata()
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Sort doesn't affect filtering correctness
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Aggregate(AbstractAllToAll):
"""Logical operator for aggregate."""
key: Optional[str | List[str]]
aggs: List[AggregateFn]
num_partitions: Optional[int] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
sub_progress_bar_names: Optional[List[str]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
object.__setattr__(
self,
"sub_progress_bar_names",
[
SortTaskSpec.SORT_SAMPLE_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.MAP_SUB_PROGRESS_BAR_NAME,
ExchangeTaskSpec.REDUCE_SUB_PROGRESS_BAR_NAME,
],
)
def infer_schema(self) -> Optional["Schema"]:
# Output = key field(s) from input schema + one field per aggregator.
# Returns None if any aggregator can't declare its output field
# (callers fall back to limit(1)).
import pyarrow as pa
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
input_schema = self.input_dependencies[0].infer_schema()
if not isinstance(input_schema, pa.Schema):
return None
fields: List[pa.Field] = []
if self.key is not None:
keys = self.key if isinstance(self.key, list) else [self.key]
for key in keys:
try:
fields.append(input_schema.field(key))
except (KeyError, TypeError, ValueError):
return None
for agg in self.aggs:
f = agg.output_field(input_schema)
if f is None:
return None
fields.append(f)
return pa.schema(fields)
@@ -0,0 +1,35 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from ray.data._internal.logical.interfaces import LogicalOperator
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"Count",
]
@dataclass(frozen=True, repr=False, eq=False)
class Count(LogicalOperator):
"""Logical operator that represents counting the number of rows in inputs.
Physical operators that implement this logical operator should produce one or more
rows with a single column named `Count.COLUMN_NAME`. When you sum the values in
this column, you should get the total number of rows in the dataset.
"""
COLUMN_NAME = "__num_rows"
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
def infer_schema(self) -> Optional["Schema"]:
# Fixed output: one row per partial count with a single ``__num_rows``
# int64 column.
import pyarrow as pa
return pa.schema([pa.field(self.COLUMN_NAME, pa.int64(), nullable=False)])
@@ -0,0 +1,133 @@
import abc
import functools
from dataclasses import InitVar, dataclass, field
from typing import TYPE_CHECKING, List, Optional, Union
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle
from ray.data._internal.logical.interfaces import LogicalOperator, SourceOperator
from ray.data._internal.util import unify_ref_bundles_schema
from ray.data.block import (
Block,
BlockMetadata,
BlockMetadataWithSchema,
)
from ray.types import ObjectRef
if TYPE_CHECKING:
import pyarrow as pa
ArrowTable = Union["pa.Table", bytes]
__all__ = [
"AbstractFrom",
"FromArrow",
"FromBlocks",
"FromItems",
"FromNumpy",
"FromPandas",
]
@dataclass(frozen=True, repr=False, eq=False)
class AbstractFrom(LogicalOperator, SourceOperator, metaclass=abc.ABCMeta):
"""Abstract logical operator for `from_*`."""
input_blocks: InitVar[List[ObjectRef[Block]]]
input_metadata: InitVar[List[BlockMetadataWithSchema]]
input_data: List[RefBundle] = field(init=False)
_input_dependencies: list[LogicalOperator] = field(
init=False, repr=False, default_factory=list
)
def __post_init__(
self,
input_blocks: List[ObjectRef[Block]],
input_metadata: List[BlockMetadataWithSchema],
):
assert len(input_blocks) == len(input_metadata), (
len(input_blocks),
len(input_metadata),
)
# `owns_blocks` is False because this op may be shared by multiple Datasets.
object.__setattr__(
self,
"input_data",
[
RefBundle(
[BlockEntry(input_blocks[i], input_metadata[i])],
owns_blocks=False,
schema=input_metadata[i].schema,
)
for i in range(len(input_blocks))
],
)
def output_data(self) -> Optional[List[RefBundle]]:
return self.input_data
@property
def num_outputs(self) -> Optional[int]:
return len(self.input_data)
@functools.cached_property
def _cached_output_metadata(self) -> BlockMetadata:
return BlockMetadata(
num_rows=self._num_rows(),
size_bytes=self._size_bytes(),
input_files=None,
exec_stats=None,
)
def _num_rows(self):
if all(bundle.num_rows() is not None for bundle in self.input_data):
return sum(bundle.num_rows() for bundle in self.input_data)
else:
return None
def _size_bytes(self):
metadata = [m for bundle in self.input_data for m in bundle.metadata]
if all(m.size_bytes is not None for m in metadata):
return sum(m.size_bytes for m in metadata)
else:
return None
def infer_metadata(self) -> BlockMetadata:
return self._cached_output_metadata
def infer_schema(self):
return unify_ref_bundles_schema(self.input_data)
def is_lineage_serializable(self) -> bool:
# This operator isn't serializable because it contains ObjectRefs.
return False
class FromItems(AbstractFrom):
"""Logical operator for `from_items`."""
pass
class FromBlocks(AbstractFrom):
"""Logical operator for `from_blocks`."""
pass
class FromNumpy(AbstractFrom):
"""Logical operator for `from_numpy`."""
pass
class FromArrow(AbstractFrom):
"""Logical operator for `from_arrow`."""
pass
class FromPandas(AbstractFrom):
"""Logical operator for `from_pandas`."""
pass
@@ -0,0 +1,64 @@
import functools
from dataclasses import dataclass, field
from typing import List, Optional
from ray.data._internal.execution.interfaces import RefBundle
from ray.data._internal.logical.interfaces import LogicalOperator, SourceOperator
from ray.data._internal.util import unify_schemas_with_validation
from ray.data.block import BlockMetadata
__all__ = [
"InputData",
]
@dataclass(frozen=True, repr=False, eq=False)
class InputData(LogicalOperator, SourceOperator):
"""Logical operator for input data.
This may hold cached blocks from a previous Dataset execution.
"""
input_data: List[RefBundle]
_input_dependencies: list[LogicalOperator] = field(
init=False, repr=False, default_factory=list
)
def output_data(self) -> Optional[List[RefBundle]]:
return self.input_data
@property
def num_outputs(self) -> Optional[int]:
return len(self.input_data)
def infer_metadata(self) -> BlockMetadata:
return self._cached_output_metadata
@functools.cached_property
def _cached_output_metadata(self) -> BlockMetadata:
return BlockMetadata(
num_rows=self._num_rows(),
size_bytes=self._size_bytes(),
input_files=None,
exec_stats=None,
)
def _num_rows(self):
if all(bundle.num_rows() is not None for bundle in self.input_data):
return sum(bundle.num_rows() for bundle in self.input_data)
else:
return None
def _size_bytes(self):
metadata = [m for bundle in self.input_data for m in bundle.metadata]
if all(m.size_bytes is not None for m in metadata):
return sum(m.size_bytes for m in metadata)
else:
return None
def infer_schema(self):
return unify_schemas_with_validation([data.schema for data in self.input_data])
def is_lineage_serializable(self) -> bool:
# This operator isn't serializable because it contains ObjectRefs.
return False
@@ -0,0 +1,263 @@
from dataclasses import InitVar, dataclass, field, replace
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data._internal.logical.operators.n_ary_operator import NAry
if TYPE_CHECKING:
from ray.data.dataset import Schema
from ray.data.expressions import Expr
__all__ = [
"Join",
"JoinSide",
"JoinType",
]
class JoinType(Enum):
INNER = "inner"
LEFT_OUTER = "left_outer"
RIGHT_OUTER = "right_outer"
FULL_OUTER = "full_outer"
LEFT_SEMI = "left_semi"
RIGHT_SEMI = "right_semi"
LEFT_ANTI = "left_anti"
RIGHT_ANTI = "right_anti"
class JoinSide(Enum):
"""Represents which side of a join to push a predicate to.
The enum values correspond to branch indices (0 for left, 1 for right).
"""
LEFT = 0
RIGHT = 1
@dataclass(frozen=True, repr=False, eq=False)
class Join(NAry, LogicalOperatorSupportsPredicatePassThrough):
"""Logical operator for join."""
left_input_op: InitVar[LogicalOperator]
right_input_op: InitVar[LogicalOperator]
join_type: Union[JoinType, str]
left_key_columns: Tuple[str]
right_key_columns: Tuple[str]
num_partitions: int
left_columns_suffix: Optional[str] = None
right_columns_suffix: Optional[str] = None
partition_size_hint: Optional[int] = None
aggregator_ray_remote_args: Optional[Dict[str, Any]] = None
_input_dependencies: list[LogicalOperator] = field(init=False, repr=False)
def __post_init__(
self,
left_input_op: LogicalOperator,
right_input_op: LogicalOperator,
):
try:
join_type_enum = JoinType(self.join_type)
except ValueError:
raise ValueError(
f"Invalid join type: '{self.join_type}'. "
f"Supported join types are: {', '.join(jt.value for jt in JoinType)}."
)
object.__setattr__(self, "join_type", join_type_enum)
object.__setattr__(
self,
"_input_dependencies",
[left_input_op, right_input_op],
)
# Mirror the reduce-task remote args onto `ray_remote_args` so fusing a downstream map
# into this join's reduce task respects the reduce task's actual args.
object.__setattr__(
self, "ray_remote_args", dict(self.aggregator_ray_remote_args or {})
)
@property
def num_outputs(self) -> Optional[int]:
return self.num_partitions
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return replace(
self,
left_input_op=input_dependencies[0],
right_input_op=input_dependencies[1],
num_partitions=self.num_outputs,
)
@staticmethod
def _validate_schemas(
left_op_schema: "Schema",
right_op_schema: "Schema",
left_key_column_names: Tuple[str],
right_key_column_names: Tuple[str],
):
def _col_names_as_str(keys: Sequence[str]):
keys_joined = ", ".join(map(lambda k: f"'{k}'", keys))
return f"[{keys_joined}]"
if len(left_key_column_names) < 1:
raise ValueError(
f"At least 1 column name to join on has to be provided (got "
f"{_col_names_as_str(left_key_column_names)})"
)
if len(left_key_column_names) != len(right_key_column_names):
raise ValueError(
f"Number of columns provided for left and right datasets has to match "
f"(got {_col_names_as_str(left_key_column_names)} and "
f"{_col_names_as_str(right_key_column_names)})"
)
def _get_key_column_types(schema: "Schema", keys: Tuple[str]):
return (
[
_type
for name, _type in zip(schema.names, schema.types)
if name in keys
]
if schema
else None
)
right_op_key_cols = _get_key_column_types(
right_op_schema, left_key_column_names
)
left_op_key_cols = _get_key_column_types(left_op_schema, right_key_column_names)
if left_op_key_cols != right_op_key_cols:
raise ValueError(
f"Key columns are expected to be present and have the same types "
"in both left and right operands of the join operation: "
f"left has {left_op_schema}, but right has {right_op_schema}"
)
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
return PredicatePassThroughBehavior.CONDITIONAL
def which_side_to_push_predicate(
self, predicate_expr: "Expr"
) -> Optional[JoinSide]:
"""Determine which side of the join to push a predicate to.
Returns the side to push to, or None if pushdown is not safe.
Predicate pushdown is safe for:
- INNER: Can push to either side
- LEFT_OUTER/SEMI/ANTI: Can push to left side (preserved/output side)
- RIGHT_OUTER/SEMI/ANTI: Can push to right side (preserved/output side)
- FULL_OUTER: Cannot push (both sides can generate nulls)
The predicate must reference columns from exactly one side of the join,
OR reference only join key columns that all exist on one side.
"""
# Get predicate columns and schemas
predicate_columns = self._get_referenced_columns(predicate_expr)
left_schema = self.input_dependencies[0].infer_schema()
right_schema = self.input_dependencies[1].infer_schema()
if not left_schema or not right_schema:
return None
# Get column sets for each side
left_columns = set(left_schema.names)
right_columns = set(right_schema.names)
left_join_keys = set(self.left_key_columns)
right_join_keys = set(self.right_key_columns)
# Get pushdown rules for this join type
can_push_left, can_push_right = self._get_pushdown_rules()
# Check if predicate can be evaluated on left side
# Condition: ALL predicate columns must exist on left (either as regular columns or join keys)
can_evaluate_on_left = predicate_columns.issubset(
left_columns
) or predicate_columns.issubset(left_join_keys)
if can_evaluate_on_left and can_push_left:
return JoinSide.LEFT
# Check if predicate can be evaluated on right side
can_evaluate_on_right = predicate_columns.issubset(
right_columns
) or predicate_columns.issubset(right_join_keys)
if can_evaluate_on_right and can_push_right:
return JoinSide.RIGHT
# Cannot push down
return None
def _get_pushdown_rules(self) -> Tuple[bool, bool]:
"""Get pushdown rules for the current join type.
Returns:
Tuple of (can_push_left, can_push_right) indicating which sides
can accept predicate pushdown for this join type.
"""
pushdown_rules = {
JoinType.INNER: (True, True),
JoinType.LEFT_OUTER: (True, False),
JoinType.RIGHT_OUTER: (False, True),
JoinType.LEFT_SEMI: (True, False),
JoinType.RIGHT_SEMI: (False, True),
JoinType.LEFT_ANTI: (True, False),
JoinType.RIGHT_ANTI: (False, True),
JoinType.FULL_OUTER: (False, False),
}
return pushdown_rules.get(self.join_type, (False, False))
def _get_referenced_columns(self, expr: "Expr") -> set[str]:
"""Extract all column names referenced in an expression."""
from ray.data._internal.planner.plan_expression.expression_visitors import (
_ColumnReferenceCollector,
)
visitor = _ColumnReferenceCollector()
visitor.visit(expr)
return set(visitor.get_column_refs())
def infer_schema(self) -> Optional["Schema"]:
"""Infer the output schema by running the shared ``join_tables``
utility on empty tables built from the input schemas. The same
utility runs at execution time, so plan-time and runtime schemas
agree by construction.
"""
import pyarrow as pa
from ray.data._internal.execution.operators.join import join_tables
left_schema = self.input_dependencies[0].infer_schema()
right_schema = self.input_dependencies[1].infer_schema()
if not isinstance(left_schema, pa.Schema) or not isinstance(
right_schema, pa.Schema
):
return None
join_type_enum = (
self.join_type
if isinstance(self.join_type, JoinType)
else JoinType(self.join_type)
)
try:
joined = join_tables(
left_schema.empty_table(),
right_schema.empty_table(),
join_type=join_type_enum,
left_key_col_names=tuple(self.left_key_columns),
right_key_col_names=tuple(self.right_key_columns),
left_columns_suffix=self.left_columns_suffix,
right_columns_suffix=self.right_columns_suffix,
)
except (pa.ArrowTypeError, pa.ArrowInvalid, pa.ArrowKeyError, ValueError):
return None
return joined.schema
@@ -0,0 +1,552 @@
import functools
import inspect
import logging
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Literal,
Optional,
Union,
)
from ray.data._internal.compute import ComputeStrategy, TaskPoolStrategy
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data._internal.logical.operators.one_to_one_operator import AbstractOneToOne
from ray.data.block import UserDefinedFunction
from ray.data.expressions import (
Expr,
StarExpr,
expand_star_exprs,
exprlist_to_fields,
)
from ray.data.preprocessor import Preprocessor
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"AbstractMap",
"AbstractUDFMap",
"Filter",
"FlatMap",
"MapBatches",
"MapRows",
"Project",
"StreamingRepartition",
"CSE_TEMP_COLUMN_PREFIX",
]
logger = logging.getLogger(__name__)
CSE_TEMP_COLUMN_PREFIX = "__ray_data_cse_"
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractMap(AbstractOneToOne):
"""Abstract class for logical operators that should be converted to physical
MapOperator.
"""
def __init__(
self,
name: Optional[str] = None,
input_dependencies: Optional[List[LogicalOperator]] = None,
*,
can_modify_num_rows: bool,
min_rows_per_bundled_input: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
compute: Optional[ComputeStrategy] = None,
):
"""Initialize an ``AbstractMap`` logical operator that will later
be converted into a physical ``MapOperator``.
Args:
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
input_dependencies: The operators preceding this operator in the plan
DAG. The outputs of these operators will be the inputs to this
operator.
can_modify_num_rows: Whether the operator can change the row count. False if
# of input rows = # of output rows. True otherwise.
min_rows_per_bundled_input: Minimum number of rows a single bundle of
blocks passed on to the task must possess.
ray_remote_args: Args to provide to :func:`ray.remote`.
ray_remote_args_fn: A function that returns a dictionary of remote
args passed to each map worker. The purpose of this argument is
to generate dynamic arguments for each actor/task, and it will
be called each time prior to initializing the worker. Args
returned from this dict always override the args in
``ray_remote_args``. Note: this is an advanced, experimental
feature.
compute: The compute strategy, either ``TaskPoolStrategy`` (default)
to use Ray tasks, or ``ActorPoolStrategy`` to use an
autoscaling actor pool.
"""
super().__init__(
input_dependencies=input_dependencies,
can_modify_num_rows=can_modify_num_rows,
name=name,
)
object.__setattr__(
self, "min_rows_per_bundled_input", min_rows_per_bundled_input
)
object.__setattr__(self, "ray_remote_args", ray_remote_args or {})
object.__setattr__(self, "ray_remote_args_fn", ray_remote_args_fn)
object.__setattr__(self, "compute", compute or TaskPoolStrategy())
object.__setattr__(self, "per_block_limit", None)
def set_per_block_limit(self, per_block_limit: int):
object.__setattr__(self, "per_block_limit", per_block_limit)
def _get_args(self) -> Dict[str, Any]:
args = super()._get_args()
for key in [
"can_modify_num_rows",
"min_rows_per_bundled_input",
"ray_remote_args",
"ray_remote_args_fn",
"compute",
"per_block_limit",
]:
args[f"_{key}"] = getattr(self, key)
return args
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractUDFMap(AbstractMap):
"""Abstract class for logical operators performing a UDF that should be converted
to physical MapOperator.
"""
fn: UserDefinedFunction
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
def __init__(
self,
name: str,
input_dependencies: List[LogicalOperator],
fn: UserDefinedFunction,
*,
can_modify_num_rows: bool,
fn_args: Optional[Iterable[Any]] = None,
fn_kwargs: Optional[Dict[str, Any]] = None,
fn_constructor_args: Optional[Iterable[Any]] = None,
fn_constructor_kwargs: Optional[Dict[str, Any]] = None,
min_rows_per_bundled_input: Optional[int] = None,
compute: Optional[ComputeStrategy] = None,
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
):
"""Initialize AbstractUDFMap.
Args:
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
input_dependencies: The operators preceding this operator in the plan DAG.
The outputs of these operators will be the inputs to this operator.
fn: User-defined function to be called.
can_modify_num_rows: Whether the UDF can change the row count. False if
# of input rows = # of output rows. True otherwise.
fn_args: Arguments to `fn`.
fn_kwargs: Keyword arguments to `fn`.
fn_constructor_args: Arguments to provide to the initializor of `fn` if
`fn` is a callable class.
fn_constructor_kwargs: Keyword Arguments to provide to the initializor of
`fn` if `fn` is a callable class.
min_rows_per_bundled_input: The target number of rows to pass to
``MapOperator._add_bundled_input()``.
compute: The compute strategy, either ``TaskPoolStrategy`` (default) to use
Ray tasks, or ``ActorPoolStrategy`` to use an autoscaling actor pool.
ray_remote_args_fn: A function that returns a dictionary of remote args
passed to each map worker. The purpose of this argument is to generate
dynamic arguments for each actor/task, and will be called each time
prior to initializing the worker. Args returned from this dict will
always override the args in ``ray_remote_args``. Note: this is an
advanced, experimental feature.
ray_remote_args: Args to provide to :func:`ray.remote`.
"""
name = self._get_operator_name(name, fn)
super().__init__(
name,
input_dependencies,
can_modify_num_rows=can_modify_num_rows,
min_rows_per_bundled_input=min_rows_per_bundled_input,
ray_remote_args=ray_remote_args,
compute=compute,
)
object.__setattr__(self, "fn", fn)
object.__setattr__(self, "fn_args", fn_args)
object.__setattr__(self, "fn_kwargs", fn_kwargs)
object.__setattr__(self, "fn_constructor_args", fn_constructor_args)
object.__setattr__(self, "fn_constructor_kwargs", fn_constructor_kwargs)
object.__setattr__(self, "ray_remote_args_fn", ray_remote_args_fn)
def _get_operator_name(self, op_name: str, fn: UserDefinedFunction):
"""Gets the Operator name including the map `fn` UDF name."""
# If the input `fn` is a Preprocessor, the
# name is simply the name of the Preprocessor class.
if inspect.ismethod(fn) and isinstance(fn.__self__, Preprocessor):
return fn.__self__.__class__.__name__
# Otherwise, it takes the form of `<MapOperator class>(<UDF name>)`,
# e.g. `MapBatches(my_udf)`.
try:
if inspect.isclass(fn):
# callable class
return f"{op_name}({fn.__name__})"
elif inspect.ismethod(fn):
# class method
return f"{op_name}({fn.__self__.__class__.__name__}.{fn.__name__})"
elif inspect.isfunction(fn):
# normal function or lambda function.
return f"{op_name}({fn.__name__})"
elif isinstance(fn, functools.partial):
# functools.partial
return f"{op_name}({fn.func.__name__})"
else:
# callable object.
return f"{op_name}({fn.__class__.__name__})"
except AttributeError as e:
logger.error("Failed to get name of UDF %s: %s", fn, e)
return "<unknown>"
@dataclass(frozen=True, repr=False, eq=False)
class MapBatches(AbstractUDFMap):
"""Logical operator for map_batches."""
fn: UserDefinedFunction
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = False
batch_size: Union[Optional[int], Literal["auto"]] = None
batch_format: Optional[str] = "default"
zero_copy_batch: bool = True
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
min_rows_per_bundled_input: Optional[int] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
self._get_operator_name(self.__class__.__name__, self.fn),
)
@dataclass(frozen=True, repr=False, eq=False)
class MapRows(AbstractUDFMap):
"""Logical operator for map."""
fn: UserDefinedFunction
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=False)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(self, "_name", self._get_operator_name("Map", self.fn))
@dataclass(frozen=True, repr=False, eq=False)
class Filter(AbstractUDFMap, LogicalOperatorPreservesSchema):
"""Logical operator for filter."""
predicate_expr: Optional[Expr] = None
fn: Optional[UserDefinedFunction] = None
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
provided_params = sum([self.fn is not None, self.predicate_expr is not None])
if provided_params != 1:
raise ValueError(
"Exactly one of 'fn', or 'predicate_expr' must be provided "
f"(received fn={self.fn}, predicate_expr={self.predicate_expr})"
)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
self._get_operator_name(self.__class__.__name__, self.fn),
)
def is_expression_based(self) -> bool:
return self.predicate_expr is not None
def _get_operator_name(self, op_name: str, fn: UserDefinedFunction):
if self.is_expression_based():
# Get a concise inline string representation of the expression
from ray.data._internal.planner.plan_expression.expression_visitors import (
_InlineExprReprVisitor,
)
expr_str = _InlineExprReprVisitor().visit(self.predicate_expr)
# Truncate only the final result if too long
max_length = 60
if len(expr_str) > max_length:
expr_str = expr_str[: max_length - 3] + "..."
return f"{op_name}({expr_str})"
return super()._get_operator_name(op_name, fn)
@dataclass(frozen=True, repr=False, eq=False)
class Project(AbstractMap, LogicalOperatorSupportsPredicatePassThrough):
"""Logical operator for all Projection Operations."""
exprs: list["Expr"]
_common_sub_exprs: list["Expr"] = field(
default_factory=list,
repr=False,
kw_only=True,
)
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
compute: Optional[ComputeStrategy] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
can_modify_num_rows: bool = field(init=False, default=False)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
batch_size: Optional[int] = field(init=False, default=None)
batch_format: str = field(init=False, default="pyarrow")
zero_copy_batch: bool = field(init=False, default=True)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
# Eagerly expand ``StarExpr`` when the input schema is known. By the time
# optimizer rules see this op, the projection list contains only
# explicit ``col()`` and computed expressions, no ``StarExpr``.
# When the input schema is opaque (e.g., upstream UDF map), the
# ``StarExpr`` is preserved and runtime ``eval_projection``
# expands it on a per-block basis.
import pyarrow as pa
input_schema = self.input_dependencies[0].infer_schema()
if isinstance(input_schema, pa.Schema):
object.__setattr__(
self, "exprs", expand_star_exprs(self.exprs, input_schema)
)
if self.compute is None:
object.__setattr__(
self,
"compute",
self._detect_and_get_compute_strategy(self.get_all_exprs()),
)
for expr in self.exprs:
if expr.name is None and not isinstance(expr, StarExpr):
raise TypeError(
"All Project expressions must be named (use .alias(name) or col(name)), "
"or be a star() expression."
)
def _detect_and_get_compute_strategy(self, exprs: list["Expr"]) -> ComputeStrategy:
"""Detect if expressions contain callable class UDFs and return appropriate compute strategy.
If any expression contains a callable class UDF, returns ActorPoolStrategy.
Otherwise returns TaskPoolStrategy.
"""
from ray.data._internal.planner.plan_expression.expression_visitors import (
_CallableClassUDFCollector,
)
# Check all expressions for callable class UDFs
for expr in exprs:
collector = _CallableClassUDFCollector()
collector.visit(expr)
if collector.get_callable_class_udfs():
# Found at least one callable class UDF - use actor semantics
from ray.data._internal.compute import ActorPoolStrategy
return ActorPoolStrategy(min_size=1, max_size=None)
# No callable class UDFs found - use task-based execution
from ray.data._internal.compute import TaskPoolStrategy
return TaskPoolStrategy()
def has_star_expr(self) -> bool:
return self.get_star_expr() is not None
def is_idempotent(self) -> bool:
"""Return whether every output expression of this projection is idempotent."""
return all(expr.is_idempotent() for expr in self.exprs)
def get_star_expr(self) -> Optional[StarExpr]:
"""Check if this projection contains a star() expression."""
for expr in self.exprs:
if isinstance(expr, StarExpr):
return expr
return None
def get_common_sub_exprs(self) -> list["Expr"]:
return self._common_sub_exprs
def get_all_exprs(self) -> list["Expr"]:
"""Both projection expressions and common expressions"""
return [*self._common_sub_exprs, *self.exprs]
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
return PredicatePassThroughBehavior.PASSTHROUGH_WITH_SUBSTITUTION
def get_column_substitutions(self) -> Optional[Dict[str, str]]:
"""Returns the column renames from this projection.
Maps source_column_name -> output_column_name. This is what we need
to rebind predicates when pushing through.
"""
# Reuse the existing logic from projection pushdown
from ray.data._internal.logical.rules.projection_pushdown import (
_extract_input_columns_renaming_mapping,
)
rename_map = _extract_input_columns_renaming_mapping(self.exprs)
return rename_map if rename_map else None
def infer_schema(self) -> Optional["Schema"]:
import pyarrow as pa
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
input_schema = self.input_dependencies[0].infer_schema()
# Only Arrow schemas are supported for static expression resolution.
# (``PandasBlockSchema`` chains fall back to ``limit(1)`` execution.)
if not isinstance(input_schema, pa.Schema):
return None
working_schema = input_schema
for common_expr in self.get_common_sub_exprs():
field = common_expr.to_field(working_schema)
if field is None:
return None
working_schema = working_schema.append(field)
fields = exprlist_to_fields(self.exprs, working_schema)
if fields is None:
return None
if self.get_common_sub_exprs():
temp_names = {expr.name for expr in self.get_common_sub_exprs()}
fields = [field for field in fields if field.name not in temp_names]
return pa.schema(fields)
@dataclass(frozen=True, repr=False, eq=False)
class FlatMap(AbstractUDFMap):
"""Logical operator for flat_map."""
fn: UserDefinedFunction
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
fn_args: Optional[Iterable[Any]] = None
fn_kwargs: Optional[Dict[str, Any]] = None
fn_constructor_args: Optional[Iterable[Any]] = None
fn_constructor_kwargs: Optional[Dict[str, Any]] = None
compute: Optional[ComputeStrategy] = None
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
self._get_operator_name(self.__class__.__name__, self.fn),
)
@dataclass(frozen=True, repr=False, eq=False)
class StreamingRepartition(
AbstractMap,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for streaming repartition operation.
Args:
input_dependencies: The operators preceding this operator in the plan DAG.
target_num_rows_per_block: The target number of rows per block granularity for
streaming repartition.
strict: If True, guarantees that all output blocks, except for the last one,
will have exactly target_num_rows_per_block rows. If False, uses best-effort
bundling and may produce at most one block smaller than target_num_rows_per_block
per input block without forcing exact sizes through block splitting.
Defaults to False.
"""
target_num_rows_per_block: int
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
strict: bool = False
can_modify_num_rows: bool = field(init=False, default=False)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
ray_remote_args_fn: Optional[Callable[[], Dict[str, Any]]] = None
compute: Optional[ComputeStrategy] = None
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if self.target_num_rows_per_block <= 0:
raise ValueError(
"target_num_rows_per_block must be positive for streaming repartition, "
f"got {self.target_num_rows_per_block}"
)
if self.compute is None:
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self,
"_name",
f"StreamingRepartition[num_rows_per_block={self.target_num_rows_per_block},strict={self.strict}]",
)
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# StreamingRepartition only re-bundles rows into different block sizes.
# It doesn't modify schema or filter rows, so filters can safely pass through.
return PredicatePassThroughBehavior.PASSTHROUGH
@@ -0,0 +1,211 @@
import enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorUnifiesInputSchemas,
PredicatePassThroughBehavior,
)
from ray.util.annotations import PublicAPI
if TYPE_CHECKING:
from ray.data.block import Schema
__all__ = [
"Mix",
"MixStoppingCondition",
"NAry",
"Union",
"Zip",
]
@PublicAPI(stability="alpha")
class MixStoppingCondition(enum.Enum):
"""Controls when a mix pipeline terminates.
STOP_ON_SHORTEST: Pipeline ends when the shortest dataset is exhausted.
Other datasets are truncated.
STOP_ON_LONGEST_DROP: Pipeline ends when the longest dataset is exhausted.
Shorter datasets drop out once exhausted; later batches are drawn
entirely from longer datasets.
"""
STOP_ON_SHORTEST = "stop_on_shortest"
STOP_ON_LONGEST_DROP = "stop_on_longest_drop"
def estimate_num_mix_outputs(
per_input_counts: List[Optional[int]],
weights: List[float],
stopping_condition: MixStoppingCondition,
) -> Optional[int]:
"""Estimate total output count for a mix operation.
Used by both the logical and physical Mix operators to estimate
num_outputs_total / num_output_rows_total.
"""
if any(c is None for c in per_input_counts):
return None
if stopping_condition == MixStoppingCondition.STOP_ON_LONGEST_DROP:
return sum(per_input_counts)
elif stopping_condition == MixStoppingCondition.STOP_ON_SHORTEST:
# Limited by whichever input runs out first relative to its weight.
total_weight = sum(weights)
return min(
int(count / (w / total_weight))
for count, w in zip(per_input_counts, weights)
)
else:
raise ValueError(f"Unknown stopping condition: {stopping_condition}")
@dataclass(frozen=True, repr=False, eq=False, init=False)
class NAry(LogicalOperator):
"""Base class for n-ary operators, which take multiple input operators."""
def __init__(
self,
input_dependencies: List[LogicalOperator],
):
"""Initialize the n-ary operator.
Args:
input_dependencies: The input operators.
"""
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return self.__class__(input_dependencies)
@dataclass(frozen=True, repr=False, eq=False, init=False)
class Zip(NAry):
"""Logical operator for zip."""
_input_dependencies: List[LogicalOperator] = field(init=False, repr=False)
def __init__(
self,
input_dependencies: List[LogicalOperator],
):
for input_op in input_dependencies:
assert isinstance(input_op, LogicalOperator), input_op
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
def estimated_num_outputs(self):
total_num_outputs = 0
for input in self.input_dependencies:
num_outputs = input.estimated_num_outputs()
if num_outputs is None:
return None
total_num_outputs = max(total_num_outputs, num_outputs)
return total_num_outputs
def infer_schema(self) -> Optional["Schema"]:
# Reuse the runtime ``BlockAccessor.zip`` so plan-time and
# execution-time schemas agree by construction (same column
# suffixing rules, etc.).
import pyarrow as pa
from ray.data.block import BlockAccessor
input_schemas = [op.infer_schema() for op in self.input_dependencies]
if not input_schemas or not all(
isinstance(s, pa.Schema) for s in input_schemas
):
return None
try:
combined = input_schemas[0].empty_table()
for s in input_schemas[1:]:
combined = BlockAccessor.for_block(combined).zip(s.empty_table())
except (pa.ArrowTypeError, pa.ArrowInvalid):
return None
return combined.schema
@dataclass(frozen=True, repr=False, eq=False, init=False)
class Mix(NAry, LogicalOperatorUnifiesInputSchemas):
"""Logical operator for weighted dataset mixing."""
_name: str = field(init=False, repr=False)
_input_dependencies: List[LogicalOperator] = field(init=False, repr=False)
weights: List[float] = field(init=False, repr=False)
stopping_condition: MixStoppingCondition = field(init=False, repr=False)
def __init__(
self,
input_dependencies: List[LogicalOperator],
*,
weights: List[float],
stopping_condition: MixStoppingCondition = MixStoppingCondition.STOP_ON_SHORTEST,
):
if len(input_dependencies) != len(weights):
raise ValueError(
f"Number of input operators ({len(input_dependencies)}) must match "
f"number of weights ({len(weights)})."
)
if any(weight <= 0 for weight in weights):
raise ValueError(f"Weights must be positive. Got weights: {weights}")
for input_op in input_dependencies:
assert isinstance(input_op, LogicalOperator), input_op
object.__setattr__(self, "_name", self.__class__.__name__)
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
object.__setattr__(self, "weights", weights)
object.__setattr__(self, "stopping_condition", stopping_condition)
def estimated_num_outputs(self) -> Optional[int]:
if self.stopping_condition == MixStoppingCondition.STOP_ON_SHORTEST:
return None
return estimate_num_mix_outputs(
[op.estimated_num_outputs() for op in self.input_dependencies],
self.weights,
self.stopping_condition,
)
def _with_new_input_dependencies(
self, input_dependencies: List[LogicalOperator]
) -> LogicalOperator:
return self.__class__(
input_dependencies,
weights=self.weights,
stopping_condition=self.stopping_condition,
)
@dataclass(frozen=True, repr=False, eq=False, init=False)
class Union(
NAry,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorUnifiesInputSchemas,
):
"""Logical operator for union."""
_input_dependencies: List[LogicalOperator] = field(init=False, repr=False)
def __init__(
self,
input_dependencies: List[LogicalOperator],
):
for input_op in input_dependencies:
assert isinstance(input_op, LogicalOperator), input_op
object.__setattr__(self, "_input_dependencies", list(input_dependencies))
def estimated_num_outputs(self):
total_num_outputs = 0
for input in self.input_dependencies:
num_outputs = input.estimated_num_outputs()
if num_outputs is None:
return None
total_num_outputs += num_outputs
return total_num_outputs
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Union allows pushing filter into each branch
return PredicatePassThroughBehavior.PUSH_INTO_BRANCHES
@@ -0,0 +1,185 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
LogicalOperatorSupportsPredicatePassThrough,
PredicatePassThroughBehavior,
)
from ray.data.block import BlockMetadata
if TYPE_CHECKING:
import pyarrow
from ray.data.block import Schema
__all__ = [
"AbstractOneToOne",
"Download",
"Limit",
]
@dataclass(frozen=True, repr=False, eq=False, init=False)
class AbstractOneToOne(LogicalOperator):
"""Abstract class for one-to-one logical operators, which
have one input and one output dependency.
"""
def __init__(
self,
input_dependencies: Optional[List[LogicalOperator]],
can_modify_num_rows: bool,
*,
name: Optional[str] = None,
):
"""Initialize an AbstractOneToOne operator.
Args:
input_dependencies: The operators preceding this operator in the plan DAG.
The outputs of these operators will be the inputs to this operator.
can_modify_num_rows: Whether the UDF can change the row count. False if
# of input rows = # of output rows. True otherwise.
name: Name for this operator. This is the name that will appear when
inspecting the logical plan of a Dataset.
"""
object.__setattr__(self, "_input_dependencies", list(input_dependencies or []))
if name is not None:
object.__setattr__(self, "_name", name)
object.__setattr__(self, "can_modify_num_rows", can_modify_num_rows)
def infer_metadata(self) -> BlockMetadata:
"""Best-effort output metadata derived from the single input dependency.
One-to-one operators that don't modify the row count (e.g. ``Project``)
preserve the row count and don't grow the data, so the input's row count
and byte size are valid output estimates -- an upper bound for the common
column-selection case, where projecting away columns only shrinks the
data. Operators that can modify the row count (e.g. ``Filter``,
``FlatMap``) can't reuse these estimates, so they fall back to ``None``.
Propagating ``size_bytes`` is what keeps size-dependent planning correct
when a one-to-one op is pushed below a join/shuffle (e.g. by projection
pushdown): otherwise ``_try_estimate_output_bytes`` sees ``size_bytes=None``
and the hash-shuffle aggregator silently falls back to a fixed default
memory reservation instead of one derived from the dataset size.
"""
if len(self.input_dependencies) != 1:
return BlockMetadata(
num_rows=None, size_bytes=None, input_files=None, exec_stats=None
)
input_meta = self.input_dependencies[0].infer_metadata()
if self.can_modify_num_rows:
return BlockMetadata(
num_rows=None,
size_bytes=None,
input_files=input_meta.input_files,
exec_stats=None,
)
return BlockMetadata(
num_rows=input_meta.num_rows,
size_bytes=input_meta.size_bytes,
input_files=input_meta.input_files,
exec_stats=None,
)
@dataclass(frozen=True, repr=False, eq=False)
class Limit(
AbstractOneToOne,
LogicalOperatorSupportsPredicatePassThrough,
LogicalOperatorPreservesSchema,
):
"""Logical operator for limit."""
limit: int
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = field(init=False, default=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
object.__setattr__(self, "_name", f"limit={self.limit}")
def infer_metadata(self) -> BlockMetadata:
return BlockMetadata(
num_rows=self._num_rows(),
size_bytes=None,
input_files=self._input_files(),
exec_stats=None,
)
def _num_rows(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
input_rows = self.input_dependencies[0].infer_metadata().num_rows
if input_rows is not None:
return min(input_rows, self.limit)
else:
return None
def _input_files(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(self.input_dependencies[0], LogicalOperator)
return self.input_dependencies[0].infer_metadata().input_files
def predicate_passthrough_behavior(self) -> PredicatePassThroughBehavior:
# Pushing filter through limit is safe: Filter(Limit(data, n), pred)
# becomes Limit(Filter(data, pred), n), which filters earlier
return PredicatePassThroughBehavior.PASSTHROUGH
@dataclass(frozen=True, repr=False, eq=False)
class Download(AbstractOneToOne):
"""Logical operator for download operation.
Supports downloading from multiple URI columns in a single operation.
"""
uri_column_names: List[str]
output_bytes_column_names: List[str]
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
filesystem: Optional["pyarrow.fs.FileSystem"] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = field(init=False, default=False)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if len(self.uri_column_names) != len(self.output_bytes_column_names):
raise ValueError(
f"Number of URI columns ({len(self.uri_column_names)}) must match "
f"number of output columns ({len(self.output_bytes_column_names)})"
)
def infer_metadata(self) -> BlockMetadata:
# Download preserves the row count but appends a binary blob column per
# requested output, so the output is *larger* than the input -- often by
# orders of magnitude. Propagating the input's ``size_bytes`` (as the
# row-preserving default does) would be a misleading under-estimate that
# could starve downstream size-dependent planning (e.g. hash-shuffle
# aggregator memory reservation). Keep the accurate row count and input
# files, but report ``size_bytes=None`` since we can't estimate the
# downloaded bytes ahead of execution.
meta = super().infer_metadata()
return BlockMetadata(
num_rows=meta.num_rows,
size_bytes=None,
input_files=meta.input_files,
exec_stats=None,
)
def infer_schema(self) -> Optional["Schema"]:
# Output = input schema with one binary column appended per requested
# output name. The runtime (``download_bytes_threaded``) always appends
# via ``add_column`` without removing any pre-existing column of the
# same name, so name collisions produce duplicate columns here too.
import pyarrow as pa
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
input_schema = self.input_dependencies[0].infer_schema()
if not isinstance(input_schema, pa.Schema):
return None
fields = list(input_schema)
for name in self.output_bytes_column_names:
fields.append(pa.field(name, pa.binary(), nullable=True))
return pa.schema(fields)
@@ -0,0 +1,476 @@
import functools
import math
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union
from ray.data._internal.compute import ComputeStrategy
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorSupportsPredicatePushdown,
LogicalOperatorSupportsProjectionPushdown,
SourceOperator,
)
from ray.data._internal.logical.operators.map_operator import AbstractMap
from ray.data.block import (
Block,
BlockMetadata,
BlockMetadataWithSchema,
)
from ray.data.context import DataContext
from ray.data.datasource.datasource import Datasource, Reader
from ray.data.expressions import Expr
if TYPE_CHECKING:
import pyarrow as pa
from pyarrow.fs import FileSystem
from ray.data._internal.datasource_v2.listing.file_indexer import FileIndexer
from ray.data._internal.datasource_v2.partitioners.file_partitioner import (
FilePartitioner,
)
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
from ray.data.datasource.file_based_datasource import FileShuffleConfig
from ray.data.datasource.partitioning import PathPartitionFilter
__all__ = [
"ListFiles",
"Read",
"ReadFiles",
]
@dataclass(frozen=True, repr=False, eq=False)
class Read(
AbstractMap,
SourceOperator,
LogicalOperatorSupportsProjectionPushdown,
LogicalOperatorSupportsPredicatePushdown,
):
"""Logical operator for read."""
datasource: Datasource
datasource_or_legacy_reader: Union[Datasource, Reader]
parallelism: int
num_outputs: Optional[int] = None
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
compute: Optional[ComputeStrategy] = None
detected_parallelism: Optional[int] = None
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
ray_remote_args_fn: None = field(init=False, default=None)
per_block_limit: Optional[int] = None
_input_dependencies: list = field(init=False, repr=False, default_factory=list)
def __post_init__(self):
if self.compute is None:
from ray.data._internal.compute import TaskPoolStrategy
object.__setattr__(self, "compute", TaskPoolStrategy())
if self.ray_remote_args is None:
object.__setattr__(self, "ray_remote_args", {})
object.__setattr__(self, "_name", f"Read{self.datasource.get_name()}")
object.__setattr__(self, "_input_dependencies", [])
def output_data(self):
return None
def set_detected_parallelism(self, parallelism: int) -> "Read":
"""
Set the true parallelism that should be used during execution. This
should be specified by the user or detected by the optimizer.
"""
object.__setattr__(self, "detected_parallelism", parallelism)
return self
def get_detected_parallelism(self) -> int:
"""
Get the true parallelism that should be used during execution.
"""
return self.detected_parallelism
def estimated_num_outputs(self) -> Optional[int]:
return self.num_outputs or self._estimate_num_outputs()
def infer_metadata(self) -> BlockMetadata:
"""A ``BlockMetadata`` that represents the aggregate metadata of the outputs.
This method gets metadata from the read tasks. It doesn't trigger any actual
execution.
"""
return self._cached_output_metadata.metadata
def infer_schema(self):
return self._cached_output_metadata.schema
def _estimate_num_outputs(self) -> Optional[int]:
metadata = self._cached_output_metadata.metadata
# Handle edge-case of empty dataset
if metadata.size_bytes == 0:
return 0
target_max_block_size = DataContext.get_current().target_max_block_size
# In either case of
# - Total byte-size estimate not available
# - Target max-block-size not being configured
#
# We fallback to estimating number of outputs to be equivalent to the
# number of input files being read (if any)
if metadata.size_bytes is None or target_max_block_size is None:
# NOTE: If there's no input files specified, return the count (could be 0)
return (
len(metadata.input_files) if metadata.input_files is not None else None
)
# Otherwise, estimate total number of blocks from estimated total
# byte size
return math.ceil(metadata.size_bytes / target_max_block_size)
@functools.cached_property
def _cached_output_metadata(self) -> "BlockMetadataWithSchema":
# Legacy datasources might not implement `get_read_tasks`.
if self.datasource.should_create_reader:
empty_meta = BlockMetadata(None, None, None, None)
return BlockMetadataWithSchema.from_metadata(empty_meta, schema=None)
# HACK: Try to get a single read task to get the metadata.
read_tasks = self.datasource.get_read_tasks(1)
if len(read_tasks) == 0:
# If there are no read tasks, the dataset is probably empty.
empty_meta = BlockMetadata(
num_rows=0,
size_bytes=0,
input_files=None,
exec_stats=None,
)
return BlockMetadataWithSchema.from_metadata(empty_meta, schema=None)
# `get_read_tasks` isn't guaranteed to return exactly one read task.
metadata = [read_task.metadata for read_task in read_tasks]
if all(meta.num_rows is not None for meta in metadata):
num_rows = sum(meta.num_rows for meta in metadata)
original_num_rows = num_rows
# Apply per-block limit if set
if self.per_block_limit is not None:
num_rows = min(num_rows, self.per_block_limit)
else:
num_rows = None
original_num_rows = None
if all(meta.size_bytes is not None for meta in metadata):
size_bytes = sum(meta.size_bytes for meta in metadata)
# Pro-rate the byte size if we applied a row limit
if (
self.per_block_limit is not None
and original_num_rows is not None
and original_num_rows > 0
):
size_bytes = int(size_bytes * (num_rows / original_num_rows))
else:
size_bytes = None
input_files = []
for meta in metadata:
if meta.input_files is not None:
input_files.extend(meta.input_files)
meta = BlockMetadata(
num_rows=num_rows,
size_bytes=size_bytes,
input_files=input_files,
exec_stats=None,
)
schemas = [
read_task.schema for read_task in read_tasks if read_task.schema is not None
]
from ray.data._internal.util import unify_schemas_with_validation
schema = None
if schemas:
schema = unify_schemas_with_validation(schemas)
return BlockMetadataWithSchema.from_metadata(meta, schema=schema)
def supports_projection_pushdown(self) -> bool:
return self.datasource.supports_projection_pushdown()
def get_projection_map(self) -> Optional[Dict[str, str]]:
return self.datasource.get_projection_map()
def apply_projection(
self,
projection_map: Optional[Dict[str, str]],
) -> "Read":
projected_datasource = self.datasource.apply_projection(projection_map)
return replace(
self,
datasource=projected_datasource,
datasource_or_legacy_reader=projected_datasource,
num_outputs=self.num_outputs,
)
def supports_predicate_pushdown(self) -> bool:
return self.datasource.supports_predicate_pushdown()
def get_current_predicate(self) -> Optional[Expr]:
return self.datasource.get_current_predicate()
def apply_predicate(self, predicate_expr: Expr) -> "Read":
predicated_datasource = self.datasource.apply_predicate(predicate_expr)
# A datasource returns its own instance to signal "no pushdown applied"
# (e.g. ``ParquetDatasource`` does this when a mixed-column conjunct
# leaves a residual). Preserve identity here so ``PredicatePushdown``'s
# ``result_op is input_op`` no-op check keeps the ``Filter`` above.
if self.datasource is predicated_datasource:
return self
return replace(
self,
datasource=predicated_datasource,
datasource_or_legacy_reader=predicated_datasource,
num_outputs=self.num_outputs,
)
@dataclass(frozen=True, repr=False, eq=False)
class ReadFiles(
AbstractMap,
LogicalOperatorSupportsProjectionPushdown,
LogicalOperatorSupportsPredicatePushdown,
):
"""Logical operator for DataSourceV2 reads.
Consumes ``FileManifest`` blocks produced by a :class:`ListFiles`
source operator upstream. Owns the :class:`Scanner` (with any pushed
column/predicate/limit state) and the post-pushdown schema. Listing,
shuffling, and size-balanced bucketing happen in the upstream op;
this op's physical planner just reads each manifest bucket via
``scanner.create_reader().read(manifest)``.
V2 reads never rename columns at the read stage — column renaming
is always handled by a ``Project`` operator above ``ReadFiles``.
This simplifies projection and predicate pushdown by eliminating
the "predicate above uses new names, predicate below uses old
names" rebinding dance.
"""
datasource_name: str
scanner: "Scanner"
schema: "pa.Schema"
parallelism: int
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
compute: Optional[ComputeStrategy] = None
# Optional post-read block transform. Used by ``read_parquet``'s
# ``_block_udf`` and ``tensor_column_schema`` (the latter is folded
# into a ``_block_udf`` by ``_resolve_parquet_args`` before it gets
# here). Applied in ``plan_read_files_op.do_read`` after each
# table is read.
block_udf: Optional[Callable[[Block], Block]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False, default=None)
ray_remote_args_fn: None = field(init=False, default=None)
# Declared so the inherited ``AbstractMap._get_args`` can resolve it; V2
# limit pushdown is applied via ``scanner.push_limit`` (see
# ``LimitPushdownRule._apply_per_block_limit_if_supported``), not this field.
per_block_limit: Optional[int] = field(init=False, default=None)
num_outputs: Optional[int] = None
_name: str = field(init=False, repr=False)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
assert isinstance(
self.input_dependencies[0], LogicalOperator
), self.input_dependencies[0]
if self.compute is None:
from ray.data._internal.compute import TaskPoolStrategy
object.__setattr__(self, "compute", TaskPoolStrategy())
if self.ray_remote_args is None:
object.__setattr__(self, "ray_remote_args", {})
object.__setattr__(self, "_name", f"ReadFiles{self.datasource_name}")
def infer_schema(self) -> "pa.Schema":
# Scanner schema reflects any applied projection pushdown
# (``scanner.prune_columns`` / empty projection from
# ``select_columns([])``); the stored ``self.schema`` is the
# unprojected one and only used for construction.
schema = self.scanner.read_schema()
# When a ``block_udf`` is attached (e.g. ``read_parquet`` was
# called with ``tensor_column_schema`` or ``_block_udf``), probe
# its effect on the schema so downstream consumers see the
# post-transform column types. Mirrors V1 ``ParquetDatasource``'s
# dummy-table trick. Falls back to the scanner schema if the
# probe fails — the UDF may require a non-empty input.
if self.block_udf is not None:
try:
transformed = self.block_udf(schema.empty_table()).schema
schema = transformed.with_metadata(schema.metadata)
except Exception:
pass
return schema
def infer_metadata(self) -> BlockMetadata:
"""Return empty metadata; downstream callers fall back to materialization.
Prior ``ReadFiles`` versions reached into a driver-side file cache to
compute size hints. With listing owned by an upstream
``ListFiles`` op, metadata-for-sizing is computed from the
materialized manifest at execution time — the logical op doesn't
try to pre-estimate.
"""
return BlockMetadata(None, None, None, None)
def supports_projection_pushdown(self) -> bool:
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsColumnPruning,
)
return isinstance(self.scanner, SupportsColumnPruning)
def get_projection_map(self) -> Optional[Dict[str, str]]:
if not self.supports_projection_pushdown():
return None
columns = self.scanner.pruned_column_names()
if columns is None:
return None
# The read stage never renames at the read layer; the projection
# map is always an identity (original name -> original name).
# Renaming is always carried by an ``AliasExpr`` in a ``Project``
# operator above the read.
return {name: name for name in columns}
def apply_projection(
self,
projection_map: Optional[Dict[str, str]],
) -> "ReadFiles":
if projection_map is None:
return self
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsColumnPruning,
)
assert isinstance(self.scanner, SupportsColumnPruning)
# V2 reads only prune columns at the read stage. Any rename info
# in ``projection_map`` is dropped here; the optimizer rule keeps
# a ``Project`` op on top of ``ReadFiles`` to carry rename
# ``AliasExpr`` instances. Only the keys (column names to keep)
# are used.
new_scanner = self.scanner.prune_columns(list(projection_map.keys()))
return replace(self, scanner=new_scanner)
def supports_predicate_pushdown(self) -> bool:
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsFilterPushdown,
)
return isinstance(self.scanner, SupportsFilterPushdown)
def get_current_predicate(self) -> Optional[Expr]:
return getattr(self.scanner, "predicate", None)
def apply_predicate(self, predicate_expr: Expr) -> LogicalOperator:
from ray.data._internal.datasource.parquet_datasource import (
_split_predicate_by_columns,
)
from ray.data._internal.datasource_v2.logical_optimizers import (
SupportsFilterPushdown,
SupportsPartitionPruning,
)
from ray.data._internal.logical.operators.map_operator import Filter
assert isinstance(self.scanner, SupportsFilterPushdown)
partition_cols: Set[str] = (
self.scanner.partition_columns
if isinstance(self.scanner, SupportsPartitionPruning)
else set()
)
if not partition_cols:
new_scanner, _residual = self.scanner.push_filters(predicate_expr)
return replace(self, scanner=new_scanner)
split = _split_predicate_by_columns(predicate_expr, partition_cols)
if split.data_predicate is None and split.partition_predicate is None:
# Entire predicate is residual (e.g. a single mixed-column
# ``OR``); nothing safe to push. Returning ``self`` tells
# ``PredicatePushdown`` to keep the ``Filter`` above us.
return self
new_scanner = self.scanner
if split.partition_predicate is not None:
new_scanner = new_scanner.prune_partitions(split.partition_predicate)
if split.data_predicate is not None:
new_scanner, _residual = new_scanner.push_filters(split.data_predicate)
new_op = replace(self, scanner=new_scanner)
if split.residual_predicate is None:
return new_op
# Residual conjuncts can't be pushed through either ``push_filters``
# (pyarrow only binds data columns) or ``prune_partitions`` (path
# parser only binds partition columns), so re-emit them as a
# ``Filter`` above the new ``ReadFiles``. Without this, we'd keep
# the splittable parts and silently drop the residual — letting
# rows through that the original predicate would have rejected.
return Filter(
predicate_expr=split.residual_predicate, input_dependencies=[new_op]
)
@dataclass(frozen=True, repr=False, eq=False)
class ListFiles(LogicalOperator, SourceOperator):
"""Logical source op that lists files and yields ``FileManifest`` blocks.
Extracted from the prior monolithic ``ReadFiles`` so listing, shuffling,
and size-balanced bucketing live in one place (see
:func:`ray.data._internal.planner.plan_list_files_op.plan_list_files_op`).
Downstream, ``ReadFiles`` consumes the manifest blocks produced here.
"""
paths: List[str]
file_indexer: "FileIndexer"
filesystem: "FileSystem"
# Original user-supplied paths. Lineage-tracking pins this to the
# caller's intent rather than the resolved absolute paths.
source_paths: List[str]
file_partitioner: Optional["FilePartitioner"] = None
file_extensions: Optional[List[str]] = None
partition_filter: Optional["PathPartitionFilter"] = None
# A factory (not a stored config) so the shuffle seed is re-sampled
# per execution when the config asks for it.
shuffle_config_factory: Callable[[], Optional["FileShuffleConfig"]] = field(
default=lambda: None
)
_name: str = field(init=False, repr=False)
_input_dependencies: List[LogicalOperator] = field(
init=False, repr=False, default_factory=list
)
def __post_init__(self):
object.__setattr__(self, "_name", self.__class__.__name__)
def output_data(self) -> Optional[list]:
return None
def infer_schema(self) -> "pa.Schema":
# ``FileManifest`` columns are fixed: __path, __file_size.
import pyarrow as pa
from ray.data._internal.datasource_v2.listing.file_manifest import (
FILE_SIZE_COLUMN_NAME,
PATH_COLUMN_NAME,
)
return pa.schema(
[
pa.field(PATH_COLUMN_NAME, pa.string()),
pa.field(FILE_SIZE_COLUMN_NAME, pa.int64()),
]
)
@@ -0,0 +1,27 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
)
if TYPE_CHECKING:
from ray.data._internal.execution.interfaces import NodeIdStr
__all__ = [
"StreamingSplit",
]
@dataclass(frozen=True, repr=False, eq=False)
class StreamingSplit(LogicalOperatorPreservesSchema):
"""Logical operator that represents splitting the input data to `n` splits."""
num_splits: int
equal: bool
locality_hints: Optional[List["NodeIdStr"]] = None
input_dependencies: List[LogicalOperator] = field(repr=False, kw_only=True)
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
@@ -0,0 +1,216 @@
"""Unit tests for :class:`ReadFiles`.
Verifies pushdown scaffolding (projection/predicate capability dispatch,
immutable scanner substitution) and schema inference without triggering
physical execution. Each test wires a minimal ``ListFiles`` upstream
op so ``ReadFiles`` (which now has one input dependency) can be
constructed.
"""
import os
from pathlib import Path
from typing import Optional, Union
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
import pytest
from ray.data._internal.datasource_v2.listing.file_indexer import (
NonSamplingFileIndexer,
)
from ray.data._internal.datasource_v2.listing.listing_utils import (
sample_files,
)
from ray.data._internal.datasource_v2.parquet_datasource_v2 import (
ParquetDatasourceV2,
)
from ray.data._internal.datasource_v2.scanners.parquet_scanner import (
ParquetScanner,
)
from ray.data._internal.logical.operators import Filter, ListFiles, ReadFiles
from ray.data.datasource.partitioning import Partitioning, PartitionStyle
from ray.data.expressions import Expr, col
def _mk_parquet(path: Path, table: pa.Table) -> None:
pq.write_table(table, str(path))
def _mk_read_files(tmp_path: Path) -> ReadFiles:
f = tmp_path / "data.parquet"
_mk_parquet(f, pa.table({"a": [1, 2, 3], "b": ["x", "y", "z"]}))
datasource = ParquetDatasourceV2([str(f)])
indexer = NonSamplingFileIndexer(ignore_missing_paths=False)
sample = sample_files(indexer, datasource.paths, datasource.filesystem)
schema = datasource.infer_schema(sample)
scanner = datasource.create_scanner(schema=schema)
list_files_op = ListFiles(
paths=list(datasource.paths),
file_indexer=indexer,
filesystem=datasource.filesystem,
source_paths=list(datasource.paths),
file_extensions=datasource.file_extensions,
)
return ReadFiles(
datasource_name=datasource.name,
scanner=scanner,
schema=schema,
parallelism=-1,
input_dependencies=[list_files_op],
)
def _mk_partitioned_read_files(tmp_path: Path) -> ReadFiles:
"""Hive-partitioned dataset with partition column ``country``."""
for country, value in (("US", 1), ("CA", 2)):
d = tmp_path / f"country={country}"
os.makedirs(d, exist_ok=True)
_mk_parquet(d / "data.parquet", pa.table({"a": [value], "b": [str(value)]}))
partitioning = Partitioning(
PartitionStyle.HIVE, base_dir=str(tmp_path), field_names=["country"]
)
datasource = ParquetDatasourceV2([str(tmp_path)], partitioning=partitioning)
indexer = NonSamplingFileIndexer(ignore_missing_paths=False)
sample = sample_files(indexer, datasource.paths, datasource.filesystem)
schema = datasource.infer_schema(sample)
scanner = datasource.create_scanner(schema=schema, partitioning=partitioning)
list_files_op = ListFiles(
paths=list(datasource.paths),
file_indexer=indexer,
filesystem=datasource.filesystem,
source_paths=list(datasource.paths),
file_extensions=datasource.file_extensions,
)
return ReadFiles(
datasource_name=datasource.name,
scanner=scanner,
schema=schema,
parallelism=-1,
input_dependencies=[list_files_op],
)
def test_construction_stores_schema_and_infer_schema_returns_it(tmp_path):
op = _mk_read_files(tmp_path)
assert op.infer_schema().names == ["a", "b"]
def test_input_dependency_is_list_files(tmp_path):
op = _mk_read_files(tmp_path)
assert isinstance(op.input_dependencies[0], ListFiles)
def test_supports_projection_pushdown_true_for_parquet_scanner(tmp_path):
op = _mk_read_files(tmp_path)
assert op.supports_projection_pushdown() is True
def test_apply_projection_returns_new_op_with_pruned_scanner(tmp_path):
op = _mk_read_files(tmp_path)
new_op = op.apply_projection({"a": "a"})
assert new_op is not op
assert isinstance(new_op.scanner, ParquetScanner)
assert new_op.scanner.columns == ("a",)
# Original scanner untouched
assert isinstance(op.scanner, ParquetScanner)
assert op.scanner.columns is None
def test_apply_projection_none_is_noop(tmp_path):
op = _mk_read_files(tmp_path)
assert op.apply_projection(None) is op
def test_supports_predicate_pushdown(tmp_path):
assert _mk_read_files(tmp_path).supports_predicate_pushdown() is True
def _assert_pred_equals(
actual: Optional[Union[Expr, pc.Expression]], expected: Optional[Expr]
) -> None:
if expected is None:
assert actual is None
return
assert actual is not None
# ``ArrowFileScanner.push_filters`` stores the data predicate as a
# ``pc.Expression`` (via ``.to_pyarrow()``), while ``partition_predicate``
# stays a Ray ``Expr``. Dispatch on which we got.
if isinstance(actual, Expr):
assert actual.structurally_equals(expected)
else:
assert actual.equals(expected.to_pyarrow())
@pytest.mark.parametrize(
"partitioned,predicate,expected_data,expected_partition",
[
(False, col("a") > 1, col("a") > 1, None),
(True, col("country") == "US", None, col("country") == "US"),
(
True,
(col("a") > 0) & (col("country") == "US"),
col("a") > 0,
col("country") == "US",
),
],
ids=["data_only", "partition_only", "mixed_and"],
)
def test_apply_predicate_splits_data_and_partition(
tmp_path, partitioned, predicate, expected_data, expected_partition
):
op = (_mk_partitioned_read_files if partitioned else _mk_read_files)(tmp_path)
new_op = op.apply_predicate(predicate)
assert isinstance(new_op, ReadFiles) and new_op is not op
new_scanner = new_op.scanner
assert isinstance(new_scanner, ParquetScanner)
_assert_pred_equals(new_scanner.predicate, expected_data)
_assert_pred_equals(new_scanner.partition_predicate, expected_partition)
# Original scanner untouched.
orig_scanner = op.scanner
assert isinstance(orig_scanner, ParquetScanner)
assert orig_scanner.predicate is None
assert orig_scanner.partition_predicate is None
def test_apply_predicate_mixed_or_keeps_filter_above(tmp_path):
op = _mk_partitioned_read_files(tmp_path)
# Mixed-column ``OR`` can't be safely split — neither bucket is
# populated, so ``apply_predicate`` returns ``self`` and the rule
# leaves the ``Filter`` above ``ReadFiles`` untouched.
result = op.apply_predicate((col("a") > 0) | (col("country") == "US"))
assert result is op
def test_apply_predicate_mixed_and_with_unsplittable_residual(tmp_path):
op = _mk_partitioned_read_files(tmp_path)
# Top-level ``AND`` of one pure-data, one pure-partition, and one
# mixed-OR conjunct: the first two push, the OR stays as a residual
# ``Filter`` so we don't silently drop it.
pure_data = col("a") > 0
pure_partition = col("country") == "US"
mixed_or = (col("a") < 100) | (col("country") == "CA")
result = op.apply_predicate(pure_data & pure_partition & mixed_or)
assert isinstance(result, Filter)
new_read = result.input_dependencies[0]
assert isinstance(new_read, ReadFiles)
new_scanner = new_read.scanner
assert isinstance(new_scanner, ParquetScanner)
_assert_pred_equals(new_scanner.predicate, pure_data)
_assert_pred_equals(new_scanner.partition_predicate, pure_partition)
# The residual carried by the new Filter is exactly the mixed-OR
# conjunct that couldn't be pushed.
_assert_pred_equals(result.predicate_expr, mixed_or)
@@ -0,0 +1,46 @@
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union
from ray.data._internal.compute import ComputeStrategy
from ray.data._internal.logical.interfaces import (
LogicalOperator,
LogicalOperatorPreservesSchema,
)
from ray.data._internal.logical.operators.map_operator import AbstractMap
from ray.data.datasource.datasink import Datasink
from ray.data.datasource.datasource import Datasource
__all__ = [
"Write",
]
@dataclass(frozen=True, repr=False, eq=False)
class Write(AbstractMap, LogicalOperatorPreservesSchema):
"""Logical operator for write."""
datasink_or_legacy_datasource: Union[Datasink, Datasource]
input_dependencies: list[LogicalOperator] = field(repr=False, kw_only=True)
ray_remote_args: Dict[str, Any] = field(default_factory=dict)
compute: Optional[ComputeStrategy] = None
write_args: Dict[str, Any] = field(default_factory=dict)
can_modify_num_rows: bool = field(init=False, default=True)
min_rows_per_bundled_input: Optional[int] = field(init=False)
ray_remote_args_fn: None = field(init=False, default=None)
per_block_limit: Optional[int] = None
def __post_init__(self):
assert len(self.input_dependencies) == 1, len(self.input_dependencies)
if isinstance(self.datasink_or_legacy_datasource, Datasink):
min_rows_per_bundled_input = (
self.datasink_or_legacy_datasource.min_rows_per_write
)
else:
min_rows_per_bundled_input = None
if self.compute is None:
from ray.data._internal.compute import TaskPoolStrategy
object.__setattr__(self, "compute", TaskPoolStrategy())
object.__setattr__(
self, "min_rows_per_bundled_input", min_rows_per_bundled_input
)