chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
from ray.data._internal.datasource.delta_sharing_datasource import (
|
||||
DeltaSharingDatasource,
|
||||
)
|
||||
from ray.data._internal.datasource.mcap_datasource import (
|
||||
MCAPDatasource,
|
||||
TimeRange,
|
||||
)
|
||||
from ray.data._internal.datasource.sql_datasource import Connection
|
||||
from ray.data._internal.datasource.turbopuffer_datasink import (
|
||||
TurbopufferDatasink,
|
||||
)
|
||||
from ray.data._internal.savemode import SaveMode
|
||||
from ray.data.datasource.datasink import (
|
||||
Datasink,
|
||||
DummyOutputDatasink,
|
||||
WriteResult,
|
||||
WriteReturnType,
|
||||
)
|
||||
from ray.data.datasource.datasource import (
|
||||
Datasource,
|
||||
RandomIntRowDatasource,
|
||||
Reader,
|
||||
ReadTask,
|
||||
)
|
||||
from ray.data.datasource.file_based_datasource import (
|
||||
FileBasedDatasource,
|
||||
FileShuffleConfig,
|
||||
_S3FileSystemWrapper,
|
||||
)
|
||||
from ray.data.datasource.file_datasink import (
|
||||
BlockBasedFileDatasink,
|
||||
RowBasedFileDatasink,
|
||||
)
|
||||
from ray.data.datasource.file_meta_provider import (
|
||||
BaseFileMetadataProvider,
|
||||
DefaultFileMetadataProvider,
|
||||
FileMetadataProvider,
|
||||
)
|
||||
from ray.data.datasource.filename_provider import FilenameProvider
|
||||
from ray.data.datasource.partitioning import (
|
||||
Partitioning,
|
||||
PartitionStyle,
|
||||
PathPartitionFilter,
|
||||
PathPartitionParser,
|
||||
)
|
||||
|
||||
# Note: HuggingFaceDatasource should NOT be imported here, because
|
||||
# we want to only import the Hugging Face datasets library when we use
|
||||
# ray.data.from_huggingface() or HuggingFaceDatasource() directly.
|
||||
__all__ = [
|
||||
"BaseFileMetadataProvider",
|
||||
"Connection",
|
||||
"Datasink",
|
||||
"Datasource",
|
||||
"DefaultFileMetadataProvider",
|
||||
"DeltaSharingDatasource",
|
||||
"DummyOutputDatasink",
|
||||
"FileBasedDatasource",
|
||||
"FileShuffleConfig",
|
||||
"FileMetadataProvider",
|
||||
"FilenameProvider",
|
||||
"MCAPDatasource",
|
||||
"PartitionStyle",
|
||||
"PathPartitionFilter",
|
||||
"PathPartitionParser",
|
||||
"Partitioning",
|
||||
"RandomIntRowDatasource",
|
||||
"ReadTask",
|
||||
"Reader",
|
||||
"RowBasedFileDatasink",
|
||||
"TurbopufferDatasink",
|
||||
"BlockBasedFileDatasink",
|
||||
"_S3FileSystemWrapper",
|
||||
"TimeRange",
|
||||
"WriteResult",
|
||||
"WriteReturnType",
|
||||
"SaveMode",
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.interfaces import TaskContext
|
||||
from ray.data.block import Block, BlockAccessor
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow as pa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
WriteReturnType = TypeVar("WriteReturnType")
|
||||
"""Generic type for the return value of `Datasink.write`."""
|
||||
|
||||
|
||||
@dataclass
|
||||
@DeveloperAPI
|
||||
class WriteResult(Generic[WriteReturnType]):
|
||||
"""Aggregated result of the Datasink write operations."""
|
||||
|
||||
# Total number of written rows.
|
||||
num_rows: int
|
||||
# Total size in bytes of written data.
|
||||
size_bytes: int
|
||||
# All returned values of `Datasink.write`.
|
||||
write_returns: List[WriteReturnType]
|
||||
|
||||
@classmethod
|
||||
def combine(cls, *wrs: "WriteResult") -> "WriteResult":
|
||||
num_rows = sum(wr.num_rows for wr in wrs)
|
||||
size_bytes = sum(wr.size_bytes for wr in wrs)
|
||||
write_returns = list(itertools.chain(*[wr.write_returns for wr in wrs]))
|
||||
|
||||
return WriteResult(
|
||||
num_rows=num_rows,
|
||||
size_bytes=size_bytes,
|
||||
write_returns=write_returns,
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class Datasink(Generic[WriteReturnType]):
|
||||
"""Interface for defining write-related logic.
|
||||
|
||||
If you want to write data to something that isn't built-in, subclass this class
|
||||
and call :meth:`~ray.data.Dataset.write_datasink`.
|
||||
"""
|
||||
|
||||
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
|
||||
"""Callback for when a write job starts.
|
||||
|
||||
Use this method to perform setup for write tasks. For example, creating a
|
||||
staging bucket in S3.
|
||||
|
||||
This is called on the driver when the first input bundle is ready, just
|
||||
before write tasks are submitted. The schema is extracted from the first
|
||||
input bundle, enabling schema-dependent initialization.
|
||||
|
||||
Args:
|
||||
schema: The PyArrow schema of the data being written. This is
|
||||
automatically extracted from the first input bundle. May be None
|
||||
if the input data has no schema.
|
||||
"""
|
||||
pass
|
||||
|
||||
def write(
|
||||
self,
|
||||
blocks: Iterable[Block],
|
||||
ctx: TaskContext,
|
||||
) -> WriteReturnType:
|
||||
"""Write blocks. This is used by a single write task.
|
||||
|
||||
Args:
|
||||
blocks: Generator of data blocks.
|
||||
ctx: ``TaskContext`` for the write task.
|
||||
|
||||
Returns:
|
||||
Result of this write task. When the entire write operator finishes,
|
||||
All returned values will be passed as `WriteResult.write_returns`
|
||||
to `Datasink.on_write_complete`.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def on_write_complete(self, write_result: WriteResult[WriteReturnType]):
|
||||
"""Callback for when a write job completes.
|
||||
|
||||
This can be used to `commit` a write output. This method must
|
||||
succeed prior to ``write_datasink()`` returning to the user. If this
|
||||
method fails, then ``on_write_failed()`` is called.
|
||||
|
||||
Args:
|
||||
write_result: Aggregated result of the
|
||||
Write operator, containing write results and stats.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_write_failed(self, error: Exception) -> None:
|
||||
"""Callback for when a write job fails.
|
||||
|
||||
This is called on a best-effort basis on write failures.
|
||||
|
||||
Args:
|
||||
error: The first error encountered.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Return a human-readable name for this datasink.
|
||||
|
||||
This is used as the names of the write tasks.
|
||||
"""
|
||||
name = type(self).__name__
|
||||
datasink_suffix = "Datasink"
|
||||
if name.startswith("_"):
|
||||
name = name[1:]
|
||||
if name.endswith(datasink_suffix):
|
||||
name = name[: -len(datasink_suffix)]
|
||||
return name
|
||||
|
||||
@property
|
||||
def supports_distributed_writes(self) -> bool:
|
||||
"""If ``False``, only launch write tasks on the driver's node."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def min_rows_per_write(self) -> Optional[int]:
|
||||
"""The target number of rows to pass to each :meth:`~ray.data.Datasink.write` call.
|
||||
|
||||
If ``None``, Ray Data passes a system-chosen number of rows.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DummyOutputDatasink(Datasink[None]):
|
||||
"""An example implementation of a writable datasource for testing.
|
||||
Examples:
|
||||
>>> import ray
|
||||
>>> from ray.data.datasource import DummyOutputDatasink
|
||||
>>> output = DummyOutputDatasink()
|
||||
>>> ray.data.range(10).write_datasink(output)
|
||||
>>> assert output.num_ok == 1
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
ctx = ray.data.DataContext.get_current()
|
||||
|
||||
# Setup a dummy actor to send the data. In a real datasource, write
|
||||
# tasks would send data to an external system instead of a Ray actor.
|
||||
@ray.remote(scheduling_strategy=ctx.scheduling_strategy)
|
||||
class DataSink:
|
||||
def __init__(self):
|
||||
self.rows_written = 0
|
||||
self.enabled = True
|
||||
|
||||
def write(self, block: Block) -> None:
|
||||
block = BlockAccessor.for_block(block)
|
||||
self.rows_written += block.num_rows()
|
||||
|
||||
def get_rows_written(self):
|
||||
return self.rows_written
|
||||
|
||||
self.data_sink = DataSink.remote()
|
||||
self.num_ok = 0
|
||||
self.num_failed = 0
|
||||
self.enabled = True
|
||||
|
||||
def write(
|
||||
self,
|
||||
blocks: Iterable[Block],
|
||||
ctx: TaskContext,
|
||||
) -> None:
|
||||
tasks = []
|
||||
if not self.enabled:
|
||||
raise ValueError("disabled")
|
||||
for b in blocks:
|
||||
tasks.append(self.data_sink.write.remote(b))
|
||||
ray.get(tasks)
|
||||
|
||||
def on_write_complete(self, write_result: WriteResult[None]):
|
||||
self.num_ok += 1
|
||||
|
||||
def on_write_failed(self, error: Exception) -> None:
|
||||
self.num_failed += 1
|
||||
|
||||
|
||||
def _gen_datasink_write_result(
|
||||
write_result_blocks: List[Block],
|
||||
) -> WriteResult:
|
||||
import pandas as pd
|
||||
|
||||
assert all(
|
||||
isinstance(block, pd.DataFrame) and len(block) == 1
|
||||
for block in write_result_blocks
|
||||
)
|
||||
|
||||
total_num_rows = sum(result["num_rows"].sum() for result in write_result_blocks)
|
||||
total_size_bytes = sum(result["size_bytes"].sum() for result in write_result_blocks)
|
||||
|
||||
write_returns = [result["write_return"][0] for result in write_result_blocks]
|
||||
return WriteResult(total_num_rows, total_size_bytes, write_returns)
|
||||
@@ -0,0 +1,479 @@
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.data._internal.util import _check_pyarrow_version
|
||||
from ray.data.block import Block, BlockMetadata, Schema
|
||||
from ray.data.datasource.util import _iter_sliced_blocks
|
||||
from ray.data.expressions import Expr
|
||||
from ray.util.annotations import Deprecated, DeveloperAPI, PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
class _DatasourceProjectionPushdownMixin:
|
||||
"""Mixin for reading operators supporting projection pushdown.
|
||||
|
||||
The read stage only prunes columns; it never renames. Column renaming
|
||||
is always carried by an ``AliasExpr`` in a ``Project`` operator above
|
||||
the read. As a consequence, projection maps stored here are always
|
||||
identity (``{name: name}``).
|
||||
"""
|
||||
|
||||
def supports_projection_pushdown(self) -> bool:
|
||||
"""Returns ``True`` in case ``Datasource`` supports projection operation
|
||||
being pushed down into the reading layer"""
|
||||
return False
|
||||
|
||||
def get_projection_map(self) -> Optional[Dict[str, str]]:
|
||||
"""Return the projection map (always an identity mapping).
|
||||
|
||||
Returns:
|
||||
Dict mapping selected column names to themselves. ``None``
|
||||
means all columns are selected. Empty dict ``{}`` means no
|
||||
columns are selected.
|
||||
"""
|
||||
return self._projection_map
|
||||
|
||||
def _get_data_columns(self) -> Optional[List[str]]:
|
||||
"""Extract data columns from projection map.
|
||||
|
||||
Helper method for datasources that need to pass columns to legacy
|
||||
read functions expecting a list of columns.
|
||||
|
||||
Returns:
|
||||
List of column names, or None if all columns should be read.
|
||||
Empty list [] means no columns.
|
||||
"""
|
||||
return (
|
||||
list(self._projection_map.keys())
|
||||
if self._projection_map is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _combine_projection_map(
|
||||
prev_projection_map: Optional[Dict[str, str]],
|
||||
new_projection_map: Optional[Dict[str, str]],
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Combine two projection maps. Identity-only; renames are not stored.
|
||||
|
||||
Args:
|
||||
prev_projection_map: Previously-applied identity map.
|
||||
new_projection_map: New identity map to compose.
|
||||
|
||||
Returns:
|
||||
Combined identity map containing the columns present in both.
|
||||
``None`` means "all columns" and acts as a passthrough.
|
||||
"""
|
||||
# Handle None cases (None means "all columns")
|
||||
if prev_projection_map is None:
|
||||
return new_projection_map
|
||||
elif new_projection_map is None:
|
||||
return prev_projection_map
|
||||
|
||||
# Both are identity maps; keep only columns present in both.
|
||||
return {
|
||||
name: name for name in prev_projection_map if name in new_projection_map
|
||||
}
|
||||
|
||||
def apply_projection(
|
||||
self,
|
||||
projection_map: Optional[Dict[str, str]],
|
||||
) -> "Datasource":
|
||||
"""Apply a projection (column selection) to this datasource.
|
||||
|
||||
Args:
|
||||
projection_map: Dict whose keys are the column names to select.
|
||||
``None`` means select all columns. Any non-identity values
|
||||
are ignored — the read stage does not rename.
|
||||
|
||||
Returns:
|
||||
A new datasource instance with the projection applied.
|
||||
"""
|
||||
clone = copy.copy(self)
|
||||
|
||||
# Normalize any rename entries to identity — the read stage
|
||||
# never renames.
|
||||
normalized = None if projection_map is None else {k: k for k in projection_map}
|
||||
|
||||
clone._projection_map = self._combine_projection_map(
|
||||
self._projection_map, normalized
|
||||
)
|
||||
|
||||
return clone
|
||||
|
||||
|
||||
class _DatasourcePredicatePushdownMixin:
|
||||
"""Mixin for reading operators supporting predicate pushdown"""
|
||||
|
||||
def __init__(self):
|
||||
self._predicate_expr: Optional[Expr] = None
|
||||
|
||||
def supports_predicate_pushdown(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_current_predicate(self) -> Optional[Expr]:
|
||||
return self._predicate_expr
|
||||
|
||||
def apply_predicate(
|
||||
self,
|
||||
predicate_expr: Expr,
|
||||
) -> "Datasource":
|
||||
"""Apply a predicate to this datasource.
|
||||
|
||||
Default implementation that combines predicates using AND.
|
||||
Subclasses that support predicate pushdown should have a _predicate_expr
|
||||
attribute to store the predicate.
|
||||
|
||||
Note: Column rebinding is handled by the PredicatePushdown rule
|
||||
before this method is called, so the predicate_expr should already
|
||||
reference the correct column names.
|
||||
"""
|
||||
import copy
|
||||
|
||||
clone = copy.copy(self)
|
||||
|
||||
# Combine with existing predicate using AND
|
||||
clone._predicate_expr = (
|
||||
predicate_expr
|
||||
if clone._predicate_expr is None
|
||||
else clone._predicate_expr & predicate_expr
|
||||
)
|
||||
|
||||
return clone
|
||||
|
||||
|
||||
@PublicAPI
|
||||
class Datasource(_DatasourceProjectionPushdownMixin, _DatasourcePredicatePushdownMixin):
|
||||
"""Interface for defining a custom :class:`~ray.data.Dataset` datasource.
|
||||
|
||||
User may subclass this class to implement a custom datasource. The subclass should
|
||||
implement :meth:`.get_read_tasks` and
|
||||
:meth:`.estimate_inmemory_data_size` to read the data and estimate the in-memory data size, respectively.
|
||||
|
||||
To read a datasource into a dataset, use :meth:`~ray.data.read_datasource`.
|
||||
|
||||
Example:
|
||||
>>> from ray.data.context import DataContext
|
||||
>>> class MyDatasource(Datasource):
|
||||
... def __init__(self, num_rows: int = 100):
|
||||
... super().__init__()
|
||||
... self.num_rows = num_rows
|
||||
... def get_read_tasks(
|
||||
... self,
|
||||
... parallelism: int,
|
||||
... per_task_row_limit: int | None = None,
|
||||
... data_context: DataContext | None = None,
|
||||
... ) -> List["ReadTask"]:
|
||||
... # Split num_rows across parallelism tasks
|
||||
... rows_per_task = self.num_rows // parallelism
|
||||
... return [
|
||||
... ReadTask(
|
||||
... lambda: [pa.Table.from_pydict({"data": range(rows_per_task)})],
|
||||
... BlockMetadata(rows_per_task, rows_per_task * 8, None, None),
|
||||
... ) for _ in range(parallelism)
|
||||
... ]
|
||||
... def estimate_inmemory_data_size(self) -> Optional[int]:
|
||||
... # Return total size for all data (independent of parallelism)
|
||||
... return self.num_rows * 8
|
||||
>>> ds = MyDatasource(num_rows=100)
|
||||
>>> tasks = ds.get_read_tasks(parallelism=5)
|
||||
>>> len(tasks) == 5
|
||||
True
|
||||
>>> tasks[0].metadata.num_rows == 20
|
||||
True
|
||||
>>> ds.estimate_inmemory_data_size() == sum(t.metadata.size_bytes for t in tasks)
|
||||
True
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the datasource and its mixins."""
|
||||
_DatasourcePredicatePushdownMixin.__init__(self)
|
||||
|
||||
@Deprecated
|
||||
def create_reader(self, **read_args) -> "Reader":
|
||||
"""
|
||||
Deprecated: Implement :meth:`~ray.data.Datasource.get_read_tasks` and
|
||||
:meth:`~ray.data.Datasource.estimate_inmemory_data_size` instead.
|
||||
"""
|
||||
return _LegacyDatasourceReader(self, **read_args)
|
||||
|
||||
@Deprecated
|
||||
def prepare_read(self, parallelism: int, **read_args) -> List["ReadTask"]:
|
||||
"""
|
||||
Deprecated: Implement :meth:`~ray.data.Datasource.get_read_tasks` and
|
||||
:meth:`~ray.data.Datasource.estimate_inmemory_data_size` instead.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Return a human-readable name for this datasource.
|
||||
This will be used as the names of the read tasks.
|
||||
"""
|
||||
name = type(self).__name__
|
||||
datasource_suffix = "Datasource"
|
||||
if name.endswith(datasource_suffix):
|
||||
name = name[: -len(datasource_suffix)]
|
||||
return name
|
||||
|
||||
def estimate_inmemory_data_size(self) -> Optional[int]:
|
||||
"""Return an estimate of the in-memory data size, or None if unknown.
|
||||
|
||||
Note that the in-memory data size may be larger than the on-disk data size.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_read_tasks(
|
||||
self,
|
||||
parallelism: int,
|
||||
per_task_row_limit: Optional[int] = None,
|
||||
data_context: Optional["DataContext"] = None,
|
||||
) -> List["ReadTask"]:
|
||||
"""Execute the read and return read tasks.
|
||||
|
||||
Args:
|
||||
parallelism: The requested read parallelism. The number of read
|
||||
tasks should equal to this value if possible.
|
||||
per_task_row_limit: The per-task row limit for the read tasks.
|
||||
data_context: The data context to use to get read tasks.
|
||||
Returns:
|
||||
A list of read tasks that can be executed to read blocks from the
|
||||
datasource in parallel.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def should_create_reader(self) -> bool:
|
||||
"""Return True if the datasource should create a legacy reader"""
|
||||
|
||||
has_implemented_get_read_tasks = (
|
||||
type(self).get_read_tasks is not Datasource.get_read_tasks
|
||||
)
|
||||
has_implemented_estimate_inmemory_data_size = (
|
||||
type(self).estimate_inmemory_data_size
|
||||
is not Datasource.estimate_inmemory_data_size
|
||||
)
|
||||
# False when both get_read_tasks and estimate_inmemory_data_size are implemented
|
||||
return not (
|
||||
has_implemented_get_read_tasks
|
||||
and has_implemented_estimate_inmemory_data_size
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_distributed_reads(self) -> bool:
|
||||
"""If ``False``, only launch read tasks on the driver's node."""
|
||||
return True
|
||||
|
||||
|
||||
@Deprecated
|
||||
class Reader:
|
||||
"""A bound read operation for a :class:`~ray.data.Datasource`.
|
||||
|
||||
This is a stateful class so that reads can be prepared in multiple stages.
|
||||
For example, it is useful for :class:`Datasets <ray.data.Dataset>` to know the
|
||||
in-memory size of the read prior to executing it.
|
||||
"""
|
||||
|
||||
def estimate_inmemory_data_size(self) -> Optional[int]:
|
||||
"""Return an estimate of the in-memory data size, or None if unknown.
|
||||
|
||||
Note that the in-memory data size may be larger than the on-disk data size.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_read_tasks(self, parallelism: int) -> List["ReadTask"]:
|
||||
"""Execute the read and return read tasks.
|
||||
|
||||
Args:
|
||||
parallelism: The requested read parallelism. The number of read
|
||||
tasks should equal to this value if possible.
|
||||
|
||||
Returns:
|
||||
A list of read tasks that can be executed to read blocks from the
|
||||
datasource in parallel.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _LegacyDatasourceReader(Reader):
|
||||
def __init__(self, datasource: Datasource, **read_args):
|
||||
self._datasource = datasource
|
||||
self._read_args = read_args
|
||||
|
||||
def estimate_inmemory_data_size(self) -> Optional[int]:
|
||||
return None
|
||||
|
||||
def get_read_tasks(
|
||||
self,
|
||||
parallelism: int,
|
||||
per_task_row_limit: Optional[int] = None,
|
||||
data_context: Optional["DataContext"] = None,
|
||||
) -> List["ReadTask"]:
|
||||
"""Execute the read and return read tasks.
|
||||
|
||||
Args:
|
||||
parallelism: The requested read parallelism. The number of read
|
||||
tasks should equal to this value if possible.
|
||||
per_task_row_limit: The per-task row limit for the read tasks.
|
||||
data_context: The data context to use to get read tasks. Not used by this
|
||||
legacy reader.
|
||||
|
||||
Returns:
|
||||
A list of read tasks that can be executed to read blocks from the
|
||||
datasource in parallel.
|
||||
"""
|
||||
return self._datasource.prepare_read(parallelism, **self._read_args)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ReadTask(Callable[[], Iterable[Block]]):
|
||||
"""A function used to read blocks from the :class:`~ray.data.Dataset`.
|
||||
|
||||
Read tasks are generated by :meth:`~ray.data.Datasource.get_read_tasks`,
|
||||
and return a list of ``ray.data.Block`` when called. Initial metadata about the read
|
||||
operation can be retrieved via the ``metadata`` attribute prior to executing the
|
||||
read. Final metadata is returned after the read along with the blocks.
|
||||
|
||||
Ray will execute read tasks in remote functions to parallelize execution.
|
||||
Note that the number of blocks returned can vary at runtime. For example,
|
||||
if a task is reading a single large file it can return multiple blocks to
|
||||
avoid running out of memory during the read.
|
||||
|
||||
The initial metadata should reflect all the blocks returned by the read,
|
||||
e.g., if the metadata says ``num_rows=1000``, the read can return a single
|
||||
block of 1000 rows, or multiple blocks with 1000 rows altogether.
|
||||
|
||||
The final metadata (returned with the actual block) reflects the exact
|
||||
contents of the block itself.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
read_fn: Callable[[], Iterable[Block]],
|
||||
metadata: BlockMetadata,
|
||||
schema: Optional["Schema"] = None,
|
||||
per_task_row_limit: Optional[int] = None,
|
||||
):
|
||||
self._metadata = metadata
|
||||
self._read_fn = read_fn
|
||||
self._schema = schema
|
||||
self._per_task_row_limit = per_task_row_limit
|
||||
|
||||
@property
|
||||
def metadata(self) -> BlockMetadata:
|
||||
return self._metadata
|
||||
|
||||
# TODO(justin): We want to remove schema from `ReadTask` later on
|
||||
@property
|
||||
def schema(self) -> Optional["Schema"]:
|
||||
return self._schema
|
||||
|
||||
@property
|
||||
def read_fn(self) -> Callable[[], Iterable[Block]]:
|
||||
return self._read_fn
|
||||
|
||||
@property
|
||||
def per_task_row_limit(self) -> Optional[int]:
|
||||
"""Get the per-task row limit for this read task."""
|
||||
return self._per_task_row_limit
|
||||
|
||||
def __call__(self) -> Iterable[Block]:
|
||||
result = self._read_fn()
|
||||
if not hasattr(result, "__iter__"):
|
||||
DeprecationWarning(
|
||||
"Read function must return Iterable[Block], got {}. "
|
||||
"Probably you need to return `[block]` instead of "
|
||||
"`block`.".format(result)
|
||||
)
|
||||
if self._per_task_row_limit is None:
|
||||
yield from result
|
||||
return
|
||||
|
||||
yield from _iter_sliced_blocks(result, self._per_task_row_limit)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class RandomIntRowDatasource(Datasource):
|
||||
"""An example datasource that generates rows with random int64 columns.
|
||||
|
||||
Examples:
|
||||
>>> import ray
|
||||
>>> from ray.data.datasource import RandomIntRowDatasource
|
||||
>>> source = RandomIntRowDatasource() # doctest: +SKIP
|
||||
>>> ray.data.read_datasource( # doctest: +SKIP
|
||||
... source, n=10, num_columns=2).take()
|
||||
{'c_0': 1717767200176864416, 'c_1': 999657309586757214}
|
||||
{'c_0': 4983608804013926748, 'c_1': 1160140066899844087}
|
||||
"""
|
||||
|
||||
def __init__(self, n: int, num_columns: int):
|
||||
"""Initialize the datasource that generates random-integer rows.
|
||||
|
||||
Args:
|
||||
n: The number of rows to generate.
|
||||
num_columns: The number of columns to generate.
|
||||
"""
|
||||
self._n = n
|
||||
self._num_columns = num_columns
|
||||
|
||||
def estimate_inmemory_data_size(self) -> Optional[int]:
|
||||
return self._n * self._num_columns * 8
|
||||
|
||||
def get_read_tasks(
|
||||
self,
|
||||
parallelism: int,
|
||||
per_task_row_limit: Optional[int] = None,
|
||||
data_context: Optional["DataContext"] = None,
|
||||
) -> List[ReadTask]:
|
||||
_check_pyarrow_version()
|
||||
import pyarrow
|
||||
|
||||
read_tasks: List[ReadTask] = []
|
||||
n = self._n
|
||||
num_columns = self._num_columns
|
||||
block_size = max(1, n // parallelism)
|
||||
|
||||
def make_block(count: int, num_columns: int) -> Block:
|
||||
return pyarrow.Table.from_arrays(
|
||||
np.random.randint(
|
||||
np.iinfo(np.int64).max, size=(num_columns, count), dtype=np.int64
|
||||
),
|
||||
names=[f"c_{i}" for i in range(num_columns)],
|
||||
)
|
||||
|
||||
schema = pyarrow.Table.from_pydict(
|
||||
{f"c_{i}": [0] for i in range(num_columns)}
|
||||
).schema
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
count = min(block_size, n - i)
|
||||
meta = BlockMetadata(
|
||||
num_rows=count,
|
||||
size_bytes=8 * count * num_columns,
|
||||
input_files=None,
|
||||
exec_stats=None,
|
||||
)
|
||||
read_tasks.append(
|
||||
ReadTask(
|
||||
lambda count=count, num_columns=num_columns: [
|
||||
make_block(count, num_columns)
|
||||
],
|
||||
meta,
|
||||
schema=schema,
|
||||
per_task_row_limit=per_task_row_limit,
|
||||
)
|
||||
)
|
||||
i += block_size
|
||||
|
||||
return read_tasks
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Return a human-readable name for this datasource.
|
||||
This will be used as the names of the read tasks.
|
||||
Note: overrides the base `Datasource` method.
|
||||
"""
|
||||
return "RandomInt"
|
||||
@@ -0,0 +1,625 @@
|
||||
import io
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ray
|
||||
from ray.data._internal.util import (
|
||||
RetryingContextManager,
|
||||
RetryingPyFileSystem,
|
||||
_check_pyarrow_version,
|
||||
_is_local_scheme,
|
||||
infer_compression,
|
||||
iterate_with_retry,
|
||||
make_async_gen,
|
||||
)
|
||||
from ray.data.block import Block, BlockAccessor
|
||||
from ray.data.context import DataContext
|
||||
from ray.data.datasource.datasource import Datasource, ReadTask
|
||||
from ray.data.datasource.file_meta_provider import (
|
||||
BaseFileMetadataProvider,
|
||||
DefaultFileMetadataProvider,
|
||||
)
|
||||
from ray.data.datasource.partitioning import (
|
||||
Partitioning,
|
||||
PathPartitionFilter,
|
||||
PathPartitionParser,
|
||||
)
|
||||
from ray.data.datasource.path_util import (
|
||||
_has_file_extension,
|
||||
_resolve_paths_and_filesystem,
|
||||
)
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pandas as pd
|
||||
import pyarrow
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# We should parallelize file size fetch operations beyond this threshold.
|
||||
FILE_SIZE_FETCH_PARALLELIZATION_THRESHOLD = 16
|
||||
|
||||
# 16 file size fetches from S3 takes ~1.5 seconds with Arrow's S3FileSystem.
|
||||
PATHS_PER_FILE_SIZE_FETCH_TASK = 16
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class FileShuffleConfig:
|
||||
"""Configuration for file shuffling.
|
||||
|
||||
This configuration object controls how files are shuffled while reading file-based
|
||||
datasets. The random seed behavior is determined by the combination of ``seed``
|
||||
and ``reseed_after_execution``:
|
||||
|
||||
- If ``seed`` is None, the random seed is always None (non-deterministic shuffling).
|
||||
- If ``seed`` is not None and ``reseed_after_execution`` is False, the random seed is
|
||||
constantly ``seed`` across executions.
|
||||
- If ``seed`` is not None and ``reseed_after_execution`` is True, the random seed is
|
||||
different for each execution.
|
||||
|
||||
.. note::
|
||||
Even if you provided a seed, you might still observe a non-deterministic row
|
||||
order. This is because tasks are executed in parallel and their completion
|
||||
order might vary. If you need to preserve the order of rows, set
|
||||
``DataContext.get_current().execution_options.preserve_order``.
|
||||
|
||||
Args:
|
||||
seed: An optional integer seed for the file shuffler. If None, shuffling is
|
||||
non-deterministic. If provided, shuffling is deterministic based on this
|
||||
seed and the ``reseed_after_execution`` setting.
|
||||
reseed_after_execution: If True, the random seed considers both ``seed`` and
|
||||
``execution_idx``, resulting in different shuffling orders across executions.
|
||||
If False, the random seed is constantly ``seed``, resulting in the same
|
||||
shuffling order across executions. Only takes effect when ``seed`` is not None.
|
||||
Defaults to True.
|
||||
|
||||
Example:
|
||||
>>> import ray
|
||||
>>> from ray.data import FileShuffleConfig
|
||||
>>> # Fixed seed - same shuffle across executions
|
||||
>>> shuffle = FileShuffleConfig(seed=42, reseed_after_execution=False)
|
||||
>>> ds = ray.data.read_images("s3://anonymous@ray-example-data/batoidea", shuffle=shuffle)
|
||||
>>>
|
||||
>>> # Seed with reseed_after_execution - different shuffle per execution
|
||||
>>> shuffle = FileShuffleConfig(seed=42, reseed_after_execution=True)
|
||||
>>> ds = ray.data.read_images("s3://anonymous@ray-example-data/batoidea", shuffle=shuffle)
|
||||
""" # noqa: E501
|
||||
|
||||
seed: Optional[int] = None
|
||||
reseed_after_execution: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure that the seed is either None or an integer."""
|
||||
if self.seed is not None and not isinstance(self.seed, int):
|
||||
raise ValueError("Seed must be an integer or None.")
|
||||
|
||||
def get_seed(self, execution_idx: int = 0) -> Optional[int]:
|
||||
if self.seed is None:
|
||||
return None
|
||||
elif self.reseed_after_execution:
|
||||
# Modulo ensures the result is in valid NumPy seed range [0, 2**32 - 1].
|
||||
return hash((self.seed, execution_idx)) % (2**32)
|
||||
else:
|
||||
return self.seed
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class FileBasedDatasource(Datasource):
|
||||
"""File-based datasource for reading files.
|
||||
|
||||
Don't use this class directly. Instead, subclass it and implement `_read_stream()`.
|
||||
"""
|
||||
|
||||
# If `_WRITE_FILE_PER_ROW` is `True`, this datasource calls `_write_row` and writes
|
||||
# each row to a file. Otherwise, this datasource calls `_write_block` and writes
|
||||
# each block to a file.
|
||||
_WRITE_FILE_PER_ROW = False
|
||||
_FILE_EXTENSIONS: Optional[Union[str, List[str]]] = None
|
||||
# Number of threads for concurrent reading within each read task.
|
||||
# If zero or negative, reading will be performed in the main thread.
|
||||
_NUM_THREADS_PER_TASK = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
paths: Union[str, List[str]],
|
||||
*,
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
||||
schema: Optional[Union[type, "pyarrow.lib.Schema"]] = None,
|
||||
open_stream_args: Optional[Dict[str, Any]] = None,
|
||||
meta_provider: BaseFileMetadataProvider = DefaultFileMetadataProvider(),
|
||||
partition_filter: PathPartitionFilter = None,
|
||||
partitioning: Partitioning = None,
|
||||
ignore_missing_paths: bool = False,
|
||||
shuffle: Optional[Union[Literal["files"], FileShuffleConfig]] = None,
|
||||
include_paths: bool = False,
|
||||
file_extensions: Optional[List[str]] = None,
|
||||
):
|
||||
super().__init__()
|
||||
_check_pyarrow_version()
|
||||
|
||||
self._supports_distributed_reads = not _is_local_scheme(paths)
|
||||
if not self._supports_distributed_reads and ray.util.client.ray.is_connected():
|
||||
raise ValueError(
|
||||
"Because you're using Ray Client, read tasks scheduled on the Ray "
|
||||
"cluster can't access your local files. To fix this issue, store "
|
||||
"files in cloud storage or a distributed filesystem like NFS."
|
||||
)
|
||||
|
||||
self._schema = schema
|
||||
self._data_context = DataContext.get_current()
|
||||
self._open_stream_args = open_stream_args
|
||||
self._meta_provider = meta_provider
|
||||
self._partition_filter = partition_filter
|
||||
self._partitioning = partitioning
|
||||
self._ignore_missing_paths = ignore_missing_paths
|
||||
self._include_paths = include_paths
|
||||
# Need this property for lineage tracking. We should not directly assign paths
|
||||
# to self since it is captured every read_task_fn during serialization and
|
||||
# causing this data being duplicated and excessive object store spilling.
|
||||
self._source_paths_ref = ray.put(paths)
|
||||
paths, self._filesystem = _resolve_paths_and_filesystem(paths, filesystem)
|
||||
self._filesystem = RetryingPyFileSystem.wrap(
|
||||
self._filesystem, retryable_errors=self._data_context.retried_io_errors
|
||||
)
|
||||
paths, file_sizes = map(
|
||||
list,
|
||||
zip(
|
||||
*meta_provider.expand_paths(
|
||||
paths,
|
||||
self._filesystem,
|
||||
partitioning,
|
||||
ignore_missing_paths=ignore_missing_paths,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if ignore_missing_paths and len(paths) == 0:
|
||||
raise ValueError(
|
||||
"None of the provided paths exist. "
|
||||
"The 'ignore_missing_paths' field is set to True."
|
||||
)
|
||||
|
||||
if self._partition_filter is not None:
|
||||
# Use partition filter to skip files which are not needed.
|
||||
path_to_size = dict(zip(paths, file_sizes))
|
||||
paths = self._partition_filter(paths)
|
||||
file_sizes = [path_to_size[p] for p in paths]
|
||||
if len(paths) == 0:
|
||||
raise ValueError(
|
||||
"No input files found to read. Please double check that "
|
||||
"'partition_filter' field is set properly."
|
||||
)
|
||||
|
||||
if file_extensions is not None:
|
||||
path_to_size = dict(zip(paths, file_sizes))
|
||||
paths = [p for p in paths if _has_file_extension(p, file_extensions)]
|
||||
file_sizes = [path_to_size[p] for p in paths]
|
||||
if len(paths) == 0:
|
||||
raise ValueError(
|
||||
"No input files found to read with the following file extensions: "
|
||||
f"{file_extensions}. Please double check that "
|
||||
"'file_extensions' field is set properly."
|
||||
)
|
||||
|
||||
_validate_shuffle_arg(shuffle)
|
||||
self._shuffle = shuffle
|
||||
|
||||
# Read tasks serialize `FileBasedDatasource` instances, and the list of paths
|
||||
# can be large. To avoid slow serialization speeds, we store a reference to
|
||||
# the paths rather than the paths themselves.
|
||||
self._paths_ref = ray.put(paths)
|
||||
self._file_sizes_ref = ray.put(file_sizes)
|
||||
|
||||
@property
|
||||
def _source_paths(self) -> List[str]:
|
||||
return ray.get(self._source_paths_ref)
|
||||
|
||||
def _paths(self) -> List[str]:
|
||||
return ray.get(self._paths_ref)
|
||||
|
||||
def _file_sizes(self) -> List[float]:
|
||||
return ray.get(self._file_sizes_ref)
|
||||
|
||||
def estimate_inmemory_data_size(self) -> Optional[int]:
|
||||
total_size = 0
|
||||
for sz in self._file_sizes():
|
||||
if sz is not None:
|
||||
total_size += sz
|
||||
return total_size
|
||||
|
||||
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
|
||||
|
||||
open_stream_args = self._open_stream_args
|
||||
partitioning = self._partitioning
|
||||
|
||||
paths = self._paths()
|
||||
file_sizes = self._file_sizes()
|
||||
|
||||
execution_idx = data_context._execution_idx if data_context is not None else 0
|
||||
paths, file_sizes = _shuffle_file_metadata(
|
||||
paths, file_sizes, self._shuffle, execution_idx
|
||||
)
|
||||
|
||||
filesystem = _wrap_s3_serialization_workaround(self._filesystem)
|
||||
|
||||
if open_stream_args is None:
|
||||
open_stream_args = {}
|
||||
|
||||
def read_files(
|
||||
read_paths: Iterable[str],
|
||||
) -> Iterable[Block]:
|
||||
nonlocal filesystem, open_stream_args, partitioning
|
||||
|
||||
fs = _unwrap_s3_serialization_workaround(filesystem)
|
||||
|
||||
for read_path in read_paths:
|
||||
partitions: Dict[str, str] = {}
|
||||
if partitioning is not None:
|
||||
parse = PathPartitionParser(partitioning)
|
||||
partitions = parse(read_path)
|
||||
|
||||
with RetryingContextManager(
|
||||
self._open_input_source(fs, read_path, **open_stream_args),
|
||||
context=self._data_context,
|
||||
) as f:
|
||||
for block in iterate_with_retry(
|
||||
lambda: self._read_stream(f, read_path),
|
||||
description="read stream iteratively",
|
||||
match=self._data_context.retried_io_errors,
|
||||
):
|
||||
if partitions:
|
||||
block = _add_partitions(block, partitions)
|
||||
if self._include_paths:
|
||||
block_accessor = BlockAccessor.for_block(block)
|
||||
block = block_accessor.fill_column("path", read_path)
|
||||
yield block
|
||||
|
||||
def create_read_task_fn(read_paths, num_threads):
|
||||
def read_task_fn():
|
||||
nonlocal num_threads, read_paths
|
||||
|
||||
# TODO: We should refactor the code so that we can get the results in
|
||||
# order even when using multiple threads.
|
||||
if self._data_context.execution_options.preserve_order:
|
||||
num_threads = 0
|
||||
|
||||
if num_threads > 0:
|
||||
num_threads = min(num_threads, len(read_paths))
|
||||
|
||||
logger.debug(
|
||||
f"Reading {len(read_paths)} files with {num_threads} threads."
|
||||
)
|
||||
|
||||
yield from make_async_gen(
|
||||
iter(read_paths),
|
||||
read_files,
|
||||
num_workers=num_threads,
|
||||
preserve_ordering=True,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Reading {len(read_paths)} files.")
|
||||
yield from read_files(read_paths)
|
||||
|
||||
return read_task_fn
|
||||
|
||||
# fix https://github.com/ray-project/ray/issues/24296
|
||||
parallelism = min(parallelism, len(paths))
|
||||
|
||||
read_tasks = []
|
||||
# Convert numpy arrays back to Python lists so downstream code
|
||||
# (e.g. meta providers) doesn't receive numpy string types.
|
||||
split_paths = [p.tolist() for p in np.array_split(paths, parallelism)]
|
||||
split_file_sizes = [s.tolist() for s in np.array_split(file_sizes, parallelism)]
|
||||
|
||||
for read_paths, file_sizes in zip(split_paths, split_file_sizes):
|
||||
if len(read_paths) <= 0:
|
||||
continue
|
||||
|
||||
meta = self._meta_provider(
|
||||
read_paths,
|
||||
rows_per_file=self._rows_per_file(),
|
||||
file_sizes=file_sizes,
|
||||
)
|
||||
|
||||
read_task_fn = create_read_task_fn(read_paths, self._NUM_THREADS_PER_TASK)
|
||||
|
||||
read_task = ReadTask(
|
||||
read_task_fn, meta, per_task_row_limit=per_task_row_limit
|
||||
)
|
||||
|
||||
read_tasks.append(read_task)
|
||||
|
||||
return read_tasks
|
||||
|
||||
def resolve_compression(
|
||||
self, path: str, open_args: Dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
"""Resolves the compression format for a stream.
|
||||
|
||||
Args:
|
||||
path: The file path to resolve compression for.
|
||||
open_args: kwargs passed to
|
||||
`pyarrow.fs.FileSystem.open_input_stream <https://arrow.apache.org/docs/python/generated/pyarrow.fs.FileSystem.html#pyarrow.fs.FileSystem.open_input_stream>`_
|
||||
when opening input files to read.
|
||||
|
||||
Returns:
|
||||
The compression format (e.g., "gzip", "snappy", "bz2") or None if
|
||||
no compression is detected or specified.
|
||||
"""
|
||||
compression = open_args.get("compression", None)
|
||||
if compression is None:
|
||||
compression = infer_compression(path)
|
||||
return compression
|
||||
|
||||
def _resolve_buffer_size(self, open_args: Dict[str, Any]) -> Optional[int]:
|
||||
buffer_size = open_args.pop("buffer_size", None)
|
||||
if buffer_size is None:
|
||||
buffer_size = self._data_context.streaming_read_buffer_size
|
||||
return buffer_size
|
||||
|
||||
def _file_to_snappy_stream(
|
||||
self,
|
||||
file: "pyarrow.NativeFile",
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
) -> "pyarrow.PythonFile":
|
||||
import pyarrow as pa
|
||||
import snappy
|
||||
from pyarrow.fs import HadoopFileSystem
|
||||
|
||||
stream = io.BytesIO()
|
||||
if isinstance(filesystem.unwrap(), HadoopFileSystem):
|
||||
snappy.hadoop_snappy.stream_decompress(src=file, dst=stream)
|
||||
else:
|
||||
snappy.stream_decompress(src=file, dst=stream)
|
||||
stream.seek(0)
|
||||
|
||||
return pa.PythonFile(stream, mode="r")
|
||||
|
||||
def _open_input_source(
|
||||
self,
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
path: str,
|
||||
**open_args,
|
||||
) -> "pyarrow.NativeFile":
|
||||
"""Opens a source path for reading and returns the associated Arrow NativeFile.
|
||||
|
||||
The default implementation opens the source path as a sequential input stream,
|
||||
using self._data_context.streaming_read_buffer_size as the buffer size if none
|
||||
is given by the caller.
|
||||
|
||||
Implementations that do not support streaming reads (e.g. that require random
|
||||
access) should override this method.
|
||||
"""
|
||||
|
||||
compression = self.resolve_compression(path, open_args)
|
||||
buffer_size = self._resolve_buffer_size(open_args)
|
||||
|
||||
if compression == "snappy":
|
||||
# Arrow doesn't support streaming Snappy decompression since the canonical
|
||||
# C++ Snappy library doesn't natively support streaming decompression. We
|
||||
# works around this by manually decompressing the file with python-snappy.
|
||||
open_args["compression"] = None
|
||||
file = filesystem.open_input_stream(
|
||||
path, buffer_size=buffer_size, **open_args
|
||||
)
|
||||
return self._file_to_snappy_stream(file, filesystem)
|
||||
|
||||
open_args["compression"] = compression
|
||||
return filesystem.open_input_stream(path, buffer_size=buffer_size, **open_args)
|
||||
|
||||
def _rows_per_file(self):
|
||||
"""Returns the number of rows per file, or None if unknown."""
|
||||
return None
|
||||
|
||||
def _read_stream(self, f: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
|
||||
"""Streaming read a single file.
|
||||
|
||||
This method should be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses of FileBasedDatasource must implement _read_stream()."
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_distributed_reads(self) -> bool:
|
||||
return self._supports_distributed_reads
|
||||
|
||||
|
||||
def _add_partitions(
|
||||
data: Union["pyarrow.Table", "pd.DataFrame"], partitions: Dict[str, Any]
|
||||
) -> Union["pyarrow.Table", "pd.DataFrame"]:
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
|
||||
assert isinstance(data, (pa.Table, pd.DataFrame))
|
||||
if isinstance(data, pa.Table):
|
||||
return _add_partitions_to_table(data, partitions)
|
||||
if isinstance(data, pd.DataFrame):
|
||||
return _add_partitions_to_dataframe(data, partitions)
|
||||
|
||||
|
||||
def _add_partitions_to_table(
|
||||
table: "pyarrow.Table", partitions: Dict[str, Any]
|
||||
) -> "pyarrow.Table":
|
||||
import pyarrow as pa
|
||||
import pyarrow.compute as pc
|
||||
|
||||
column_names = set(table.column_names)
|
||||
for field, value in partitions.items():
|
||||
column = pa.array([value] * len(table))
|
||||
if field in column_names:
|
||||
# TODO: Handle cast error.
|
||||
column_type = table.schema.field(field).type
|
||||
column = column.cast(column_type)
|
||||
|
||||
values_are_equal = pc.all(pc.equal(column, table[field]))
|
||||
values_are_equal = values_are_equal.as_py()
|
||||
|
||||
if not values_are_equal:
|
||||
raise ValueError(
|
||||
f"Partition column {field} exists in table data, but partition "
|
||||
f"value '{value}' is different from in-data values: "
|
||||
f"{table[field].unique().to_pylist()}."
|
||||
)
|
||||
|
||||
i = table.schema.get_field_index(field)
|
||||
table = table.set_column(i, field, column)
|
||||
else:
|
||||
table = table.append_column(field, column)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _add_partitions_to_dataframe(
|
||||
df: "pd.DataFrame", partitions: Dict[str, Any]
|
||||
) -> "pd.DataFrame":
|
||||
import pandas as pd
|
||||
|
||||
for field, value in partitions.items():
|
||||
column = pd.Series(data=[value] * len(df), name=field)
|
||||
|
||||
if field in df:
|
||||
column = column.astype(df[field].dtype)
|
||||
mask = df[field].notna()
|
||||
if not df[field][mask].equals(column[mask]):
|
||||
raise ValueError(
|
||||
f"Partition column {field} exists in table data, but partition "
|
||||
f"value '{value}' is different from in-data values: "
|
||||
f"{list(df[field].unique())}."
|
||||
)
|
||||
|
||||
df[field] = column
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def _wrap_s3_serialization_workaround(filesystem: "pyarrow.fs.FileSystem"):
|
||||
# This is needed because pa.fs.S3FileSystem assumes pa.fs is already
|
||||
# imported before deserialization. See #17085.
|
||||
import pyarrow as pa
|
||||
import pyarrow.fs
|
||||
|
||||
base_fs = filesystem
|
||||
if isinstance(filesystem, RetryingPyFileSystem):
|
||||
base_fs = filesystem.unwrap()
|
||||
|
||||
if isinstance(base_fs, pa.fs.S3FileSystem):
|
||||
return _S3FileSystemWrapper(filesystem)
|
||||
|
||||
return filesystem
|
||||
|
||||
|
||||
def _unwrap_s3_serialization_workaround(
|
||||
filesystem: Union["pyarrow.fs.FileSystem", "_S3FileSystemWrapper"],
|
||||
):
|
||||
if isinstance(filesystem, _S3FileSystemWrapper):
|
||||
filesystem = filesystem.unwrap()
|
||||
return filesystem
|
||||
|
||||
|
||||
class _S3FileSystemWrapper:
|
||||
"""pyarrow.fs.S3FileSystem wrapper that can be deserialized safely.
|
||||
|
||||
Importing pyarrow.fs during reconstruction triggers the pyarrow
|
||||
S3 subsystem initialization.
|
||||
|
||||
NOTE: This is only needed for pyarrow<14.0.0 and should be removed
|
||||
once the minimum supported pyarrow version exceeds that.
|
||||
See https://github.com/apache/arrow/pull/38375 for context.
|
||||
"""
|
||||
|
||||
def __init__(self, fs: "pyarrow.fs.FileSystem"):
|
||||
self._fs = fs
|
||||
|
||||
def unwrap(self):
|
||||
return self._fs
|
||||
|
||||
@classmethod
|
||||
def _reconstruct(cls, fs_reconstruct, fs_args):
|
||||
# Implicitly trigger S3 subsystem initialization by importing
|
||||
# pyarrow.fs.
|
||||
import pyarrow.fs # noqa: F401
|
||||
|
||||
return cls(fs_reconstruct(*fs_args))
|
||||
|
||||
def __reduce__(self):
|
||||
return _S3FileSystemWrapper._reconstruct, self._fs.__reduce__()
|
||||
|
||||
|
||||
def _resolve_kwargs(
|
||||
kwargs_fn: Callable[[], Dict[str, Any]], **kwargs
|
||||
) -> Dict[str, Any]:
|
||||
if kwargs_fn:
|
||||
kwarg_overrides = kwargs_fn()
|
||||
kwargs.update(kwarg_overrides)
|
||||
return kwargs
|
||||
|
||||
|
||||
def _validate_shuffle_arg(
|
||||
shuffle: Union[Literal["files"], FileShuffleConfig, None],
|
||||
) -> None:
|
||||
if not (
|
||||
shuffle is None or shuffle == "files" or isinstance(shuffle, FileShuffleConfig)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid value for 'shuffle': {shuffle}. "
|
||||
"Valid values are None, 'files', `FileShuffleConfig`."
|
||||
)
|
||||
|
||||
|
||||
FileMetadata = TypeVar("FileMetadata")
|
||||
|
||||
|
||||
def _shuffle_file_metadata(
|
||||
paths: List[str],
|
||||
file_metadata: List[FileMetadata],
|
||||
shuffler: Union[Literal["files"], FileShuffleConfig, None],
|
||||
execution_idx: int,
|
||||
) -> Tuple[List[str], List[FileMetadata]]:
|
||||
"""Shuffle file paths and sizes together using the given shuffler."""
|
||||
if shuffler is None:
|
||||
return paths, file_metadata
|
||||
|
||||
assert len(paths) == len(file_metadata), (
|
||||
"Number of paths and file metadata must match. "
|
||||
f"Got {len(paths)} paths and {len(file_metadata)} file metadata."
|
||||
)
|
||||
if len(paths) == 0:
|
||||
return paths, file_metadata
|
||||
|
||||
if shuffler == "files":
|
||||
seed = None
|
||||
else:
|
||||
assert isinstance(shuffler, FileShuffleConfig)
|
||||
seed = shuffler.get_seed(execution_idx)
|
||||
|
||||
file_metadata_shuffler = np.random.default_rng(seed)
|
||||
|
||||
files_metadata = list(zip(paths, file_metadata))
|
||||
file_metadata_shuffler.shuffle(files_metadata)
|
||||
return list(map(list, zip(*files_metadata)))
|
||||
@@ -0,0 +1,308 @@
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ray._common.retry import call_with_retry
|
||||
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
|
||||
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._internal.util import (
|
||||
RetryingPyFileSystem,
|
||||
_is_local_scheme,
|
||||
)
|
||||
from ray.data._internal.utils.arrow_utils import add_creatable_buckets_param_if_s3_uri
|
||||
from ray.data.block import Block, BlockAccessor
|
||||
from ray.data.context import DataContext
|
||||
from ray.data.datasource.datasink import Datasink, WriteResult
|
||||
from ray.data.datasource.filename_provider import (
|
||||
FilenameProvider,
|
||||
_split_base_and_ext,
|
||||
)
|
||||
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _FileDatasink(Datasink[None]):
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
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,
|
||||
file_format: Optional[str] = None,
|
||||
mode: SaveMode = SaveMode.APPEND,
|
||||
):
|
||||
"""Initialize this datasink.
|
||||
|
||||
Args:
|
||||
path: The folder to write files to.
|
||||
filesystem: The filesystem to write files to. If not provided, the
|
||||
filesystem is inferred from the path.
|
||||
try_create_dir: Whether to create the directory to write files to.
|
||||
open_stream_args: Arguments to pass to ``filesystem.open_output_stream``.
|
||||
filename_provider: A :class:`ray.data.datasource.FilenameProvider` that
|
||||
generates filenames for each row or block.
|
||||
dataset_uuid: The UUID of the dataset being written. If specified, it's
|
||||
included in the filename.
|
||||
file_format: The file extension. If specified, files are written with this
|
||||
extension.
|
||||
mode: The save mode controlling behavior when the destination already
|
||||
exists (e.g., append, overwrite, error, ignore).
|
||||
"""
|
||||
if open_stream_args is None:
|
||||
open_stream_args = {}
|
||||
|
||||
if filename_provider is None:
|
||||
filename_provider = FilenameProvider(
|
||||
dataset_uuid=dataset_uuid, file_format=file_format
|
||||
)
|
||||
|
||||
self._data_context = DataContext.get_current()
|
||||
self.unresolved_path = path
|
||||
paths, self.filesystem = _resolve_paths_and_filesystem(path, filesystem)
|
||||
self.filesystem = RetryingPyFileSystem.wrap(
|
||||
self.filesystem, retryable_errors=self._data_context.retried_io_errors
|
||||
)
|
||||
assert len(paths) == 1, len(paths)
|
||||
self.path = paths[0]
|
||||
|
||||
self.try_create_dir = try_create_dir
|
||||
self.open_stream_args = open_stream_args
|
||||
self.filename_provider = filename_provider
|
||||
self.dataset_uuid = dataset_uuid
|
||||
self.file_format = file_format
|
||||
self.mode = mode
|
||||
self.has_created_dir = False
|
||||
self._skip_write = False
|
||||
self._write_started = False
|
||||
|
||||
def open_output_stream(self, path: str) -> "pyarrow.NativeFile":
|
||||
return self.filesystem.open_output_stream(path, **self.open_stream_args)
|
||||
|
||||
def on_write_start(self, schema: Optional["pyarrow.Schema"] = None) -> None:
|
||||
# Make idempotent - if already called, return early.
|
||||
if self._write_started:
|
||||
return
|
||||
self._write_started = True
|
||||
|
||||
from pyarrow.fs import FileType
|
||||
|
||||
dir_exists = (
|
||||
self.filesystem.get_file_info(self.path).type is not FileType.NotFound
|
||||
)
|
||||
if dir_exists:
|
||||
if self.mode in {SaveMode.ERROR, SaveMode.CREATE}:
|
||||
raise ValueError(
|
||||
f"Path {self.path} already exists. "
|
||||
"If this is unexpected, use mode='ignore' to ignore those files"
|
||||
)
|
||||
if self.mode == SaveMode.IGNORE:
|
||||
logger.warning(f"[SaveMode={self.mode}] Skipping {self.path}")
|
||||
self._skip_write = True
|
||||
return
|
||||
if self.mode == SaveMode.OVERWRITE:
|
||||
logger.warning(f"[SaveMode={self.mode}] Replacing contents {self.path}")
|
||||
self.filesystem.delete_dir_contents(self.path)
|
||||
self.has_created_dir = self._create_dir(self.path)
|
||||
|
||||
def _create_dir(self, dest) -> bool:
|
||||
"""Create a directory to write files to.
|
||||
|
||||
If ``try_create_dir`` is ``False``, this method is a no-op.
|
||||
"""
|
||||
from pyarrow.fs import FileType
|
||||
|
||||
# We should skip creating directories in s3 unless the user specifically
|
||||
# overrides this behavior. PyArrow's s3fs implementation for create_dir
|
||||
# will attempt to check if the parent directory exists before trying to
|
||||
# create the directory (with recursive=True it will try to do this to
|
||||
# all of the directories until the root of the bucket). An IAM Policy that
|
||||
# restricts access to a subset of prefixes within the bucket might cause
|
||||
# the creation of the directory to fail even if the permissions should
|
||||
# allow the data can be written to the specified path. For example if a
|
||||
# a policy only allows users to write blobs prefixed with s3://bucket/foo
|
||||
# a call to create_dir for s3://bucket/foo/bar will fail even though it
|
||||
# should not.
|
||||
parsed_uri = urlparse(dest)
|
||||
is_s3_uri = parsed_uri.scheme == "s3"
|
||||
skip_create_dir_for_s3 = is_s3_uri and not self._data_context.s3_try_create_dir
|
||||
|
||||
if self.try_create_dir and not skip_create_dir_for_s3:
|
||||
if self.filesystem.get_file_info(dest).type is FileType.NotFound:
|
||||
# Arrow's S3FileSystem doesn't allow creating buckets by default, so we
|
||||
# add a query arg enabling bucket creation if an S3 URI is provided.
|
||||
tmp = add_creatable_buckets_param_if_s3_uri(dest)
|
||||
self.filesystem.create_dir(tmp, recursive=True)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def write(
|
||||
self,
|
||||
blocks: Iterable[Block],
|
||||
ctx: TaskContext,
|
||||
) -> None:
|
||||
builder = DelegatingBlockBuilder()
|
||||
for block in blocks:
|
||||
builder.add_block(block)
|
||||
block = builder.build()
|
||||
block_accessor = BlockAccessor.for_block(block)
|
||||
|
||||
if block_accessor.num_rows() == 0:
|
||||
logger.warning(f"Skipped writing empty block to {self.path}")
|
||||
return
|
||||
|
||||
self.write_block(block_accessor, 0, ctx)
|
||||
|
||||
def write_block(self, block: BlockAccessor, block_index: int, ctx: TaskContext):
|
||||
raise NotImplementedError
|
||||
|
||||
def on_write_complete(self, write_result: WriteResult[None]):
|
||||
# If no rows were written, we can delete the directory.
|
||||
if self.has_created_dir and write_result.num_rows == 0:
|
||||
self.filesystem.delete_dir(self.path)
|
||||
|
||||
@property
|
||||
def supports_distributed_writes(self) -> bool:
|
||||
return not _is_local_scheme(self.unresolved_path)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class RowBasedFileDatasink(_FileDatasink):
|
||||
"""A datasink that writes one row to each file.
|
||||
|
||||
Subclasses must implement ``write_row_to_file`` and call the superclass constructor.
|
||||
|
||||
Examples:
|
||||
.. testcode::
|
||||
|
||||
import io
|
||||
from typing import Any, Dict
|
||||
|
||||
import pyarrow
|
||||
from PIL import Image
|
||||
|
||||
from ray.data.datasource import RowBasedFileDatasink
|
||||
|
||||
class ImageDatasink(RowBasedFileDatasink):
|
||||
def __init__(self, path: str, *, column: str, file_format: str = "png"):
|
||||
super().__init__(path, file_format=file_format)
|
||||
self._file_format = file_format
|
||||
self._column = column
|
||||
|
||||
def write_row_to_file(self, row: Dict[str, Any], file: "pyarrow.NativeFile"):
|
||||
image = Image.fromarray(row[self._column])
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=self._file_format)
|
||||
file.write(buffer.getvalue())
|
||||
""" # noqa: E501
|
||||
|
||||
def write_row_to_file(self, row: Dict[str, Any], file: "pyarrow.NativeFile"):
|
||||
"""Write a row to a file.
|
||||
|
||||
Args:
|
||||
row: The row to write.
|
||||
file: The file to write the row to.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def write_block(self, block: BlockAccessor, block_index: int, ctx: TaskContext):
|
||||
task_filename = self.filename_provider.get_filename_for_task(
|
||||
ctx.kwargs[WRITE_UUID_KWARG_NAME],
|
||||
ctx.task_idx,
|
||||
)
|
||||
base, ext = _split_base_and_ext(task_filename)
|
||||
for row_index, row in enumerate(block.iter_rows(public_row_format=False)):
|
||||
filename = f"{base}_{block_index:06}_{row_index:06}{ext}"
|
||||
write_path = posixpath.join(self.path, filename)
|
||||
logger.debug(f"Writing {write_path} file.")
|
||||
|
||||
def write_row_to_path():
|
||||
with self.open_output_stream(write_path) as file:
|
||||
self.write_row_to_file(row, file)
|
||||
|
||||
call_with_retry(
|
||||
write_row_to_path,
|
||||
description=f"write '{write_path}'",
|
||||
match=self._data_context.retried_io_errors,
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BlockBasedFileDatasink(_FileDatasink):
|
||||
"""A datasink that writes multiple rows to each file.
|
||||
|
||||
Subclasses must implement ``write_block_to_file`` and call the superclass
|
||||
constructor.
|
||||
|
||||
Examples:
|
||||
.. testcode::
|
||||
|
||||
class CSVDatasink(BlockBasedFileDatasink):
|
||||
def __init__(self, path: str):
|
||||
super().__init__(path, file_format="csv")
|
||||
|
||||
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
|
||||
from pyarrow import csv
|
||||
csv.write_csv(block.to_arrow(), file)
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
min_rows_per_file: Optional[int] = None,
|
||||
**file_datasink_kwargs,
|
||||
):
|
||||
"""Initialize this block-based file datasink.
|
||||
|
||||
Args:
|
||||
path: The folder to write files to.
|
||||
min_rows_per_file: The target minimum number of rows per file. When
|
||||
``None``, rows are not buffered before being written.
|
||||
**file_datasink_kwargs: Additional keyword arguments forwarded to
|
||||
:class:`_FileDatasink`.
|
||||
"""
|
||||
super().__init__(path, **file_datasink_kwargs)
|
||||
|
||||
self._min_rows_per_file = min_rows_per_file
|
||||
|
||||
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
|
||||
"""Write a block of data to a file.
|
||||
|
||||
Args:
|
||||
block: The block to write.
|
||||
file: The file to write the block to.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def write_block(self, block: BlockAccessor, block_index: int, ctx: TaskContext):
|
||||
filename = self.filename_provider.get_filename_for_task(
|
||||
ctx.kwargs[WRITE_UUID_KWARG_NAME], ctx.task_idx
|
||||
)
|
||||
write_path = posixpath.join(self.path, filename)
|
||||
|
||||
def write_block_to_path():
|
||||
with self.open_output_stream(write_path) as file:
|
||||
self.write_block_to_file(block, file)
|
||||
|
||||
logger.debug(f"Writing {write_path} file.")
|
||||
call_with_retry(
|
||||
write_block_to_path,
|
||||
description=f"write '{write_path}'",
|
||||
match=self._data_context.retried_io_errors,
|
||||
)
|
||||
|
||||
@property
|
||||
def min_rows_per_write(self) -> Optional[int]:
|
||||
return self._min_rows_per_file
|
||||
@@ -0,0 +1,491 @@
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.data._internal.execution.util import merge_label_selector
|
||||
from ray.data._internal.progress.progress_bar import ProgressBar
|
||||
from ray.data._internal.remote_fn import cached_remote_fn
|
||||
from ray.data._internal.util import RetryingPyFileSystem
|
||||
from ray.data.block import BlockMetadata
|
||||
from ray.data.context import DataContext
|
||||
from ray.data.datasource.partitioning import Partitioning, PathPartitionFilter
|
||||
from ray.data.datasource.path_util import _has_file_extension
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class FileMetadataProvider:
|
||||
"""Abstract callable that provides metadata for the files of a single dataset block.
|
||||
|
||||
Current subclasses:
|
||||
- :class:`BaseFileMetadataProvider`
|
||||
"""
|
||||
|
||||
def _get_block_metadata(
|
||||
self,
|
||||
paths: List[str],
|
||||
**kwargs,
|
||||
) -> BlockMetadata:
|
||||
"""Resolves and returns block metadata for files in the given paths.
|
||||
|
||||
All file paths provided should belong to a single dataset block.
|
||||
|
||||
Args:
|
||||
paths: The file paths for a single dataset block.
|
||||
**kwargs: Additional kwargs used to determine block metadata.
|
||||
|
||||
Returns:
|
||||
BlockMetadata aggregated across the given paths.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
paths: List[str],
|
||||
**kwargs,
|
||||
) -> BlockMetadata:
|
||||
return self._get_block_metadata(paths, **kwargs)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class BaseFileMetadataProvider(FileMetadataProvider):
|
||||
"""Abstract callable that provides metadata for
|
||||
:class:`~ray.data.datasource.file_based_datasource.FileBasedDatasource`
|
||||
implementations that reuse the base :meth:`~ray.data.Datasource.prepare_read`
|
||||
method.
|
||||
|
||||
Also supports file and file size discovery in input directory paths.
|
||||
|
||||
Current subclasses:
|
||||
- :class:`DefaultFileMetadataProvider`
|
||||
"""
|
||||
|
||||
def _get_block_metadata(
|
||||
self,
|
||||
paths: List[str],
|
||||
*,
|
||||
rows_per_file: Optional[int],
|
||||
file_sizes: List[Optional[int]],
|
||||
) -> BlockMetadata:
|
||||
"""Resolves and returns block metadata for files of a single dataset block.
|
||||
|
||||
Args:
|
||||
paths: The file paths for a single dataset block. These
|
||||
paths will always be a subset of those previously returned from
|
||||
:meth:`.expand_paths`.
|
||||
rows_per_file: The fixed number of rows per input file, or None.
|
||||
file_sizes: Optional file size per input file previously returned
|
||||
from :meth:`.expand_paths`, where `file_sizes[i]` holds the size of
|
||||
the file at `paths[i]`.
|
||||
|
||||
Returns:
|
||||
BlockMetadata aggregated across the given file paths.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def expand_paths(
|
||||
self,
|
||||
paths: List[str],
|
||||
filesystem: Optional["RetryingPyFileSystem"],
|
||||
partitioning: Optional[Partitioning] = None,
|
||||
ignore_missing_paths: bool = False,
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
"""Expands all paths into concrete file paths by walking directories.
|
||||
|
||||
Also returns a sidecar of file sizes.
|
||||
|
||||
The input paths must be normalized for compatibility with the input
|
||||
filesystem prior to invocation.
|
||||
|
||||
Args:
|
||||
paths: A list of file and/or directory paths compatible with the
|
||||
given filesystem.
|
||||
filesystem: The filesystem implementation that should be used for
|
||||
expanding all paths and reading their files.
|
||||
partitioning: Partitioning describing how files under directories are
|
||||
organized into partitions. If ``None``, paths are not interpreted as
|
||||
partitioned.
|
||||
ignore_missing_paths: If True, ignores any file paths in ``paths`` that
|
||||
are not found. Defaults to False.
|
||||
|
||||
Returns:
|
||||
An iterator of `(file_path, file_size)` pairs. None may be returned for the
|
||||
file size if it is either unknown or will be fetched later by
|
||||
`_get_block_metadata()`, but the length of
|
||||
both lists must be equal.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DefaultFileMetadataProvider(BaseFileMetadataProvider):
|
||||
"""Default metadata provider for
|
||||
:class:`~ray.data.datasource.file_based_datasource.FileBasedDatasource`
|
||||
implementations that reuse the base `prepare_read` method.
|
||||
|
||||
Calculates block size in bytes as the sum of its constituent file sizes,
|
||||
and assumes a fixed number of rows per file.
|
||||
"""
|
||||
|
||||
def _get_block_metadata(
|
||||
self,
|
||||
paths: List[str],
|
||||
*,
|
||||
rows_per_file: Optional[int],
|
||||
file_sizes: List[Optional[int]],
|
||||
) -> BlockMetadata:
|
||||
if rows_per_file is None:
|
||||
num_rows = None
|
||||
else:
|
||||
num_rows = len(paths) * rows_per_file
|
||||
input_files = list(paths)
|
||||
return BlockMetadata(
|
||||
num_rows=num_rows,
|
||||
size_bytes=None if None in file_sizes else int(sum(file_sizes)),
|
||||
input_files=input_files,
|
||||
exec_stats=None,
|
||||
) # Exec stats filled in later.
|
||||
|
||||
def expand_paths(
|
||||
self,
|
||||
paths: List[str],
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
partitioning: Optional[Partitioning] = None,
|
||||
ignore_missing_paths: bool = False,
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
yield from _expand_paths(paths, filesystem, partitioning, ignore_missing_paths)
|
||||
|
||||
|
||||
def _handle_read_os_error(error: OSError, paths: Union[str, List[str]]) -> str:
|
||||
# NOTE: this is not comprehensive yet, and should be extended as more errors arise.
|
||||
# NOTE: The latter patterns are raised in Arrow 10+, while the former is raised in
|
||||
# Arrow < 10.
|
||||
aws_error_pattern = (
|
||||
r"^(?:(.*)AWS Error \[code \d+\]: No response body\.(.*))|"
|
||||
r"(?:(.*)AWS Error UNKNOWN \(HTTP status 400\) during HeadObject operation: "
|
||||
r"No response body\.(.*))|"
|
||||
r"(?:(.*)AWS Error ACCESS_DENIED during HeadObject operation: No response "
|
||||
r"body\.(.*))$"
|
||||
)
|
||||
if re.match(aws_error_pattern, str(error)):
|
||||
# Specially handle AWS error when reading files, to give a clearer error
|
||||
# message to avoid confusing users. The real issue is most likely that the AWS
|
||||
# S3 file credentials have not been properly configured yet.
|
||||
if isinstance(paths, str):
|
||||
# Quote to highlight single file path in error message for better
|
||||
# readability. List of file paths will be shown up as ['foo', 'boo'],
|
||||
# so only quote single file path here.
|
||||
paths = f'"{paths}"'
|
||||
raise OSError(
|
||||
(
|
||||
f"Failing to read AWS S3 file(s): {paths}. "
|
||||
"Please check that file exists and has properly configured access. "
|
||||
"You can also run AWS CLI command to get more detailed error message "
|
||||
"(e.g., aws s3 ls <file-name>). "
|
||||
"See https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/index.html " # noqa
|
||||
"and https://docs.ray.io/en/latest/data/creating-datasets.html#reading-from-remote-storage " # noqa
|
||||
"for more information."
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise error
|
||||
|
||||
|
||||
def _list_files(
|
||||
paths: List[str],
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
*,
|
||||
partition_filter: Optional[PathPartitionFilter],
|
||||
file_extensions: Optional[List[str]],
|
||||
) -> List[Tuple[str, int]]:
|
||||
return list(
|
||||
_list_files_internal(
|
||||
paths,
|
||||
filesystem,
|
||||
partition_filter=partition_filter,
|
||||
file_extensions=file_extensions,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _list_files_internal(
|
||||
paths: List[str],
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
*,
|
||||
partition_filter: Optional[PathPartitionFilter],
|
||||
file_extensions: Optional[List[str]],
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
default_meta_provider = DefaultFileMetadataProvider()
|
||||
|
||||
for path, file_size in default_meta_provider.expand_paths(paths, filesystem):
|
||||
# HACK: PyArrow's `ParquetDataset` errors if input paths contain non-parquet
|
||||
# files. To avoid this, we expand the input paths with the default metadata
|
||||
# provider and then apply the partition filter or file extensions.
|
||||
if (
|
||||
partition_filter
|
||||
and not partition_filter.apply(path)
|
||||
or not _has_file_extension(path, file_extensions)
|
||||
):
|
||||
continue
|
||||
|
||||
yield path, file_size
|
||||
|
||||
|
||||
def _expand_paths(
|
||||
paths: List[str],
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
partitioning: Optional[Partitioning],
|
||||
ignore_missing_paths: bool = False,
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
"""Get the file sizes for all provided file paths."""
|
||||
from pyarrow.fs import LocalFileSystem
|
||||
|
||||
from ray.data.datasource.file_based_datasource import (
|
||||
FILE_SIZE_FETCH_PARALLELIZATION_THRESHOLD,
|
||||
)
|
||||
from ray.data.datasource.path_util import _is_http_url, _unwrap_protocol
|
||||
|
||||
# We break down our processing paths into a few key cases:
|
||||
# 1. If len(paths) < threshold, fetch the file info for the individual files/paths
|
||||
# serially.
|
||||
# 2. If all paths are contained under the same parent directory (or base directory,
|
||||
# if using partitioning), fetch all file infos at this prefix and filter to the
|
||||
# provided paths on the client; this should be a single file info request.
|
||||
# 3. If more than threshold requests required, parallelize them via Ray tasks.
|
||||
# 1. Small # of paths case.
|
||||
is_local = isinstance(filesystem, LocalFileSystem)
|
||||
if isinstance(filesystem, RetryingPyFileSystem):
|
||||
is_local = isinstance(filesystem.unwrap(), LocalFileSystem)
|
||||
|
||||
if (
|
||||
len(paths) < FILE_SIZE_FETCH_PARALLELIZATION_THRESHOLD
|
||||
# Local file systems are very fast to hit.
|
||||
or is_local
|
||||
):
|
||||
yield from _get_file_infos_serial(paths, filesystem, ignore_missing_paths)
|
||||
else:
|
||||
# 2. Common path prefix case.
|
||||
# Get longest common path of all paths.
|
||||
common_path = os.path.commonpath(paths)
|
||||
# If parent directory (or base directory, if using partitioning) is common to
|
||||
# all paths, fetch all file infos at that prefix and filter the response to the
|
||||
# provided paths.
|
||||
if not _is_http_url(common_path) and (
|
||||
(
|
||||
partitioning is not None
|
||||
and common_path == _unwrap_protocol(partitioning.base_dir)
|
||||
)
|
||||
or all(str(pathlib.Path(path).parent) == common_path for path in paths)
|
||||
):
|
||||
yield from _get_file_infos_common_path_prefix(
|
||||
paths, common_path, filesystem, ignore_missing_paths
|
||||
)
|
||||
# 3. Parallelization case.
|
||||
else:
|
||||
# Parallelize requests via Ray tasks.
|
||||
yield from _get_file_infos_parallel(paths, filesystem, ignore_missing_paths)
|
||||
|
||||
|
||||
def _get_file_infos_serial(
|
||||
paths: List[str],
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
ignore_missing_paths: bool = False,
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
for path in paths:
|
||||
yield from _get_file_infos(path, filesystem, ignore_missing_paths)
|
||||
|
||||
|
||||
def _get_file_infos_common_path_prefix(
|
||||
paths: List[str],
|
||||
common_path: str,
|
||||
filesystem: "pyarrow.fs.FileSystem",
|
||||
ignore_missing_paths: bool = False,
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
path_to_size = {path: None for path in paths}
|
||||
for path, file_size in _get_file_infos(
|
||||
common_path, filesystem, ignore_missing_paths
|
||||
):
|
||||
if path in path_to_size:
|
||||
path_to_size[path] = file_size
|
||||
|
||||
# Check if all `paths` have file size metadata.
|
||||
# If any of paths has no file size, fall back to get files metadata in parallel.
|
||||
# This can happen when path is a directory, but not a file.
|
||||
have_missing_path = False
|
||||
for path in paths:
|
||||
if path_to_size[path] is None:
|
||||
logger.debug(
|
||||
f"Finding path {path} not have file size metadata. "
|
||||
"Fall back to get files metadata in parallel for all paths."
|
||||
)
|
||||
have_missing_path = True
|
||||
break
|
||||
|
||||
if have_missing_path:
|
||||
# Parallelize requests via Ray tasks.
|
||||
yield from _get_file_infos_parallel(paths, filesystem, ignore_missing_paths)
|
||||
else:
|
||||
# Iterate over `paths` to yield each path in original order.
|
||||
# NOTE: do not iterate over `path_to_size` because the dictionary skips
|
||||
# duplicated path, while `paths` might contain duplicated path if one wants
|
||||
# to read same file multiple times.
|
||||
for path in paths:
|
||||
yield path, path_to_size[path]
|
||||
|
||||
|
||||
def _get_file_infos_parallel(
|
||||
paths: List[str],
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
ignore_missing_paths: bool = False,
|
||||
) -> Iterator[Tuple[str, int]]:
|
||||
from ray.data.datasource.file_based_datasource import (
|
||||
PATHS_PER_FILE_SIZE_FETCH_TASK,
|
||||
_unwrap_s3_serialization_workaround,
|
||||
_wrap_s3_serialization_workaround,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Expanding {len(paths)} path(s). This may be a HIGH LATENCY "
|
||||
f"operation on some cloud storage services. Moving all the "
|
||||
"paths to a common parent directory will lead to faster "
|
||||
"metadata fetching."
|
||||
)
|
||||
|
||||
# Capture the filesystem in the fetcher func closure, but wrap it in our
|
||||
# serialization workaround to make sure that the pickle roundtrip works as expected.
|
||||
filesystem = _wrap_s3_serialization_workaround(filesystem)
|
||||
|
||||
def _file_infos_fetcher(paths: List[str]) -> List[Tuple[str, int]]:
|
||||
fs = _unwrap_s3_serialization_workaround(filesystem)
|
||||
return list(
|
||||
itertools.chain.from_iterable(
|
||||
_get_file_infos(path, fs, ignore_missing_paths) for path in paths
|
||||
)
|
||||
)
|
||||
|
||||
yield from _fetch_metadata_parallel(
|
||||
paths, _file_infos_fetcher, PATHS_PER_FILE_SIZE_FETCH_TASK
|
||||
)
|
||||
|
||||
|
||||
Uri = TypeVar("Uri")
|
||||
Meta = TypeVar("Meta")
|
||||
|
||||
|
||||
def _fetch_metadata_parallel(
|
||||
uris: List[Uri],
|
||||
fetch_func: Callable[[List[Uri]], List[Meta]],
|
||||
desired_uris_per_task: int,
|
||||
**ray_remote_args,
|
||||
) -> Iterator[Meta]:
|
||||
"""Fetch file metadata in parallel using Ray tasks."""
|
||||
remote_fetch_func = cached_remote_fn(fetch_func)
|
||||
ray_remote_args = merge_label_selector(
|
||||
dict(ray_remote_args),
|
||||
DataContext.get_current().execution_options.label_selector,
|
||||
)
|
||||
if ray_remote_args:
|
||||
remote_fetch_func = remote_fetch_func.options(**ray_remote_args)
|
||||
# Choose a parallelism that results in a # of metadata fetches per task that
|
||||
# dominates the Ray task overhead while ensuring good parallelism.
|
||||
# Always launch at least 2 parallel fetch tasks.
|
||||
parallelism = max(len(uris) // desired_uris_per_task, 2)
|
||||
metadata_fetch_bar = ProgressBar(
|
||||
"Metadata Fetch Progress", total=parallelism, unit="task"
|
||||
)
|
||||
fetch_tasks = []
|
||||
for uri_chunk in np.array_split(uris, parallelism):
|
||||
if len(uri_chunk) == 0:
|
||||
continue
|
||||
fetch_tasks.append(remote_fetch_func.remote(uri_chunk))
|
||||
results = metadata_fetch_bar.fetch_until_complete(fetch_tasks)
|
||||
yield from itertools.chain.from_iterable(results)
|
||||
|
||||
|
||||
def _get_file_infos(
|
||||
path: str, filesystem: "RetryingPyFileSystem", ignore_missing_path: bool = False
|
||||
) -> List[Tuple[str, int]]:
|
||||
"""Get the file info for all files at or under the provided path."""
|
||||
from pyarrow.fs import FileType
|
||||
|
||||
file_infos = []
|
||||
try:
|
||||
file_info = filesystem.get_file_info(path)
|
||||
except OSError as e:
|
||||
_handle_read_os_error(e, path)
|
||||
if file_info.type == FileType.Directory:
|
||||
for file_path, file_size in _expand_directory(path, filesystem):
|
||||
file_infos.append((file_path, file_size))
|
||||
elif file_info.type == FileType.File:
|
||||
file_infos.append((path, file_info.size))
|
||||
elif file_info.type == FileType.NotFound and ignore_missing_path:
|
||||
pass
|
||||
else:
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
return file_infos
|
||||
|
||||
|
||||
def _expand_directory(
|
||||
path: str,
|
||||
filesystem: "RetryingPyFileSystem",
|
||||
exclude_prefixes: Optional[List[str]] = None,
|
||||
ignore_missing_path: bool = False,
|
||||
) -> List[Tuple[str, int]]:
|
||||
"""
|
||||
Expand the provided directory path to a list of file paths.
|
||||
|
||||
Args:
|
||||
path: The directory path to expand.
|
||||
filesystem: The filesystem implementation that should be used for
|
||||
reading these files.
|
||||
exclude_prefixes: The file relative path prefixes that should be
|
||||
excluded from the returned file set. Default excluded prefixes are
|
||||
"." and "_".
|
||||
ignore_missing_path: If True, returns an empty list when ``path`` does not
|
||||
exist instead of raising.
|
||||
|
||||
Returns:
|
||||
An iterator of (file_path, file_size) tuples.
|
||||
"""
|
||||
if exclude_prefixes is None:
|
||||
exclude_prefixes = [".", "_"]
|
||||
|
||||
from pyarrow.fs import FileSelector
|
||||
|
||||
selector = FileSelector(path, recursive=True, allow_not_found=ignore_missing_path)
|
||||
files = filesystem.get_file_info(selector)
|
||||
base_path = selector.base_dir
|
||||
out = []
|
||||
for file_ in files:
|
||||
if not file_.is_file:
|
||||
continue
|
||||
file_path = file_.path
|
||||
if not file_path.startswith(base_path):
|
||||
continue
|
||||
relative = file_path[len(base_path) :].lstrip("/")
|
||||
if any(relative.startswith(prefix) for prefix in exclude_prefixes):
|
||||
continue
|
||||
out.append((file_path, file_.size))
|
||||
# We sort the paths to guarantee a stable order.
|
||||
return sorted(out)
|
||||
@@ -0,0 +1,171 @@
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from ray.data.block import Block
|
||||
from ray.util.annotations import Deprecated, PublicAPI
|
||||
|
||||
|
||||
def _split_base_and_ext(filename: str) -> Tuple[str, str]:
|
||||
"""Split a filename into (base, extension) where extension includes the dot.
|
||||
|
||||
Returns (base, ext) where ext includes the leading dot (e.g., ".parquet"),
|
||||
or is empty string if the filename has no extension.
|
||||
|
||||
This is the single source of truth for separating a task filename's base
|
||||
from its extension. Used by both row-filename derivation and checkpoint
|
||||
base-filename extraction — these MUST agree for prefix-trie recovery.
|
||||
"""
|
||||
if "." in filename:
|
||||
base, ext = filename.rsplit(".", 1)
|
||||
return base, f".{ext}"
|
||||
return filename, ""
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
class FilenameProvider:
|
||||
"""Generates filenames when you write a :class:`~ray.data.Dataset`.
|
||||
|
||||
Use this class to customize the filenames used when writing a Dataset.
|
||||
|
||||
Override :meth:`~FilenameProvider.get_filename_for_task` to customize filenames.
|
||||
For row-based writes (e.g., :meth:`~ray.data.Dataset.write_images`), row filenames
|
||||
are automatically derived by appending ``_{block_index:06}_{row_index:06}`` to the
|
||||
task filename.
|
||||
|
||||
Example:
|
||||
|
||||
This snippet shows you how to customize filenames with a prefix. For example,
|
||||
a file might be named ``images_abc123_000000.png``.
|
||||
|
||||
.. testcode::
|
||||
|
||||
import ray
|
||||
from ray.data.datasource import FilenameProvider
|
||||
|
||||
class ImageFilenameProvider(FilenameProvider):
|
||||
|
||||
def __init__(self, prefix: str, file_format: str):
|
||||
super().__init__(file_format=file_format)
|
||||
self.prefix = prefix
|
||||
|
||||
def get_filename_for_task(self, write_uuid, task_index):
|
||||
return f"{self.prefix}_{write_uuid}_{task_index:06}.{self.file_format}"
|
||||
|
||||
ds = ray.data.read_parquet("s3://anonymous@ray-example-data/images.parquet")
|
||||
ds.write_images(
|
||||
"/tmp/results",
|
||||
column="image",
|
||||
filename_provider=ImageFilenameProvider("images", "png")
|
||||
)
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset_uuid: Optional[str] = None,
|
||||
file_format: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Create a FilenameProvider.
|
||||
|
||||
Args:
|
||||
dataset_uuid: An optional UUID to include as a prefix in generated
|
||||
filenames. If provided, filenames will be formatted as
|
||||
``{dataset_uuid}_{write_uuid}_{task_index}``.
|
||||
file_format: An optional file extension (without the leading dot)
|
||||
to append to generated filenames, e.g. ``"parquet"`` or
|
||||
``"csv"``.
|
||||
"""
|
||||
self.dataset_uuid = dataset_uuid
|
||||
self.file_format = file_format
|
||||
|
||||
def get_filename_for_task(self, write_uuid: str, task_index: int) -> str:
|
||||
"""Generate a filename for a write task.
|
||||
|
||||
Override this method to customize filenames when writing a Dataset.
|
||||
|
||||
.. note::
|
||||
Filenames must be unique and deterministic for a given write UUID and
|
||||
task index.
|
||||
|
||||
Args:
|
||||
write_uuid: The UUID of the write operation.
|
||||
task_index: The index of the write task.
|
||||
|
||||
Returns:
|
||||
The generated filename string.
|
||||
"""
|
||||
file_id = f"{write_uuid}_{task_index:06}"
|
||||
filename = ""
|
||||
if self.dataset_uuid is not None:
|
||||
filename += f"{self.dataset_uuid}_"
|
||||
filename += file_id
|
||||
if self.file_format is not None:
|
||||
filename += f".{self.file_format}"
|
||||
return filename
|
||||
|
||||
@Deprecated(
|
||||
message="Use get_filename_for_task() instead. The block and block_index "
|
||||
"parameters are unused in practice because datasinks merge all blocks into "
|
||||
"one before writing. These parameters will be removed in a future release. "
|
||||
"Do not depend on block content or block_index in your FilenameProvider "
|
||||
"implementation - filenames must be deterministic from (write_uuid, task_index) "
|
||||
"alone to ensure checkpointing correctness."
|
||||
)
|
||||
def get_filename_for_block(
|
||||
self, block: Optional[Block], write_uuid: str, task_index: int, block_index: int
|
||||
) -> str:
|
||||
"""Generate a filename for a block of data.
|
||||
|
||||
.. note::
|
||||
Filenames must be unique and deterministic for a given write UUID and
|
||||
task index. Do NOT depend on block content or block_index.
|
||||
|
||||
Checkpointing requires predicting the output filename BEFORE writing
|
||||
data. This enables 2-phase commit: if a write fails after creating the
|
||||
file but before committing the checkpoint, recovery can use the
|
||||
predicted filename to delete orphaned files and retry cleanly. If
|
||||
filenames depend on block content, this prediction is impossible and
|
||||
checkpointing cannot guarantee exactly-once semantics.
|
||||
|
||||
Args:
|
||||
block: Deprecated, unused. Do not depend on block content.
|
||||
write_uuid: The UUID of the write operation.
|
||||
task_index: The index of the write task.
|
||||
block_index: Deprecated, always 0. Do not depend on this value.
|
||||
|
||||
Returns:
|
||||
The filename to use for the block.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@Deprecated(
|
||||
message="Implement get_filename_for_task() instead. Row filenames are "
|
||||
"automatically derived by appending _{block_index:06}_{row_index:06} to the "
|
||||
"task filename. All files from the same task must share the task filename as "
|
||||
"a prefix so that uncommitted data files can be identified and cleaned up "
|
||||
"during checkpoint recovery."
|
||||
)
|
||||
def get_filename_for_row(
|
||||
self,
|
||||
row: Dict[str, Any],
|
||||
write_uuid: str,
|
||||
task_index: int,
|
||||
block_index: int,
|
||||
row_index: int,
|
||||
) -> str:
|
||||
"""Generate a filename for a row.
|
||||
|
||||
.. deprecated::
|
||||
Implement :meth:`get_filename_for_task` instead. Row filenames are
|
||||
automatically derived by appending ``_{block_index:06}_{row_index:06}``
|
||||
to the task filename.
|
||||
|
||||
Args:
|
||||
row: The row that will be written to a file.
|
||||
write_uuid: The UUID of the write operation.
|
||||
task_index: The index of the write task.
|
||||
block_index: The index of the block *within* the write task.
|
||||
row_index: The index of the row *within* the block.
|
||||
|
||||
Returns:
|
||||
The filename to use for the row.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,609 @@
|
||||
import logging
|
||||
import posixpath
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.util.annotations import DeveloperAPI, PublicAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow
|
||||
|
||||
from ray.data.expressions import Expr
|
||||
|
||||
|
||||
PartitionDataType = Type[Union[int, float, str, bool]]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class PartitionStyle(str, Enum):
|
||||
"""Supported dataset partition styles.
|
||||
|
||||
Inherits from `str` to simplify plain text serialization/deserialization.
|
||||
|
||||
Examples:
|
||||
>>> # Serialize to JSON text.
|
||||
>>> json.dumps(PartitionStyle.HIVE) # doctest: +SKIP
|
||||
'"hive"'
|
||||
|
||||
>>> # Deserialize from JSON text.
|
||||
>>> PartitionStyle(json.loads('"hive"')) # doctest: +SKIP
|
||||
<PartitionStyle.HIVE: 'hive'>
|
||||
"""
|
||||
|
||||
HIVE = "hive"
|
||||
DIRECTORY = "dir"
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class Partitioning:
|
||||
"""Partition scheme used to describe path-based partitions.
|
||||
|
||||
Path-based partition formats embed all partition keys and values directly in
|
||||
their dataset file paths.
|
||||
|
||||
For example, to read a dataset with
|
||||
`Hive-style partitions <https://athena.guide/articles/hive-style-partitioning>`_:
|
||||
|
||||
>>> import ray
|
||||
>>> from ray.data.datasource.partitioning import Partitioning
|
||||
>>> ds = ray.data.read_csv(
|
||||
... "s3://anonymous@ray-example-data/iris.csv",
|
||||
... partitioning=Partitioning("hive"),
|
||||
... )
|
||||
|
||||
Instead, if your files are arranged in a directory structure such as:
|
||||
|
||||
.. code::
|
||||
|
||||
root/dog/dog_0.jpeg
|
||||
root/dog/dog_1.jpeg
|
||||
...
|
||||
|
||||
root/cat/cat_0.jpeg
|
||||
root/cat/cat_1.jpeg
|
||||
...
|
||||
|
||||
Then you can use directory-based partitioning:
|
||||
|
||||
>>> import ray
|
||||
>>> from ray.data.datasource.partitioning import Partitioning
|
||||
>>> root = "s3://anonymous@air-example-data/cifar-10/images"
|
||||
>>> partitioning = Partitioning("dir", field_names=["class"], base_dir=root)
|
||||
>>> ds = ray.data.read_images(root, partitioning=partitioning)
|
||||
"""
|
||||
|
||||
#: The partition style - may be either HIVE or DIRECTORY.
|
||||
style: PartitionStyle
|
||||
#: "/"-delimited base directory that all partitioned paths should
|
||||
#: exist under (exclusive). File paths either outside of, or at the first
|
||||
#: level of, this directory will be considered unpartitioned. Specify
|
||||
#: `None` or an empty string to search for partitions in all file path
|
||||
#: directories.
|
||||
base_dir: Optional[str] = None
|
||||
#: The partition key field names (i.e. column names for tabular
|
||||
#: datasets). When non-empty, the order and length of partition key
|
||||
#: field names must match the order and length of partition values.
|
||||
#: Required when parsing DIRECTORY partitioned paths or generating
|
||||
#: HIVE partitioned paths.
|
||||
field_names: Optional[List[str]] = None
|
||||
#: A dictionary that maps partition key names to their desired data type. If not
|
||||
#: provided, the data type defaults to string.
|
||||
field_types: Optional[Dict[str, PartitionDataType]] = None
|
||||
#: Filesystem that will be used for partition path file I/O.
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.base_dir is None:
|
||||
self.base_dir = ""
|
||||
|
||||
if self.field_types is None:
|
||||
self.field_types = {}
|
||||
|
||||
self._normalized_base_dir = None
|
||||
self._resolved_filesystem = None
|
||||
|
||||
@property
|
||||
def normalized_base_dir(self) -> str:
|
||||
"""Returns the base directory normalized for compatibility with a filesystem."""
|
||||
if self._normalized_base_dir is None:
|
||||
self._normalize_base_dir()
|
||||
return self._normalized_base_dir
|
||||
|
||||
@property
|
||||
def resolved_filesystem(self) -> "pyarrow.fs.FileSystem":
|
||||
"""Returns the filesystem resolved for compatibility with a base directory."""
|
||||
if self._resolved_filesystem is None:
|
||||
self._normalize_base_dir()
|
||||
return self._resolved_filesystem
|
||||
|
||||
def to_pyarrow(self) -> "pyarrow.dataset.Partitioning":
|
||||
"""Convert to a PyArrow dataset Partitioning.
|
||||
|
||||
Returns:
|
||||
Equivalent ``pyarrow.dataset.Partitioning`` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the partition style is not supported.
|
||||
"""
|
||||
import pyarrow.dataset as pds
|
||||
|
||||
schema = _partition_field_types_to_pa_schema(
|
||||
self.field_names or [],
|
||||
self.field_types or {},
|
||||
)
|
||||
|
||||
if self.style == PartitionStyle.HIVE:
|
||||
return pds.HivePartitioning(schema)
|
||||
elif self.style == PartitionStyle.DIRECTORY:
|
||||
return pds.DirectoryPartitioning(schema)
|
||||
else:
|
||||
raise ValueError(f"Unsupported partition style: {self.style}")
|
||||
|
||||
def _normalize_base_dir(self):
|
||||
"""Normalizes the partition base directory for compatibility with the
|
||||
given filesystem.
|
||||
|
||||
This should be called once a filesystem has been resolved to ensure that this
|
||||
base directory is correctly discovered at the root of all partitioned file
|
||||
paths.
|
||||
"""
|
||||
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
|
||||
|
||||
paths, self._resolved_filesystem = _resolve_paths_and_filesystem(
|
||||
self.base_dir,
|
||||
self.filesystem,
|
||||
)
|
||||
assert (
|
||||
len(paths) == 1
|
||||
), f"Expected 1 normalized base directory, but found {len(paths)}"
|
||||
normalized_base_dir = paths[0]
|
||||
if len(normalized_base_dir) and not normalized_base_dir.endswith("/"):
|
||||
normalized_base_dir += "/"
|
||||
self._normalized_base_dir = normalized_base_dir
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class PathPartitionParser:
|
||||
"""Partition parser for path-based partition formats.
|
||||
|
||||
Path-based partition formats embed all partition keys and values directly in
|
||||
their dataset file paths.
|
||||
|
||||
Two path partition formats are currently supported - `HIVE` and `DIRECTORY`.
|
||||
|
||||
For `HIVE` Partitioning, all partition directories under the base directory
|
||||
will be discovered based on `{key1}={value1}/{key2}={value2}` naming
|
||||
conventions. Key/value pairs do not need to be presented in the same
|
||||
order across all paths. Directory names nested under the base directory that
|
||||
don't follow this naming condition will be considered unpartitioned. If a
|
||||
partition filter is defined, then it will be called with an empty input
|
||||
dictionary for each unpartitioned file.
|
||||
|
||||
For `DIRECTORY` Partitioning, all directories under the base directory will
|
||||
be interpreted as partition values of the form `{value1}/{value2}`. An
|
||||
accompanying ordered list of partition field names must also be provided,
|
||||
where the order and length of all partition values must match the order and
|
||||
length of field names. Files stored directly in the base directory will
|
||||
be considered unpartitioned. If a partition filter is defined, then it will
|
||||
be called with an empty input dictionary for each unpartitioned file. For
|
||||
example, if the base directory is `"foo"`, then `"foo.csv"` and `"foo/bar.csv"`
|
||||
would be considered unpartitioned files but `"foo/bar/baz.csv"` would be associated
|
||||
with partition `"bar"`. If the base directory is undefined, then `"foo.csv"` would
|
||||
be unpartitioned, `"foo/bar.csv"` would be associated with partition `"foo"`, and
|
||||
"foo/bar/baz.csv" would be associated with partition `("foo", "bar")`.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def of(
|
||||
style: PartitionStyle = PartitionStyle.HIVE,
|
||||
base_dir: Optional[str] = None,
|
||||
field_names: Optional[List[str]] = None,
|
||||
field_types: Optional[Dict[str, PartitionDataType]] = None,
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
||||
) -> "PathPartitionParser":
|
||||
"""Creates a path-based partition parser using a flattened argument list.
|
||||
|
||||
Args:
|
||||
style: The partition style - may be either HIVE or DIRECTORY.
|
||||
base_dir: "/"-delimited base directory to start searching for partitions
|
||||
(exclusive). File paths outside of this directory will be considered
|
||||
unpartitioned. Specify `None` or an empty string to search for
|
||||
partitions in all file path directories.
|
||||
field_names: The partition key names. Required for DIRECTORY partitioning.
|
||||
Optional for HIVE partitioning. When non-empty, the order and length of
|
||||
partition key field names must match the order and length of partition
|
||||
directories discovered. Partition key field names are not required to
|
||||
exist in the dataset schema.
|
||||
field_types: A dictionary that maps partition key names to their desired
|
||||
data type. If not provided, the data type default to string.
|
||||
filesystem: Filesystem that will be used for partition path file I/O.
|
||||
|
||||
Returns:
|
||||
The new path-based partition parser.
|
||||
"""
|
||||
scheme = Partitioning(style, base_dir, field_names, field_types, filesystem)
|
||||
return PathPartitionParser(scheme)
|
||||
|
||||
def __init__(self, partitioning: Partitioning):
|
||||
"""Creates a path-based partition parser.
|
||||
|
||||
Args:
|
||||
partitioning: The path-based partition scheme. The parser starts
|
||||
searching for partitions from this scheme's base directory. File paths
|
||||
outside the base directory will be considered unpartitioned. If the
|
||||
base directory is `None` or an empty string then this will search for
|
||||
partitions in all file path directories. Field names are required for
|
||||
DIRECTORY partitioning, and optional for HIVE partitioning. When
|
||||
non-empty, the order and length of partition key field names must match
|
||||
the order and length of partition directories discovered.
|
||||
"""
|
||||
style = partitioning.style
|
||||
field_names = partitioning.field_names
|
||||
if style == PartitionStyle.DIRECTORY and not field_names:
|
||||
raise ValueError(
|
||||
"Directory partitioning requires a corresponding list of "
|
||||
"partition key field names. Please retry your request with one "
|
||||
"or more field names specified."
|
||||
)
|
||||
parsers = {
|
||||
PartitionStyle.HIVE: self._parse_hive_path,
|
||||
PartitionStyle.DIRECTORY: self._parse_dir_path,
|
||||
}
|
||||
self._parser_fn: Callable[[str], Dict[str, str]] = parsers.get(style)
|
||||
if self._parser_fn is None:
|
||||
raise ValueError(
|
||||
f"Unsupported partition style: {style}. "
|
||||
f"Supported styles: {parsers.keys()}"
|
||||
)
|
||||
self._scheme = partitioning
|
||||
|
||||
def __call__(self, path: str) -> Dict[str, str]:
|
||||
"""Parses partition keys and values from a single file path.
|
||||
|
||||
Args:
|
||||
path: Input file path to parse.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping directory partition keys to values from the input file
|
||||
path. Returns an empty dictionary for unpartitioned files.
|
||||
"""
|
||||
dir_path = self._dir_path_trim_base(path)
|
||||
if dir_path is None:
|
||||
return {}
|
||||
partitions: Dict[str, str] = self._parser_fn(dir_path)
|
||||
|
||||
for field, data_type in self._scheme.field_types.items():
|
||||
partitions[field] = _cast_value(partitions[field], data_type)
|
||||
|
||||
return partitions
|
||||
|
||||
def evaluate_predicate_on_partition(self, path: str, predicate: "Expr") -> bool:
|
||||
"""Evaluate a predicate expression against partition values from a path.
|
||||
|
||||
This method enables partition pruning by evaluating predicates that reference
|
||||
partition columns against the partition values parsed from file paths.
|
||||
|
||||
Args:
|
||||
path: File path to parse partition values from.
|
||||
predicate: Expression that references partition columns.
|
||||
|
||||
Returns:
|
||||
True if the partition satisfies the predicate (should read the file),
|
||||
False if it doesn't (can skip the file for partition pruning).
|
||||
"""
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_evaluator import (
|
||||
NativeExpressionEvaluator,
|
||||
)
|
||||
|
||||
# Parse partition values from the file path
|
||||
partition_values = self(path)
|
||||
|
||||
if not partition_values:
|
||||
# Unpartitioned file - exclude it when filtering on partition columns
|
||||
# If the predicate references partition columns and the file doesn't have
|
||||
# partition values in its path, we can't determine if it matches
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create a single-row table with partition values
|
||||
partition_table = pa.table(
|
||||
{col: [val] for col, val in partition_values.items()}
|
||||
)
|
||||
|
||||
# Evaluate using Ray Data's native evaluator
|
||||
evaluator = NativeExpressionEvaluator(partition_table)
|
||||
result = evaluator.visit(predicate)
|
||||
|
||||
# Extract boolean result from array-like types.
|
||||
# NOTE: We must use ``.as_py()`` for PyArrow scalars because
|
||||
# ``bool(pa.BooleanScalar(False))`` returns ``True`` (it
|
||||
# checks validity/not-null, not the boolean value).
|
||||
if isinstance(result, (pa.Array, pa.ChunkedArray)):
|
||||
assert (
|
||||
len(result) == 1
|
||||
), f"Result expected to be of length 1 (got {result})"
|
||||
return bool(result[0].as_py())
|
||||
|
||||
if isinstance(result, np.ndarray):
|
||||
assert (
|
||||
len(result) == 1
|
||||
), f"Result expected to be of length 1 (got {result})"
|
||||
return bool(result[0])
|
||||
|
||||
# Import pandas here to avoid circular dependencies
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(result, pd.Series):
|
||||
assert (
|
||||
len(result) == 1
|
||||
), f"Result expected to be of length 1 (got {result})"
|
||||
return bool(result.iloc[0])
|
||||
|
||||
# Scalar result
|
||||
if isinstance(result, pa.Scalar):
|
||||
return bool(result.as_py())
|
||||
|
||||
return bool(result)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to evaluate predicate on partition for path %s, "
|
||||
"conservatively including file.",
|
||||
path,
|
||||
exc_info=True,
|
||||
)
|
||||
return True
|
||||
|
||||
@property
|
||||
def scheme(self) -> Partitioning:
|
||||
"""Returns the partitioning for this parser."""
|
||||
return self._scheme
|
||||
|
||||
def _dir_path_trim_base(self, path: str) -> Optional[str]:
|
||||
"""Trims the normalized base directory and returns the directory path.
|
||||
|
||||
Returns None if the path does not start with the normalized base directory.
|
||||
Simply returns the directory path if the base directory is undefined.
|
||||
"""
|
||||
if not path.startswith(self._scheme.normalized_base_dir):
|
||||
return None
|
||||
path = path[len(self._scheme.normalized_base_dir) :]
|
||||
return posixpath.dirname(path)
|
||||
|
||||
def _parse_hive_path(self, dir_path: str) -> Dict[str, str]:
|
||||
"""Hive partition path parser.
|
||||
|
||||
Returns a dictionary mapping partition keys to values given a hive-style
|
||||
partition path of the form "{key1}={value1}/{key2}={value2}/..." or an empty
|
||||
dictionary for unpartitioned files.
|
||||
"""
|
||||
dirs = [d for d in dir_path.split("/") if d and (d.count("=") == 1)]
|
||||
kv_pairs = [d.split("=") for d in dirs] if dirs else []
|
||||
# NOTE: PyArrow URL-encodes partition values when writing to cloud storage. To
|
||||
# ensure the values are consistent when you read them back, we need to
|
||||
# URL-decode them. See https://github.com/apache/arrow/issues/34905.
|
||||
kv_pairs = [[key, urllib.parse.unquote(value)] for key, value in kv_pairs]
|
||||
|
||||
field_names = self._scheme.field_names
|
||||
if field_names and kv_pairs:
|
||||
if len(kv_pairs) != len(field_names):
|
||||
raise ValueError(
|
||||
f"Expected {len(field_names)} partition value(s) but found "
|
||||
f"{len(kv_pairs)}: {kv_pairs}."
|
||||
)
|
||||
for i, field_name in enumerate(field_names):
|
||||
if kv_pairs[i][0] != field_name:
|
||||
raise ValueError(
|
||||
f"Expected partition key {field_name} but found "
|
||||
f"{kv_pairs[i][0]}"
|
||||
)
|
||||
return dict(kv_pairs)
|
||||
|
||||
def _parse_dir_path(self, dir_path: str) -> Dict[str, str]:
|
||||
"""Directory partition path parser.
|
||||
|
||||
Returns a dictionary mapping directory partition keys to values from a
|
||||
partition path of the form "{value1}/{value2}/..." or an empty dictionary for
|
||||
unpartitioned files.
|
||||
|
||||
Requires a corresponding ordered list of partition key field names to map the
|
||||
correct key to each value.
|
||||
"""
|
||||
dirs = [d for d in dir_path.split("/") if d]
|
||||
field_names = self._scheme.field_names
|
||||
|
||||
if dirs and len(dirs) != len(field_names):
|
||||
raise ValueError(
|
||||
f"Expected {len(field_names)} partition value(s) but found "
|
||||
f"{len(dirs)}: {dirs}."
|
||||
)
|
||||
|
||||
if not dirs:
|
||||
return {}
|
||||
return {
|
||||
field: directory
|
||||
for field, directory in zip(field_names, dirs)
|
||||
if field is not None
|
||||
}
|
||||
|
||||
|
||||
@PublicAPI(stability="beta")
|
||||
class PathPartitionFilter:
|
||||
"""Partition filter for path-based partition formats.
|
||||
|
||||
Used to explicitly keep or reject files based on a custom filter function that
|
||||
takes partition keys and values parsed from the file's path as input.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def of(
|
||||
filter_fn: Callable[[Dict[str, str]], bool],
|
||||
style: PartitionStyle = PartitionStyle.HIVE,
|
||||
base_dir: Optional[str] = None,
|
||||
field_names: Optional[List[str]] = None,
|
||||
field_types: Optional[Dict[str, PartitionDataType]] = None,
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
||||
) -> "PathPartitionFilter":
|
||||
"""Creates a path-based partition filter using a flattened argument list.
|
||||
|
||||
Args:
|
||||
filter_fn: Callback used to filter partitions. Takes a dictionary mapping
|
||||
partition keys to values as input. Unpartitioned files are denoted with
|
||||
an empty input dictionary. Returns `True` to read a file for that
|
||||
partition or `False` to skip it. Partition keys and values are always
|
||||
strings read from the filesystem path. For example, this removes all
|
||||
unpartitioned files:
|
||||
|
||||
.. code:: python
|
||||
|
||||
lambda d: True if d else False
|
||||
|
||||
This raises an assertion error for any unpartitioned file found:
|
||||
|
||||
.. code:: python
|
||||
|
||||
def do_assert(val, msg):
|
||||
assert val, msg
|
||||
|
||||
lambda d: do_assert(d, "Expected all files to be partitioned!")
|
||||
|
||||
And this only reads files from January, 2022 partitions:
|
||||
|
||||
.. code:: python
|
||||
|
||||
lambda d: d["month"] == "January" and d["year"] == "2022"
|
||||
|
||||
style: The partition style - may be either HIVE or DIRECTORY.
|
||||
base_dir: "/"-delimited base directory to start searching for partitions
|
||||
(exclusive). File paths outside of this directory will be considered
|
||||
unpartitioned. Specify `None` or an empty string to search for
|
||||
partitions in all file path directories.
|
||||
field_names: The partition key names. Required for DIRECTORY partitioning.
|
||||
Optional for HIVE partitioning. When non-empty, the order and length of
|
||||
partition key field names must match the order and length of partition
|
||||
directories discovered. Partition key field names are not required to
|
||||
exist in the dataset schema.
|
||||
field_types: A dictionary that maps partition key names to their desired
|
||||
data type. If not provided, the data type defaults to string.
|
||||
filesystem: Filesystem that will be used for partition path file I/O.
|
||||
|
||||
Returns:
|
||||
The new path-based partition filter.
|
||||
"""
|
||||
scheme = Partitioning(style, base_dir, field_names, field_types, filesystem)
|
||||
path_partition_parser = PathPartitionParser(scheme)
|
||||
return PathPartitionFilter(path_partition_parser, filter_fn)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path_partition_parser: PathPartitionParser,
|
||||
filter_fn: Callable[[Dict[str, str]], bool],
|
||||
):
|
||||
"""Creates a new path-based partition filter based on a parser.
|
||||
|
||||
Args:
|
||||
path_partition_parser: The path-based partition parser.
|
||||
filter_fn: Callback used to filter partitions. Takes a dictionary mapping
|
||||
partition keys to values as input. Unpartitioned files are denoted with
|
||||
an empty input dictionary. Returns `True` to read a file for that
|
||||
partition or `False` to skip it. Partition keys and values are always
|
||||
strings read from the filesystem path. For example, this removes all
|
||||
unpartitioned files:
|
||||
``lambda d: True if d else False``
|
||||
This raises an assertion error for any unpartitioned file found:
|
||||
``lambda d: assert d, "Expected all files to be partitioned!"``
|
||||
And this only reads files from January, 2022 partitions:
|
||||
``lambda d: d["month"] == "January" and d["year"] == "2022"``
|
||||
"""
|
||||
self._parser = path_partition_parser
|
||||
self._filter_fn = filter_fn
|
||||
|
||||
def __call__(self, paths: List[str]) -> List[str]:
|
||||
"""Returns all paths that pass this partition scheme's partition filter.
|
||||
|
||||
If no partition filter is set, then returns all input paths. If a base
|
||||
directory is set, then only paths under this base directory will be parsed
|
||||
for partitions. All paths outside of this base directory will automatically
|
||||
be considered unpartitioned, and passed into the filter function as empty
|
||||
dictionaries.
|
||||
|
||||
Also normalizes the partition base directory for compatibility with the
|
||||
given filesystem before applying the filter.
|
||||
|
||||
Args:
|
||||
paths: Paths to pass through the partition filter function. All
|
||||
paths should be normalized for compatibility with the given
|
||||
filesystem.
|
||||
Returns:
|
||||
List of paths that pass the partition filter, or all paths if no
|
||||
partition filter is defined.
|
||||
"""
|
||||
filtered_paths = paths
|
||||
if self._filter_fn is not None:
|
||||
filtered_paths = [path for path in paths if self.apply(path)]
|
||||
return filtered_paths
|
||||
|
||||
def apply(self, path: str) -> bool:
|
||||
return self._filter_fn(self._parser(path))
|
||||
|
||||
@property
|
||||
def parser(self) -> PathPartitionParser:
|
||||
"""Returns the path partition parser for this filter."""
|
||||
return self._parser
|
||||
|
||||
|
||||
def _partition_field_types_to_pa_schema(
|
||||
field_names: List[str],
|
||||
field_types: Dict[str, PartitionDataType],
|
||||
) -> "pyarrow.Schema":
|
||||
"""Build a PyArrow schema from partition field names and Python types.
|
||||
|
||||
Args:
|
||||
field_names: Ordered partition key names.
|
||||
field_types: Mapping from field name to Python type. Fields not
|
||||
present in the map default to ``str`` (``pa.string()``).
|
||||
|
||||
Returns:
|
||||
A ``pyarrow.Schema`` with one field per partition key.
|
||||
"""
|
||||
import pyarrow as pa
|
||||
|
||||
type_map = {
|
||||
int: pa.int64(),
|
||||
float: pa.float64(),
|
||||
bool: pa.bool_(),
|
||||
str: pa.string(),
|
||||
}
|
||||
fields = []
|
||||
for name in field_names:
|
||||
py_type: PartitionDataType = field_types.get(name, str)
|
||||
pa_type = type_map.get(py_type, pa.string())
|
||||
fields.append(pa.field(name, pa_type))
|
||||
return pa.schema(fields)
|
||||
|
||||
|
||||
def _cast_value(value: str, data_type: PartitionDataType) -> Any:
|
||||
if data_type is int:
|
||||
return int(value)
|
||||
elif data_type is float:
|
||||
return float(value)
|
||||
elif data_type is bool:
|
||||
return value.lower() == "true"
|
||||
else:
|
||||
return value
|
||||
@@ -0,0 +1,492 @@
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from ray.data._internal.util import (
|
||||
RetryingPyFileSystem,
|
||||
_normalize_paths_to_strings,
|
||||
_resolve_custom_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import fsspec.spec
|
||||
import pyarrow
|
||||
|
||||
|
||||
def _get_fsspec_http_filesystem() -> "pyarrow.fs.PyFileSystem":
|
||||
"""Get fsspec HTTPFileSystem wrapped in PyArrow PyFileSystem.
|
||||
|
||||
Returns:
|
||||
PyFileSystem wrapping fsspec HTTPFileSystem.
|
||||
|
||||
Raises:
|
||||
ImportError: If fsspec is not installed.
|
||||
"""
|
||||
try:
|
||||
import fsspec # noqa: F401
|
||||
from fsspec.implementations.http import HTTPFileSystem
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError("Please install fsspec to read files from HTTP.") from None
|
||||
|
||||
from pyarrow.fs import FSSpecHandler, PyFileSystem
|
||||
|
||||
return PyFileSystem(FSSpecHandler(HTTPFileSystem()))
|
||||
|
||||
|
||||
def _validate_and_wrap_filesystem(
|
||||
filesystem: Optional[
|
||||
Union["pyarrow.fs.FileSystem", "fsspec.spec.AbstractFileSystem"]
|
||||
],
|
||||
) -> Optional["pyarrow.fs.FileSystem"]:
|
||||
"""Validate filesystem and wrap fsspec filesystems in PyArrow.
|
||||
|
||||
Args:
|
||||
filesystem: Filesystem to validate and potentially wrap. Can be None,
|
||||
a pyarrow.fs.FileSystem, or an fsspec.spec.AbstractFileSystem.
|
||||
|
||||
Returns:
|
||||
None if filesystem is None, otherwise a pyarrow.fs.FileSystem
|
||||
(either the original if already PyArrow, or wrapped if fsspec).
|
||||
|
||||
Raises:
|
||||
TypeError: If filesystem is not None and not a valid pyarrow or fsspec filesystem.
|
||||
"""
|
||||
if filesystem is None:
|
||||
return None
|
||||
|
||||
from pyarrow.fs import FileSystem
|
||||
|
||||
if isinstance(filesystem, FileSystem):
|
||||
return filesystem
|
||||
|
||||
try:
|
||||
import fsspec # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
raise TypeError("fsspec is not installed") from None
|
||||
|
||||
if not isinstance(filesystem, fsspec.spec.AbstractFileSystem):
|
||||
raise TypeError(
|
||||
f"Filesystem must conform to pyarrow.fs.FileSystem or "
|
||||
f"fsspec.spec.AbstractFileSystem, got: {type(filesystem).__name__}"
|
||||
)
|
||||
|
||||
from pyarrow.fs import FSSpecHandler, PyFileSystem
|
||||
|
||||
return PyFileSystem(FSSpecHandler(filesystem))
|
||||
|
||||
|
||||
def _try_resolve_with_encoding(
|
||||
path: str,
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"],
|
||||
) -> Tuple["pyarrow.fs.FileSystem", str]:
|
||||
"""Try resolving a path with URL encoding for special characters.
|
||||
|
||||
This handles paths with special characters like ';', '?', '#' that
|
||||
may cause URI parsing errors.
|
||||
|
||||
Args:
|
||||
path: The path to resolve.
|
||||
filesystem: Optional filesystem to validate against.
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_filesystem, resolved_path).
|
||||
"""
|
||||
from pyarrow.fs import _resolve_filesystem_and_path
|
||||
|
||||
encoded_path = quote(path, safe="/:", errors="ignore")
|
||||
resolved_filesystem, resolved_path = _resolve_filesystem_and_path(
|
||||
encoded_path, filesystem
|
||||
)
|
||||
return resolved_filesystem, unquote(resolved_path, errors="ignore")
|
||||
|
||||
|
||||
def _has_file_extension(path: str, extensions: Optional[List[str]]) -> bool:
|
||||
"""Check if a path has a file extension in the provided list.
|
||||
|
||||
Examples:
|
||||
>>> _has_file_extension("foo.csv", ["csv"])
|
||||
True
|
||||
>>> _has_file_extension("foo.CSV", ["csv"])
|
||||
True
|
||||
>>> _has_file_extension("foo.CSV", [".csv"])
|
||||
True
|
||||
>>> _has_file_extension("foo.csv", ["json", "jsonl"])
|
||||
False
|
||||
>>> _has_file_extension("foo.csv", None)
|
||||
True
|
||||
|
||||
Args:
|
||||
path: The path to check.
|
||||
extensions: A list of extensions to check against. If `None`, any extension is
|
||||
considered valid.
|
||||
|
||||
Returns:
|
||||
``True`` if ``path`` ends with one of the provided extensions (or
|
||||
``extensions`` is ``None``), otherwise ``False``.
|
||||
"""
|
||||
assert extensions is None or isinstance(extensions, list), type(extensions)
|
||||
|
||||
if extensions is None:
|
||||
return True
|
||||
|
||||
# If the user-specified extensions don't contain a leading dot, we add it here
|
||||
extensions = [
|
||||
f".{ext.lower()}" if not ext.startswith(".") else ext.lower()
|
||||
for ext in extensions
|
||||
]
|
||||
# Ignore query components when checking extensions (for example,
|
||||
# versioned object-store paths like `...parquet?versionId=...`).
|
||||
# Keep `#` untouched because it can be part of object keys.
|
||||
parsed_path = path.split("?", 1)[0]
|
||||
return any(parsed_path.lower().endswith(ext) for ext in extensions)
|
||||
|
||||
|
||||
# Mapping from URI schemes to compatible filesystem type_name values.
|
||||
# Used to validate that a cached filesystem is compatible with a given URI scheme
|
||||
# before attempting to use it, avoiding silent failures from PyArrow when the
|
||||
# wrong filesystem type is passed to _resolve_filesystem_and_path.
|
||||
_SCHEME_TO_FS_TYPE_NAMES = {
|
||||
"": ("local",), # No scheme = local filesystem
|
||||
"file": ("local",), # file:// = local filesystem
|
||||
"s3": ("s3",), # s3:// = S3 filesystem
|
||||
"s3a": ("s3",), # s3a:// = S3 filesystem (Hadoop compat)
|
||||
"gs": ("gcs",), # gs:// = GCS filesystem
|
||||
"gcs": ("gcs",), # gcs:// = GCS filesystem
|
||||
"hdfs": ("hdfs",), # hdfs:// = Hadoop filesystem
|
||||
"viewfs": ("hdfs",), # viewfs:// = Hadoop filesystem
|
||||
"abfs": ("abfs",), # abfs:// = Azure Blob FileSystem
|
||||
"abfss": ("abfs",), # abfss:// = Azure Blob FileSystem (TLS)
|
||||
"http": ("py",), # http:// = fsspec HTTP (wrapped in PyFileSystem)
|
||||
"https": ("py",), # https:// = fsspec HTTP (wrapped in PyFileSystem)
|
||||
}
|
||||
|
||||
|
||||
def _is_filesystem_compatible_with_scheme(
|
||||
filesystem: "pyarrow.fs.FileSystem",
|
||||
scheme: str,
|
||||
) -> bool:
|
||||
"""Check if a filesystem is compatible with a URI scheme.
|
||||
|
||||
Uses PyArrow's `type_name` property for reliable filesystem type detection.
|
||||
This prevents silently using the wrong filesystem for a URI, which can result
|
||||
in malformed paths or incorrect behavior.
|
||||
|
||||
Args:
|
||||
filesystem: The PyArrow filesystem to check.
|
||||
scheme: The URI scheme (e.g., 's3', 'gs', 'http', 'file', '').
|
||||
|
||||
Returns:
|
||||
True if the filesystem can handle the scheme, False otherwise.
|
||||
"""
|
||||
# Get expected type names for this scheme
|
||||
expected_types = _SCHEME_TO_FS_TYPE_NAMES.get(scheme.lower())
|
||||
if expected_types is None:
|
||||
# Unknown scheme (e.g., abfs://, az://, custom protocols) - trust user's filesystem
|
||||
# This preserves backward compatibility for custom filesystems
|
||||
return True
|
||||
|
||||
# Unwrap RetryingPyFileSystem to get the underlying filesystem's type
|
||||
from ray.data._internal.util import RetryingPyFileSystem
|
||||
|
||||
unwrapped = (
|
||||
filesystem.unwrap()
|
||||
if isinstance(filesystem, RetryingPyFileSystem)
|
||||
else filesystem
|
||||
)
|
||||
|
||||
# Get the actual filesystem type
|
||||
fs_type = unwrapped.type_name
|
||||
|
||||
# For PyFileSystem (fsspec wrappers), check the inner fsspec protocol
|
||||
# rather than relying on type_name alone, since all fsspec wrappers
|
||||
# share type_name "py" regardless of the underlying protocol.
|
||||
if fs_type in ("py", "RetryingPyFileSystem") or fs_type.startswith("py::"):
|
||||
from pyarrow.fs import FSSpecHandler, PyFileSystem
|
||||
|
||||
actual_fs = filesystem
|
||||
if isinstance(actual_fs, RetryingPyFileSystem):
|
||||
actual_fs = actual_fs.unwrap()
|
||||
|
||||
# After unwrapping, the inner filesystem may be a native PyArrow
|
||||
# filesystem (e.g., S3FileSystem) rather than a PyFileSystem wrapper.
|
||||
# Fall back to direct type_name matching in that case.
|
||||
if not isinstance(actual_fs, PyFileSystem):
|
||||
return actual_fs.type_name in expected_types
|
||||
|
||||
if isinstance(actual_fs.handler, FSSpecHandler):
|
||||
inner_fs = actual_fs.handler.fs
|
||||
protocol = getattr(inner_fs, "protocol", None)
|
||||
if protocol is not None:
|
||||
if isinstance(protocol, str):
|
||||
protocol = (protocol,)
|
||||
# Match scheme against fsspec protocol(s)
|
||||
if scheme in protocol:
|
||||
return True
|
||||
# For bare paths (empty scheme), trust user-provided filesystem
|
||||
if scheme == "":
|
||||
return True
|
||||
|
||||
# Fallback: check HTTP
|
||||
if scheme in ("http", "https"):
|
||||
return _is_http_filesystem(filesystem)
|
||||
|
||||
return False
|
||||
|
||||
# Direct match for native PyArrow filesystems (s3, gcs, local, hdfs, etc.)
|
||||
return fs_type in expected_types
|
||||
|
||||
|
||||
def _resolve_single_path_with_fallback(
|
||||
path: str,
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
||||
) -> Tuple["pyarrow.fs.FileSystem", str]:
|
||||
"""Resolve a single path with filesystem, with fallback to re-resolution on error.
|
||||
|
||||
This is a helper for lazy filesystem resolution. If a filesystem is provided,
|
||||
it first validates that the filesystem type is compatible with the URI scheme,
|
||||
then attempts to resolve the path. If the filesystem is incompatible or
|
||||
resolution fails, it re-resolves without the cached filesystem.
|
||||
|
||||
Args:
|
||||
path: A single file/directory path.
|
||||
filesystem: Optional cached filesystem from previous resolution.
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_filesystem, resolved_path).
|
||||
|
||||
Raises:
|
||||
ValueError: If path resolution fails.
|
||||
ImportError: If required dependencies are missing.
|
||||
"""
|
||||
import pyarrow as pa
|
||||
from pyarrow.fs import _resolve_filesystem_and_path
|
||||
|
||||
path = _resolve_custom_scheme(path)
|
||||
|
||||
# Validate/wrap filesystem if needed
|
||||
try:
|
||||
filesystem = _validate_and_wrap_filesystem(filesystem)
|
||||
except TypeError as e:
|
||||
raise ValueError(f"Invalid filesystem provided: {e}") from e
|
||||
|
||||
# Parse scheme to validate filesystem compatibility
|
||||
parsed = urlparse(path, allow_fragments=False)
|
||||
scheme = parsed.scheme.lower() if parsed.scheme else ""
|
||||
|
||||
# Check HTTP scheme FIRST - PyArrow doesn't support HTTP/HTTPS natively
|
||||
if scheme in ("http", "https"):
|
||||
# If we have a compatible cached HTTP filesystem, use it
|
||||
if filesystem is not None and _is_filesystem_compatible_with_scheme(
|
||||
filesystem, scheme
|
||||
):
|
||||
return filesystem, path
|
||||
# Otherwise create a new HTTP filesystem
|
||||
try:
|
||||
resolved_filesystem = _get_fsspec_http_filesystem()
|
||||
resolved_path = path
|
||||
return resolved_filesystem, resolved_path
|
||||
except ImportError as import_error:
|
||||
raise ImportError(
|
||||
f"Cannot resolve HTTP path '{path}': {import_error}"
|
||||
) from import_error
|
||||
|
||||
# Try with provided filesystem only if scheme is compatible (fast path for cached FS)
|
||||
if filesystem is not None and _is_filesystem_compatible_with_scheme(
|
||||
filesystem, scheme
|
||||
):
|
||||
try:
|
||||
_, resolved_path = _resolve_filesystem_and_path(path, filesystem)
|
||||
# Return the wrapped filesystem we passed in.
|
||||
return filesystem, resolved_path
|
||||
except Exception:
|
||||
# Fall through to full resolution without cached filesystem
|
||||
pass
|
||||
|
||||
# Full resolution without cached filesystem
|
||||
try:
|
||||
resolved_filesystem, resolved_path = _resolve_filesystem_and_path(path, None)
|
||||
except (pa.lib.ArrowInvalid, ValueError) as original_error:
|
||||
# Try URL encoding for paths with special characters that may cause parsing issues
|
||||
try:
|
||||
resolved_filesystem, resolved_path = _try_resolve_with_encoding(path, None)
|
||||
except (pa.lib.ArrowInvalid, ValueError, TypeError) as encoding_error:
|
||||
# If encoding doesn't help, raise with both errors for full context
|
||||
raise ValueError(
|
||||
f"Failed to resolve path '{path}'. Initial error: {original_error}. "
|
||||
f"URL encoding fallback also failed: {encoding_error}"
|
||||
) from original_error
|
||||
except TypeError as e:
|
||||
raise ValueError(f"The path: '{path}' has an invalid type {e}") from e
|
||||
|
||||
return resolved_filesystem, resolved_path
|
||||
|
||||
|
||||
def _resolve_paths_and_filesystem(
|
||||
paths: Union[str, List[str]],
|
||||
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
||||
) -> Tuple[List[str], "pyarrow.fs.FileSystem"]:
|
||||
"""
|
||||
Resolves and normalizes all provided paths, infers a filesystem from the
|
||||
paths and assumes that all paths use the same filesystem.
|
||||
|
||||
Args:
|
||||
paths: A single file/directory path or a list of file/directory paths.
|
||||
A list of paths can contain both files and directories.
|
||||
filesystem: The filesystem implementation that should be used for
|
||||
reading these files. If None, a filesystem will be inferred. If not
|
||||
None, the provided filesystem will still be validated against all
|
||||
filesystems inferred from the provided paths to ensure
|
||||
compatibility.
|
||||
|
||||
Returns:
|
||||
A pair ``(resolved_paths, filesystem)``. *resolved_paths* lists the
|
||||
normalized paths for each input path that resolved successfully, in
|
||||
order.
|
||||
|
||||
If *filesystem* was ``None``, the returned *filesystem* is set from
|
||||
``resolved_filesystem`` on the first successful path and is left
|
||||
unchanged on later iterations whenever it is already non-``None``.
|
||||
|
||||
If *filesystem* was not ``None``, the returned value is always that
|
||||
same validated instance, even when ``_resolve_single_path_with_fallback``
|
||||
inferred a different filesystem for a given path. Callers should pass
|
||||
``None`` or a filesystem compatible with the path URIs so returned paths
|
||||
and filesystem stay consistent.
|
||||
|
||||
All paths are assumed to use one storage backend; mixing unrelated URI
|
||||
schemes in a single call is unsupported and may fail when reading.
|
||||
"""
|
||||
paths = _normalize_paths_to_strings(paths)
|
||||
|
||||
# Validate/wrap filesystem upfront so we return a proper PyArrow filesystem
|
||||
filesystem = _validate_and_wrap_filesystem(filesystem)
|
||||
|
||||
resolved_paths = []
|
||||
for path in paths:
|
||||
try:
|
||||
resolved_filesystem, resolved_path = _resolve_single_path_with_fallback(
|
||||
path, filesystem
|
||||
)
|
||||
except (ValueError, ImportError) as e:
|
||||
logger.warning(f"Failed to resolve path '{path}': {e}, skipping")
|
||||
continue
|
||||
|
||||
if filesystem is None:
|
||||
filesystem = resolved_filesystem
|
||||
|
||||
# If the PyArrow filesystem is handled by a fsspec HTTPFileSystem, the protocol/
|
||||
# scheme of paths should not be unwrapped/removed, because HTTPFileSystem
|
||||
# expects full file paths including protocol/scheme. This is different behavior
|
||||
# compared to other file system implementation in pyarrow.fs.FileSystem.
|
||||
if not _is_http_filesystem(resolved_filesystem):
|
||||
resolved_path = _unwrap_protocol(resolved_path)
|
||||
|
||||
resolved_path = resolved_filesystem.normalize_path(resolved_path)
|
||||
resolved_paths.append(resolved_path)
|
||||
|
||||
return resolved_paths, filesystem
|
||||
|
||||
|
||||
def _split_uri(uri: str):
|
||||
"""Split a URI into (store_url, path) for use with obstore.
|
||||
|
||||
e.g. "s3://my-bucket/a/b/c.jpg" -> ("s3://my-bucket", "a/b/c.jpg")
|
||||
"https://host.com/a/b?X-Amz-Signature=x" -> ("https://host.com", "a/b?X-Amz-Signature=x")
|
||||
|
||||
The query string is preserved so signed URLs (e.g. pre-signed S3 HTTPS)
|
||||
reach obstore intact. Semicolons in object keys normally appear in
|
||||
``parsed.path`` (not ``parsed.params``) for typical ``urlparse`` output.
|
||||
|
||||
Only the first leading ``/`` after the authority (as reported in
|
||||
``parsed.path``) is removed. Extra leading slashes belong to the object
|
||||
key (e.g. ``s3://bucket//abs/key`` -> key ``/abs/key``), so
|
||||
``str.lstrip("/")`` is not used.
|
||||
"""
|
||||
parsed = urlparse(uri, allow_fragments=False)
|
||||
store_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
raw_path = parsed.path
|
||||
path = raw_path[1:] if raw_path.startswith("/") else raw_path
|
||||
if parsed.query:
|
||||
path = f"{path}?{parsed.query}"
|
||||
return store_url, path
|
||||
|
||||
|
||||
def _is_http_filesystem(fs: "pyarrow.fs.FileSystem") -> bool:
|
||||
"""Return whether ``fs`` is a PyFileSystem handled by a fsspec HTTPFileSystem."""
|
||||
from pyarrow.fs import FSSpecHandler, PyFileSystem
|
||||
|
||||
# Try to import HTTPFileSystem
|
||||
try:
|
||||
from fsspec.implementations.http import HTTPFileSystem
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
if isinstance(fs, RetryingPyFileSystem):
|
||||
fs = fs.unwrap()
|
||||
|
||||
if not isinstance(fs, PyFileSystem):
|
||||
return False
|
||||
|
||||
return isinstance(fs.handler, FSSpecHandler) and isinstance(
|
||||
fs.handler.fs, HTTPFileSystem
|
||||
)
|
||||
|
||||
|
||||
def _unwrap_protocol(path):
|
||||
"""
|
||||
Slice off any protocol prefixes on path.
|
||||
"""
|
||||
if sys.platform == "win32" and _is_local_windows_path(path):
|
||||
# Represent as posix path such that downstream functions properly handle it.
|
||||
# This is executed when 'file://' is NOT included in the path.
|
||||
return pathlib.Path(path).as_posix()
|
||||
|
||||
parsed = urlparse(path, allow_fragments=False) # support '#' in path
|
||||
params = ";" + parsed.params if parsed.params else "" # support ';' in path
|
||||
query = "?" + parsed.query if parsed.query else "" # support '?' in path
|
||||
netloc = parsed.netloc
|
||||
if parsed.scheme == "s3" and "@" in parsed.netloc:
|
||||
# If the path contains an @, it is assumed to be an anonymous
|
||||
# credentialed path, and we need to strip off the credentials.
|
||||
netloc = parsed.netloc.split("@")[-1]
|
||||
|
||||
parsed_path = parsed.path
|
||||
# urlparse prepends the path with a '/'. This does not work on Windows
|
||||
# so if this is the case strip the leading slash.
|
||||
if (
|
||||
sys.platform == "win32"
|
||||
and not netloc
|
||||
and len(parsed_path) >= 3
|
||||
and parsed_path[0] == "/" # The problematic leading slash
|
||||
and parsed_path[1].isalpha() # Ensure it is a drive letter.
|
||||
and parsed_path[2:4] in (":", ":/")
|
||||
):
|
||||
parsed_path = parsed_path[1:]
|
||||
|
||||
return netloc + parsed_path + params + query
|
||||
|
||||
|
||||
def _is_http_url(path) -> bool:
|
||||
parsed = urlparse(path)
|
||||
return parsed.scheme in ("http", "https")
|
||||
|
||||
|
||||
def _is_local_windows_path(path: str) -> bool:
|
||||
"""Determines if path is a Windows file-system location."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
|
||||
if len(path) >= 1 and path[0] == "\\":
|
||||
return True
|
||||
if (
|
||||
len(path) >= 3
|
||||
and path[1] == ":"
|
||||
and (path[2] == "/" or path[2] == "\\")
|
||||
and path[0].isalpha()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,109 @@
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
|
||||
import ray
|
||||
from ray.data.block import Block
|
||||
|
||||
|
||||
def _iter_sliced_blocks(
|
||||
blocks: Iterable[Block], per_task_row_limit: int
|
||||
) -> Iterable[Block]:
|
||||
"""Iterate over blocks, accumulating rows up to the per-task row limit."""
|
||||
rows_read = 0
|
||||
for block in blocks:
|
||||
if rows_read >= per_task_row_limit:
|
||||
break
|
||||
|
||||
from ray.data.block import BlockAccessor
|
||||
|
||||
accessor = BlockAccessor.for_block(block)
|
||||
block_rows = accessor.num_rows()
|
||||
|
||||
if rows_read + block_rows <= per_task_row_limit:
|
||||
yield block
|
||||
rows_read += block_rows
|
||||
else:
|
||||
# Slice the block to meet the limit exactly
|
||||
remaining_rows = per_task_row_limit - rows_read
|
||||
sliced_block = accessor.slice(0, remaining_rows, copy=True)
|
||||
yield sliced_block
|
||||
break
|
||||
|
||||
|
||||
def _validate_head_node_resources_for_local_scheduling(
|
||||
ray_remote_args: Dict[str, Any],
|
||||
*,
|
||||
op_description: str,
|
||||
default_num_cpus: int = 1,
|
||||
default_num_gpus: int = 0,
|
||||
default_memory: int = 0,
|
||||
) -> None:
|
||||
"""Ensure the head node has enough resources before pinning work there.
|
||||
|
||||
Local paths (``local://``) and other driver-local I/O schedule tasks on the
|
||||
head node via ``NodeAffinitySchedulingStrategy``. If the head node was
|
||||
intentionally started with zero logical resources (a common practice to
|
||||
avoid OOMs), those tasks become unschedulable. Detect this upfront and
|
||||
raise a clear error with remediation steps.
|
||||
"""
|
||||
|
||||
# Ray defaults to reserving 1 CPU per task when num_cpus isn't provided.
|
||||
num_cpus = ray_remote_args.get("num_cpus", default_num_cpus)
|
||||
num_gpus = ray_remote_args.get("num_gpus", default_num_gpus)
|
||||
memory = ray_remote_args.get("memory", default_memory)
|
||||
|
||||
# Resource keys follow the Resources map of ray.nodes() (e.g., CPU, GPU, memory).
|
||||
required_resources: Dict[str, float] = {}
|
||||
required_resources["CPU"] = float(num_cpus)
|
||||
required_resources["GPU"] = float(num_gpus)
|
||||
required_resources["memory"] = float(memory)
|
||||
|
||||
# Include any additional custom resources requested.
|
||||
custom_resources = ray_remote_args.get("resources", {})
|
||||
for name, amount in custom_resources.items():
|
||||
if amount is None:
|
||||
continue
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(f"Invalid resource amount for '{name}': {amount}") from err
|
||||
required_resources[name] = amount
|
||||
|
||||
head_node = next(
|
||||
(
|
||||
node
|
||||
for node in ray.nodes()
|
||||
if node.get("Alive")
|
||||
and "node:__internal_head__" in node.get("Resources", {})
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not head_node:
|
||||
# The head node metadata is unavailable (e.g., during shutdown). Fall back
|
||||
# to the default behavior and let Ray surface its own error.
|
||||
return
|
||||
|
||||
# Build a map of required vs available resources on the head node.
|
||||
head_resources: Dict[str, float] = head_node.get("Resources", {})
|
||||
# Map: resource name -> (required, available).
|
||||
insufficient: Dict[str, Tuple[float, float]] = {}
|
||||
for name, req in required_resources.items():
|
||||
avail = head_resources.get(name, 0.0)
|
||||
if avail < req:
|
||||
insufficient[name] = (req, avail)
|
||||
|
||||
# If nothing is below the required amount, we are good to proceed.
|
||||
if not insufficient:
|
||||
return
|
||||
|
||||
details = "; ".join(
|
||||
f"{name} required {req:g} but head has {avail:g}"
|
||||
for name, (req, avail) in insufficient.items()
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"{op_description} must run on the head node (e.g., for local:// paths), "
|
||||
f"but the head node doesn't have enough resources: {details}. "
|
||||
"Add resources to the head node, switch to a shared filesystem instead "
|
||||
"of local://, or set the resource requests on this operation to 0 "
|
||||
"(for example, num_cpus=0) so it can run without head resources."
|
||||
)
|
||||
Reference in New Issue
Block a user