ray-project--ray
2140 行
80 KiB
Python
2140 行
80 KiB
Python
import hashlib
|
|
import logging
|
|
import math
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
Iterable,
|
|
Iterator,
|
|
List,
|
|
Literal,
|
|
Optional,
|
|
Tuple,
|
|
Union,
|
|
)
|
|
|
|
import numpy as np
|
|
from packaging.version import parse as parse_version
|
|
|
|
import ray
|
|
from ray._common.utils import env_bool, env_integer
|
|
from ray.data._internal.arrow_block import (
|
|
_BATCH_SIZE_PRESERVING_STUB_COL_NAME,
|
|
ArrowBlockAccessor,
|
|
)
|
|
from ray.data._internal.execution.util import merge_label_selector
|
|
from ray.data._internal.object_extensions.arrow import ArrowPythonObjectType
|
|
from ray.data._internal.planner.plan_expression.expression_visitors import (
|
|
get_column_references,
|
|
)
|
|
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 (
|
|
MiB,
|
|
RetryingPyFileSystem,
|
|
_check_pyarrow_version,
|
|
_is_local_scheme,
|
|
iterate_with_retry,
|
|
)
|
|
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
|
|
from ray.data.block import Block, BlockAccessor, BlockMetadata
|
|
from ray.data.context import DataContext
|
|
from ray.data.datasource import Datasource
|
|
from ray.data.datasource.datasource import ReadTask
|
|
from ray.data.datasource.file_based_datasource import (
|
|
_shuffle_file_metadata,
|
|
_validate_shuffle_arg,
|
|
)
|
|
from ray.data.datasource.file_meta_provider import (
|
|
FileMetadataProvider,
|
|
_handle_read_os_error,
|
|
_list_files,
|
|
)
|
|
from ray.data.datasource.partitioning import (
|
|
PartitionDataType,
|
|
Partitioning,
|
|
PathPartitionFilter,
|
|
PathPartitionParser,
|
|
)
|
|
from ray.data.datasource.path_util import (
|
|
_resolve_paths_and_filesystem,
|
|
)
|
|
from ray.data.expressions import BinaryExpr, Expr, Operation
|
|
from ray.util.debug import log_once
|
|
|
|
if TYPE_CHECKING:
|
|
import pyarrow
|
|
from pyarrow.dataset import ParquetFileFragment
|
|
|
|
from ray.data.datasource.file_based_datasource import FileShuffleConfig
|
|
# Type aliases for tensor column schema
|
|
ColumnName = str
|
|
# Shape of the tensor
|
|
Shape = Tuple[int, ...]
|
|
TensorColumnSchema = Dict[ColumnName, Tuple[np.dtype, Shape]]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
MIN_PYARROW_TO_BATCHES_READAHEAD = parse_version("10.0.0")
|
|
_MIN_PYARROW_VERSION_FS_FACTORY_INSPECT_PROMOTE_OPTIONS = parse_version("22.0.0")
|
|
|
|
|
|
# The `num_cpus` for each metadata prefetching task.
|
|
# Default to 0.5 instead of 1 because it is cheaper than normal read task.
|
|
NUM_CPUS_FOR_META_FETCH_TASK = 0.5
|
|
|
|
# The number of rows to read per batch. This is sized to generate 10MiB batches
|
|
# for rows about 1KiB in size.
|
|
DEFAULT_PARQUET_READER_ROW_BATCH_SIZE = 10_000
|
|
FILE_READING_RETRY = 8
|
|
|
|
# `ParquetFileFragment.to_batches` passes `batch_size` through PyArrow's Cython
|
|
# layer as a C ``int`` (32-bit). Larger values raise
|
|
# `OverflowError: value too large to convert to int` (e.g. when estimated batch
|
|
# size from bytes-per-row blows up for sparse or highly compressed batches).
|
|
_MAX_PYARROW_TO_BATCHES_BATCH_SIZE = 2**31 - 1
|
|
|
|
# The default size multiplier for reading Parquet data source in Arrow.
|
|
# Parquet data format is encoded with various encoding techniques (such as
|
|
# dictionary, RLE, delta), so Arrow in-memory representation uses much more memory
|
|
# compared to Parquet encoded representation. Parquet file statistics only record
|
|
# encoded (i.e. uncompressed) data size information.
|
|
#
|
|
# To estimate real-time in-memory data size, Datasets will try to estimate the
|
|
# correct inflation ratio from Parquet to Arrow, using this constant as the default
|
|
# value for safety. See https://github.com/ray-project/ray/pull/26516 for more context.
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT = 5
|
|
|
|
# The lower bound size to estimate Parquet encoding ratio.
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_LOWER_BOUND = 1
|
|
|
|
# The percentage of files (1% by default) to be sampled from the dataset to estimate
|
|
# Parquet encoding ratio.
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_SAMPLING_RATIO = 0.01
|
|
|
|
# The minimal and maximal number of file samples to take from the dataset to estimate
|
|
# Parquet encoding ratio.
|
|
# This is to restrict `PARQUET_ENCODING_RATIO_ESTIMATE_SAMPLING_RATIO` within the
|
|
# proper boundary.
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_MIN_NUM_SAMPLES = 2
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_MAX_NUM_SAMPLES = 10
|
|
|
|
# The number of rows to read from each file for sampling. Try to keep it low to avoid
|
|
# reading too much data into memory.
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_NUM_ROWS = 1024
|
|
|
|
# Arrow's nested type chunking limit
|
|
# See: https://github.com/apache/arrow/issues/21526 (ARROW-5030)
|
|
_ARROW_CHUNK_LIMIT = 2 * 1024**3 # 2GB
|
|
|
|
_MIN_PYARROW_VERSION_FOR_SCANNER_DEFAULTS = parse_version("12.0.1")
|
|
|
|
# Opt-in env var to allow reading Parquet files that contain
|
|
# ray.data.arrow_pickled_object columns. Disabled by default because
|
|
# pickle.load on attacker-controlled data enables arbitrary code execution.
|
|
AUTOLOAD_PICKLE_OBJECT_SCALAR_ENV_VAR = "RAY_DATA_AUTOLOAD_PICKLE_OBJECT_SCALAR"
|
|
|
|
|
|
class _ParquetFragment:
|
|
"""This wrapper class is created to avoid utilizing `ParquetFileFragment` original
|
|
serialization protocol that actually does network RPCs during serialization
|
|
(to fetch actual parquet metadata)"""
|
|
|
|
def __init__(self, f: "ParquetFileFragment", file_size: int):
|
|
self._fragment = f
|
|
self._file_size = file_size
|
|
|
|
@property
|
|
def file_size(self) -> int:
|
|
return self._file_size
|
|
|
|
@property
|
|
def original(self) -> "ParquetFileFragment":
|
|
return self._fragment
|
|
|
|
def __reduce__(self):
|
|
return _ParquetFragment.make_fragment, (
|
|
self._fragment.format,
|
|
self._fragment.path,
|
|
self._fragment.filesystem,
|
|
self._fragment.partition_expression,
|
|
self._file_size,
|
|
)
|
|
|
|
@staticmethod
|
|
def make_fragment(format, path, filesystem, partition_expression, file_size):
|
|
fragment = format.make_fragment(path, filesystem, partition_expression)
|
|
return _ParquetFragment(fragment, file_size)
|
|
|
|
|
|
def check_for_legacy_tensor_type(schema):
|
|
"""Check for the legacy tensor extension type and raise an error if found.
|
|
|
|
Ray Data uses an extension type to represent tensors in Arrow tables. Previously,
|
|
the extension type extended `PyExtensionType`. However, this base type can expose
|
|
users to arbitrary code execution. To prevent this, we don't load the type by
|
|
default.
|
|
"""
|
|
import pyarrow as pa
|
|
|
|
for name, type in zip(schema.names, schema.types):
|
|
if isinstance(type, pa.UnknownExtensionType) and isinstance(
|
|
type, pa.PyExtensionType
|
|
):
|
|
raise RuntimeError(
|
|
f"Ray Data couldn't infer the type of column '{name}' (got "
|
|
f"`UnknownExtensionType` with pickled class ref "
|
|
f"'{type.__arrow_ext_serialize__()}'). This might mean you're trying "
|
|
f"to read data written with an older version of Ray. Reading data "
|
|
f"written with older versions of Ray might expose you to arbitrary code "
|
|
f"execution. To try reading the data anyway, "
|
|
f"preset `RAY_DATA_AUTOLOAD_PYEXTENSIONTYPE=1` on *all* nodes."
|
|
"To learn more, see https://github.com/ray-project/ray/issues/41314."
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _SplitPredicateResult:
|
|
"""Result of splitting a predicate by column type.
|
|
|
|
Attributes:
|
|
data_predicate: Conjuncts referencing only data columns (for PyArrow
|
|
pushdown), or None if none could be extracted.
|
|
partition_predicate: Conjuncts referencing only partition columns
|
|
(for partition pruning), or None if none could be extracted.
|
|
residual_predicate: Conjuncts that mix partition and data columns
|
|
and can't be split safely (e.g. an ``OR`` straddling both
|
|
kinds). The caller must keep these as a ``Filter`` above the
|
|
read; dropping them would over-include rows.
|
|
"""
|
|
|
|
data_predicate: Optional[Expr]
|
|
partition_predicate: Optional[Expr]
|
|
residual_predicate: Optional[Expr]
|
|
|
|
|
|
def _split_predicate_by_columns(
|
|
predicate: Expr,
|
|
partition_columns: set,
|
|
) -> _SplitPredicateResult:
|
|
"""Split a predicate into data, partition, and residual parts.
|
|
|
|
This function walks the top-level ``AND`` chain and classifies each
|
|
conjunct by the columns it references:
|
|
|
|
- References only data columns (or none) → data bucket; pyarrow can
|
|
evaluate it at scan time.
|
|
- References only partition columns → partition bucket; the partition
|
|
parser can evaluate it from file paths.
|
|
- References both kinds (i.e. a non-``AND`` whose column set spans
|
|
both) → residual bucket; semantics-preserving splitting is
|
|
impossible (e.g. ``data > 5 OR partition == "US"``), so the caller
|
|
must keep these as a ``Filter`` above the read.
|
|
|
|
Args:
|
|
predicate: The predicate expression to analyze.
|
|
partition_columns: Set of partition column names.
|
|
|
|
Returns:
|
|
:class:`_SplitPredicateResult` with the three buckets. Combining
|
|
``data_predicate``, ``partition_predicate``, and
|
|
``residual_predicate`` with ``AND`` reproduces the original
|
|
predicate exactly.
|
|
|
|
Examples:
|
|
>>> from ray.data.expressions import col
|
|
>>> # Pure data predicate:
|
|
>>> result = _split_predicate_by_columns(col("data1") > 5, {"partition_col"})
|
|
>>> result.data_predicate is not None
|
|
True
|
|
>>> result.partition_predicate is None and result.residual_predicate is None
|
|
True
|
|
|
|
>>> # Pure partition predicate:
|
|
>>> result = _split_predicate_by_columns(col("partition_col") == "US", {"partition_col"})
|
|
>>> result.partition_predicate is not None
|
|
True
|
|
>>> result.data_predicate is None and result.residual_predicate is None
|
|
True
|
|
|
|
>>> # Mixed AND - can split into data and partition parts:
|
|
>>> result = _split_predicate_by_columns(
|
|
... (col("data1") > 5) & (col("partition_col") == "US"),
|
|
... {"partition_col"}
|
|
... )
|
|
>>> result.data_predicate is not None and result.partition_predicate is not None
|
|
True
|
|
>>> result.residual_predicate is None
|
|
True
|
|
|
|
>>> # Mixed OR - kept as residual; caller wraps it in a Filter above:
|
|
>>> result = _split_predicate_by_columns(
|
|
... (col("data1") > 5) | (col("partition_col") == "US"),
|
|
... {"partition_col"}
|
|
... )
|
|
>>> result.data_predicate is None and result.partition_predicate is None
|
|
True
|
|
>>> result.residual_predicate is not None
|
|
True
|
|
"""
|
|
referenced_cols = set(get_column_references(predicate))
|
|
data_cols = referenced_cols - partition_columns
|
|
partition_cols_in_predicate = referenced_cols & partition_columns
|
|
|
|
if not partition_cols_in_predicate:
|
|
# Pure data predicate (or no column refs).
|
|
return _SplitPredicateResult(
|
|
data_predicate=predicate,
|
|
partition_predicate=None,
|
|
residual_predicate=None,
|
|
)
|
|
|
|
if not data_cols:
|
|
# Pure partition predicate.
|
|
return _SplitPredicateResult(
|
|
data_predicate=None,
|
|
partition_predicate=predicate,
|
|
residual_predicate=None,
|
|
)
|
|
|
|
# Mixed predicate - keep splitting if it's an AND chain.
|
|
if isinstance(predicate, BinaryExpr) and predicate.op == Operation.AND:
|
|
left_result = _split_predicate_by_columns(predicate.left, partition_columns)
|
|
right_result = _split_predicate_by_columns(predicate.right, partition_columns)
|
|
|
|
def combine_predicates(
|
|
left: Optional[Expr], right: Optional[Expr]
|
|
) -> Optional[Expr]:
|
|
if left and right:
|
|
return left & right
|
|
return left or right
|
|
|
|
return _SplitPredicateResult(
|
|
data_predicate=combine_predicates(
|
|
left_result.data_predicate, right_result.data_predicate
|
|
),
|
|
partition_predicate=combine_predicates(
|
|
left_result.partition_predicate, right_result.partition_predicate
|
|
),
|
|
residual_predicate=combine_predicates(
|
|
left_result.residual_predicate, right_result.residual_predicate
|
|
),
|
|
)
|
|
|
|
# ``OR``/``NOT``/etc. straddling both column kinds — not safely
|
|
# splittable. Surface as residual so the caller doesn't silently drop
|
|
# it (the prior version returned ``(None, None)`` here, which let the
|
|
# surrounding ``AND`` chain push partial conjuncts and over-include
|
|
# rows that should have been filtered by this one).
|
|
return _SplitPredicateResult(
|
|
data_predicate=None,
|
|
partition_predicate=None,
|
|
residual_predicate=predicate,
|
|
)
|
|
|
|
|
|
class ParquetDatasource(Datasource):
|
|
"""Parquet datasource, for reading and writing Parquet files.
|
|
|
|
This implementation uses PyArrow's `ParquetDataset` abstraction for dataset reads,
|
|
and thus offers automatic Arrow dataset schema inference and row count collection at
|
|
the cost of some potential performance and/or compatibility penalties.
|
|
"""
|
|
|
|
# Number of fragments that Pyarrow should look at to determine schema.
|
|
#
|
|
# NOTE: Default is 1 for backwards compatibility, which means only 1 fragment
|
|
# (in no particular order) will be inspected. To inspect all fragments
|
|
# set this to None.
|
|
_DEFAULT_NUM_FRAGMENTS_TO_INSPECT_FOR_SCHEMA: Optional[int] = 1
|
|
|
|
_FILE_EXTENSIONS = ["parquet"]
|
|
|
|
# Denotes number of batches to read ahead in a fragment. Default is 16
|
|
# as per https://arrow.apache.org/docs/python/generated/pyarrow.dataset.Dataset.html#pyarrow.dataset.Dataset.to_batches
|
|
# Chose 8 based on past experiments.
|
|
_DEFAULT_BATCH_READAHEAD = env_integer("RAY_DATA_PARQUET_READER_BATCH_READAHEAD", 8)
|
|
# NOTE: We're essentially stubbing out this value as currently
|
|
# ParquetDatasource reads individual fragments independently
|
|
# Default is 4. This refers to the number of files to readahead, which was chosen based on past experiments.
|
|
_DEFAULT_FRAGMENT_READAHEAD = env_integer(
|
|
"RAY_DATA_PARQUET_READER_FRAGMENT_READAHEAD", 1
|
|
)
|
|
|
|
# Default is False as per https://arrow.apache.org/docs/python/generated/pyarrow.dataset.ParquetFragmentScanOptions.html
|
|
# This parameter when set to True, reads files through buffered input streams rather than loading entire row groups at once.
|
|
_DEFAULT_FRAGMENT_USE_BUFFERED_STREAM = env_bool(
|
|
"RAY_DATA_PARQUET_READER_FRAGMENT_USE_BUFFERED_STREAM", True
|
|
)
|
|
# Default is 8 KiB as per https://arrow.apache.org/docs/python/generated/pyarrow.dataset.ParquetFragmentScanOptions.html
|
|
# Based on experiments, this was a good default.
|
|
_DEFAULT_FRAGMENT_SCAN_BUFFER_SIZE = env_integer(
|
|
"RAY_DATA_PARQUET_READER_FRAGMENT_SCAN_BUFFER_SIZE", 8 * MiB
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
paths: Union[str, List[str]],
|
|
*,
|
|
columns: Optional[List[str]] = None,
|
|
dataset_kwargs: Optional[Dict[str, Any]] = None,
|
|
to_batch_kwargs: Optional[Dict[str, Any]] = None,
|
|
_block_udf: Optional[Callable[[Block], Block]] = None,
|
|
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
|
schema: Optional[Union["pyarrow.lib.Schema"]] = None,
|
|
meta_provider: Optional[FileMetadataProvider] = None,
|
|
partition_filter: Optional[PathPartitionFilter] = None,
|
|
partitioning: Optional[Partitioning] = Partitioning("hive"),
|
|
shuffle: "FileShuffleConfig" | Literal["files"] | None = None,
|
|
include_paths: bool = False,
|
|
include_row_hash: bool = False,
|
|
file_extensions: Optional[List[str]] = None,
|
|
):
|
|
super().__init__()
|
|
_check_pyarrow_version()
|
|
|
|
supports_distributed_reads = not _is_local_scheme(paths)
|
|
if not 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."
|
|
)
|
|
|
|
local_scheduling = None
|
|
if not supports_distributed_reads:
|
|
local_scheduling = {
|
|
ray._raylet.RAY_NODE_ID_KEY: ray.get_runtime_context().get_node_id()
|
|
}
|
|
|
|
# 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.
|
|
source_paths_ref = ray.put(paths)
|
|
|
|
paths, resolved_filesystem = _resolve_paths_and_filesystem(paths, filesystem)
|
|
filesystem = RetryingPyFileSystem.wrap(
|
|
resolved_filesystem,
|
|
retryable_errors=DataContext.get_current().retried_io_errors,
|
|
)
|
|
|
|
listed_files = _list_files(
|
|
paths,
|
|
filesystem,
|
|
partition_filter=partition_filter,
|
|
file_extensions=file_extensions,
|
|
)
|
|
|
|
if listed_files:
|
|
paths, file_sizes = zip(*listed_files)
|
|
else:
|
|
paths, file_sizes = [], []
|
|
|
|
if dataset_kwargs is not None:
|
|
logger.warning(
|
|
"Please note that `ParquetDatasource.__init__`s `dataset_kwargs` "
|
|
"is a deprecated parameter and will be removed in the future."
|
|
)
|
|
else:
|
|
dataset_kwargs = {}
|
|
|
|
if "partitioning" in dataset_kwargs:
|
|
raise ValueError(
|
|
"The 'partitioning' parameter isn't supported in 'dataset_kwargs'. "
|
|
"Use the top-level 'partitioning' parameter instead."
|
|
)
|
|
|
|
# This datasource manually adds partition data at the Ray Data-level. To avoid
|
|
# duplicating the partition data, we disable PyArrow's partitioning.
|
|
dataset_kwargs["partitioning"] = None
|
|
|
|
# NOTE: ParquetDataset only accepts list of paths, hence we need to convert
|
|
# it to a list
|
|
pq_ds = get_parquet_dataset(
|
|
list(paths),
|
|
filesystem=filesystem,
|
|
schema=schema,
|
|
inspect_num_fragments=self._DEFAULT_NUM_FRAGMENTS_TO_INSPECT_FOR_SCHEMA,
|
|
dataset_kwargs=dataset_kwargs,
|
|
)
|
|
|
|
fragments = list(pq_ds.fragments)
|
|
|
|
# Users can pass both data columns and partition columns in the 'columns'
|
|
# argument. To prevent PyArrow from complaining about missing columns, we
|
|
# separate the partition columns from the data columns. When we read the
|
|
# fragments, we pass the data columns to PyArrow and add the partition
|
|
# columns manually.
|
|
data_columns, partition_columns = None, None
|
|
if columns is not None:
|
|
if fragments:
|
|
data_columns, partition_columns = _infer_data_and_partition_columns(
|
|
columns, fragments[0], partitioning
|
|
)
|
|
else:
|
|
# Empty dataset - can't infer columns without fragments
|
|
data_columns, partition_columns = [], []
|
|
|
|
if to_batch_kwargs is None:
|
|
to_batch_kwargs = {}
|
|
|
|
# Store as projection_map (identity mapping if columns specified, None otherwise)
|
|
# Note: Empty list [] means no columns, None means all columns
|
|
# Include partition columns in projection_map if they were requested, so that
|
|
# projection pushdown can properly track them
|
|
if data_columns is None and partition_columns is None:
|
|
projection_map = None
|
|
else:
|
|
projection_map = {}
|
|
if data_columns is not None:
|
|
projection_map.update({col: col for col in data_columns})
|
|
if partition_columns is not None:
|
|
projection_map.update({col: col for col in partition_columns})
|
|
|
|
# Eagerly compute the actual partition columns for _partition_columns.
|
|
# This ensures _partition_columns is always a list (never None).
|
|
actual_partition_columns = partition_columns
|
|
if partition_columns is None and partitioning is not None and fragments:
|
|
parse = PathPartitionParser(partitioning)
|
|
parsed_partitions = parse(fragments[0].path)
|
|
if parsed_partitions:
|
|
actual_partition_columns = list(parsed_partitions.keys())
|
|
|
|
# Store selected partition columns. Always a list (never None) representing
|
|
# the actual partition columns to include.
|
|
actual_partition_columns = (
|
|
actual_partition_columns if actual_partition_columns is not None else []
|
|
)
|
|
# Track whether partition columns were explicitly part of the user's column selection
|
|
partition_columns_selected = (
|
|
partition_columns is not None and len(actual_partition_columns) > 0
|
|
)
|
|
|
|
self._init_state(
|
|
supports_distributed_reads=supports_distributed_reads,
|
|
local_scheduling=local_scheduling,
|
|
source_paths_ref=source_paths_ref,
|
|
filesystem=resolved_filesystem,
|
|
fragments=fragments,
|
|
file_sizes=list(file_sizes),
|
|
file_schema=pq_ds.schema,
|
|
read_schema=schema,
|
|
partition_columns=actual_partition_columns,
|
|
partition_columns_selected=partition_columns_selected,
|
|
partition_schema=_get_partition_columns_schema(
|
|
partitioning, [p.path for p in fragments]
|
|
),
|
|
partitioning=partitioning,
|
|
projection_map=projection_map,
|
|
to_batch_kwargs=to_batch_kwargs,
|
|
_block_udf=_block_udf,
|
|
shuffle=shuffle,
|
|
include_paths=include_paths,
|
|
include_row_hash=include_row_hash,
|
|
)
|
|
|
|
def _init_state(
|
|
self,
|
|
*,
|
|
supports_distributed_reads: bool,
|
|
local_scheduling: Optional[Dict[str, str]],
|
|
source_paths_ref: "ray.ObjectRef",
|
|
filesystem: "pyarrow.fs.FileSystem",
|
|
fragments: List["ParquetFileFragment"],
|
|
file_sizes: List[int],
|
|
file_schema: "pyarrow.Schema",
|
|
read_schema: Optional["pyarrow.lib.Schema"],
|
|
partition_columns: List[str],
|
|
partition_columns_selected: bool,
|
|
partition_schema: "pyarrow.Schema",
|
|
partitioning: Optional[Partitioning],
|
|
projection_map: Optional[Dict[str, str]],
|
|
to_batch_kwargs: Optional[Dict[str, Any]],
|
|
_block_udf: Optional[Callable[[Block], Block]],
|
|
shuffle: Union["FileShuffleConfig", Literal["files"], None],
|
|
include_paths: bool,
|
|
include_row_hash: bool = False,
|
|
):
|
|
"""Shared initialization for all instance state and sampling estimates.
|
|
|
|
Called by both ``__init__`` (after path resolution and file listing)
|
|
and ``from_state`` (used by alternate constructors like
|
|
``from_pyarrow_dataset``).
|
|
"""
|
|
self._allow_pickle_object_columns = env_bool(
|
|
AUTOLOAD_PICKLE_OBJECT_SCALAR_ENV_VAR, False
|
|
)
|
|
self._supports_distributed_reads = supports_distributed_reads
|
|
self._local_scheduling = local_scheduling
|
|
self._source_paths_ref = source_paths_ref
|
|
self._filesystem = filesystem
|
|
|
|
# NOTE: Store the custom serialized `ParquetFileFragment` to avoid unexpected
|
|
# network calls when `_ParquetDatasourceReader` is serialized. See
|
|
# `_SerializedFragment()` implementation for more details.
|
|
self._pq_fragments = [
|
|
_ParquetFragment(fragment, file_size)
|
|
for fragment, file_size in zip(fragments, file_sizes)
|
|
]
|
|
self._pq_paths = [f.path for f in fragments]
|
|
self._block_udf = _block_udf
|
|
self._projection_map = projection_map
|
|
self._partition_columns = partition_columns
|
|
self._partition_columns_selected = partition_columns_selected
|
|
self._read_schema = read_schema
|
|
self._file_schema = file_schema
|
|
self._partition_schema = partition_schema
|
|
self._file_metadata_shuffler = None
|
|
self._include_paths = include_paths
|
|
self._include_row_hash = include_row_hash
|
|
self._partitioning = partitioning
|
|
_validate_shuffle_arg(shuffle)
|
|
self._shuffle = shuffle
|
|
|
|
# Sample small number of parquet files to estimate
|
|
# - Encoding ratio: ratio of file size on disk to approximate expected
|
|
# size of the corresponding block in memory
|
|
# - Default batch-size: number of rows to be read from a file at a time,
|
|
# used to limit amount of memory pressure
|
|
sampled_fragments = _sample_fragments(
|
|
self._pq_fragments,
|
|
)
|
|
|
|
sampled_file_infos = _fetch_file_infos(
|
|
sampled_fragments,
|
|
columns=self._get_data_columns(),
|
|
schema=read_schema,
|
|
local_scheduling=self._local_scheduling,
|
|
)
|
|
|
|
self._encoding_ratio = _estimate_files_encoding_ratio(
|
|
sampled_fragments,
|
|
sampled_file_infos,
|
|
)
|
|
|
|
estimated_batch_size = _estimate_reader_batch_size(
|
|
sampled_file_infos, DataContext.get_current().target_max_block_size
|
|
)
|
|
|
|
self._scanner_kwargs = self._get_scanner_kwargs(
|
|
to_batch_kwargs, estimated_batch_size
|
|
)
|
|
|
|
@classmethod
|
|
def from_state(cls, **kwargs) -> "ParquetDatasource":
|
|
"""Create a fully-initialized instance from pre-computed state.
|
|
|
|
This is the preferred entry point for alternate constructors (e.g.
|
|
``from_pyarrow_dataset``) that bypass the normal ``__init__`` path.
|
|
All keyword arguments are forwarded to ``_init_state``.
|
|
"""
|
|
instance = cls.__new__(cls)
|
|
Datasource.__init__(instance)
|
|
_check_pyarrow_version()
|
|
instance._init_state(**kwargs)
|
|
return instance
|
|
|
|
@classmethod
|
|
def from_pyarrow_dataset(
|
|
cls,
|
|
pa_dataset: "pyarrow.dataset.Dataset",
|
|
*,
|
|
columns: Optional[List[str]] = None,
|
|
to_batch_kwargs: Optional[Dict[str, Any]] = None,
|
|
_block_udf: Optional[Callable[[Block], Block]] = None,
|
|
schema: Optional["pyarrow.lib.Schema"] = None,
|
|
shuffle: "FileShuffleConfig" | Literal["files"] | None = None,
|
|
include_paths: bool = False,
|
|
include_row_hash: bool = False,
|
|
) -> "ParquetDatasource":
|
|
"""Create a ParquetDatasource from a pre-built PyArrow dataset.
|
|
|
|
This bypasses path resolution, file listing, and dataset construction —
|
|
useful when another system (e.g. delta-rs) has already built a PyArrow
|
|
dataset with the correct filesystem, schema, and fragment metadata.
|
|
"""
|
|
import pyarrow as pa
|
|
from pyarrow.fs import LocalFileSystem
|
|
|
|
fragments = list(pa_dataset.get_fragments())
|
|
filesystem = pa_dataset.filesystem
|
|
pq_paths = [f.path for f in fragments]
|
|
|
|
# Determine locality from the filesystem type rather than path parsing.
|
|
# PyArrow fragment paths are filesystem-relative (e.g. "bucket/key/file.parquet"
|
|
# instead of "s3://bucket/key/..."), so _is_local_scheme cannot distinguish
|
|
# local from remote paths here.
|
|
is_local = isinstance(filesystem, LocalFileSystem)
|
|
supports_distributed_reads = not is_local
|
|
local_scheduling = None
|
|
|
|
if pq_paths:
|
|
if is_local 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."
|
|
)
|
|
|
|
if is_local:
|
|
local_scheduling = {
|
|
ray._raylet.RAY_NODE_ID_KEY: ray.get_runtime_context().get_node_id()
|
|
}
|
|
|
|
infos = filesystem.get_file_info(pq_paths)
|
|
file_sizes = [info.size if info.size is not None else 0 for info in infos]
|
|
else:
|
|
file_sizes = []
|
|
|
|
# Partition columns are intentionally left empty. For PyArrow datasets
|
|
# produced by systems like delta-rs, partition columns are materialized
|
|
# by PyArrow itself (via fragment.to_batches(schema=...)) rather than
|
|
# parsed from file paths. Setting these to empty prevents Ray Data's
|
|
# path-based partition parsing from duplicating columns that PyArrow
|
|
# already provides.
|
|
return cls.from_state(
|
|
supports_distributed_reads=supports_distributed_reads,
|
|
local_scheduling=local_scheduling,
|
|
source_paths_ref=ray.put(pq_paths),
|
|
filesystem=filesystem,
|
|
fragments=fragments,
|
|
file_sizes=file_sizes,
|
|
file_schema=pa_dataset.schema,
|
|
read_schema=schema if schema is not None else pa_dataset.schema,
|
|
partition_columns=[],
|
|
partition_columns_selected=False,
|
|
partition_schema=pa.schema([]),
|
|
partitioning=None,
|
|
projection_map={col: col for col in columns}
|
|
if columns is not None
|
|
else None,
|
|
to_batch_kwargs=to_batch_kwargs,
|
|
_block_udf=_block_udf,
|
|
shuffle=shuffle,
|
|
include_paths=include_paths,
|
|
include_row_hash=include_row_hash,
|
|
)
|
|
|
|
@property
|
|
def _source_paths(self) -> List[str]:
|
|
return ray.get(self._source_paths_ref)
|
|
|
|
def estimate_inmemory_data_size(self) -> int:
|
|
# In case of empty projections no data will be read
|
|
if self._projection_map == {}:
|
|
return 0
|
|
|
|
return self._estimate_in_mem_size(self._pq_fragments)
|
|
|
|
def _get_scanner_kwargs(
|
|
self,
|
|
to_batch_kwargs: Optional[Dict[str, Any]],
|
|
batch_size: Optional[int],
|
|
) -> dict[str, Any]:
|
|
import pyarrow.dataset as pds
|
|
|
|
scanner_kwargs: Dict[str, Any] = (to_batch_kwargs or {}).copy()
|
|
|
|
# NOTE: We inject ``batch_size`` via kwargs, since Scanner doesn't accept
|
|
# nulls. Use setdefault so a user-provided ``batch_size`` in
|
|
# ``to_batch_kwargs`` wins.
|
|
if batch_size is not None:
|
|
scanner_kwargs.setdefault("batch_size", batch_size)
|
|
|
|
# Override default `batch_readahead` value to reduce amount of data prefetched
|
|
# by Pyarrow's Parquet reader
|
|
pyarrow_version = get_pyarrow_version()
|
|
if (
|
|
pyarrow_version is not None
|
|
and pyarrow_version >= _MIN_PYARROW_VERSION_FOR_SCANNER_DEFAULTS
|
|
):
|
|
scanner_kwargs.setdefault("batch_readahead", self._DEFAULT_BATCH_READAHEAD)
|
|
scanner_kwargs.setdefault(
|
|
"fragment_readahead", self._DEFAULT_FRAGMENT_READAHEAD
|
|
)
|
|
|
|
# Refer https://arrow.apache.org/docs/python/generated/pyarrow.dataset.ParquetFragmentScanOptions.html
|
|
# Read files through buffered input streams rather than loading
|
|
# entire row groups at once.
|
|
if self._DEFAULT_FRAGMENT_USE_BUFFERED_STREAM:
|
|
scanner_kwargs.setdefault(
|
|
"fragment_scan_options",
|
|
pds.ParquetFragmentScanOptions(
|
|
use_buffered_stream=True,
|
|
buffer_size=self._DEFAULT_FRAGMENT_SCAN_BUFFER_SIZE,
|
|
),
|
|
)
|
|
|
|
return scanner_kwargs
|
|
|
|
def get_read_tasks(
|
|
self,
|
|
parallelism: int,
|
|
per_task_row_limit: Optional[int] = None,
|
|
data_context: Optional["DataContext"] = None,
|
|
) -> List[ReadTask]:
|
|
# NOTE: We override the base class FileBasedDatasource.get_read_tasks()
|
|
# method in order to leverage pyarrow's ParquetDataset abstraction,
|
|
# which simplifies partitioning logic. We still use
|
|
# FileBasedDatasource's write side, however.
|
|
execution_idx = data_context._execution_idx if data_context is not None else 0
|
|
pq_fragments, pq_paths = _shuffle_file_metadata(
|
|
self._pq_fragments, self._pq_paths, self._shuffle, execution_idx
|
|
)
|
|
|
|
# Derive expected target schema of the blocks being read
|
|
target_schema = self._derive_schema(
|
|
self._read_schema,
|
|
file_schema=self._file_schema,
|
|
partition_schema=self._partition_schema,
|
|
projected_columns=self.get_current_projection(),
|
|
_block_udf=self._block_udf,
|
|
include_paths=self._include_paths,
|
|
include_row_hash=self._include_row_hash,
|
|
)
|
|
|
|
read_tasks = []
|
|
filter_expr = (
|
|
self._predicate_expr.to_pyarrow()
|
|
if self._predicate_expr is not None
|
|
else None
|
|
)
|
|
filter_columns = None
|
|
if self._predicate_expr is not None:
|
|
filter_columns = get_column_references(self._predicate_expr)
|
|
|
|
for fragments, paths in zip(
|
|
np.array_split(pq_fragments, parallelism),
|
|
np.array_split(pq_paths, parallelism),
|
|
):
|
|
if len(fragments) <= 0:
|
|
continue
|
|
|
|
meta = BlockMetadata(
|
|
num_rows=None,
|
|
size_bytes=self._estimate_in_mem_size(fragments),
|
|
input_files=paths,
|
|
exec_stats=None,
|
|
)
|
|
|
|
(
|
|
block_udf,
|
|
to_batches_kwargs,
|
|
data_columns,
|
|
partition_columns,
|
|
read_schema,
|
|
include_paths,
|
|
include_row_hash,
|
|
partitioning,
|
|
) = (
|
|
self._block_udf,
|
|
self._scanner_kwargs,
|
|
self._get_data_columns(),
|
|
self._get_partition_columns(),
|
|
self._read_schema,
|
|
self._include_paths,
|
|
self._include_row_hash,
|
|
self._partitioning,
|
|
)
|
|
|
|
allow_pickle = self._allow_pickle_object_columns
|
|
read_tasks.append(
|
|
ReadTask(
|
|
lambda f=fragments: read_fragments(
|
|
block_udf,
|
|
to_batches_kwargs,
|
|
data_columns,
|
|
partition_columns,
|
|
read_schema,
|
|
f,
|
|
include_paths,
|
|
include_row_hash,
|
|
partitioning,
|
|
filter_expr,
|
|
filter_columns,
|
|
allow_pickle,
|
|
),
|
|
meta,
|
|
schema=target_schema,
|
|
per_task_row_limit=per_task_row_limit,
|
|
)
|
|
)
|
|
|
|
return read_tasks
|
|
|
|
def get_name(self):
|
|
"""Return a human-readable name for this datasource.
|
|
|
|
This will be used as the names of the read tasks.
|
|
"""
|
|
return "Parquet"
|
|
|
|
@property
|
|
def supports_distributed_reads(self) -> bool:
|
|
return self._supports_distributed_reads
|
|
|
|
def supports_projection_pushdown(self) -> bool:
|
|
return True
|
|
|
|
def supports_predicate_pushdown(self) -> bool:
|
|
return True
|
|
|
|
def get_current_projection(self) -> Optional[List[str]]:
|
|
"""Override to include partition columns in addition to data columns."""
|
|
# NOTE: In case there's no projection both file and partition columns
|
|
# will be none
|
|
data_columns = self._get_data_columns()
|
|
partition_columns = self._get_partition_columns()
|
|
if data_columns is None and partition_columns is None:
|
|
result = None
|
|
else:
|
|
result = (data_columns or []) + (partition_columns or [])
|
|
# If include_paths is True, make sure to include the path column in the projection
|
|
# NOTE: When result is None (no projection), the path column is already added
|
|
# via _derive_schema, so we only need to add it when there is a projection.
|
|
if self._include_paths and "path" not in result:
|
|
result = result + ["path"]
|
|
if self._include_row_hash and "row_hash" not in result:
|
|
result = result + ["row_hash"]
|
|
|
|
return result
|
|
|
|
def _get_partition_columns(self) -> Optional[List[str]]:
|
|
"""Extract partition columns from projection map.
|
|
|
|
This method extracts partition columns from _projection_map, which is the
|
|
source of truth after projection pushdown. Since partition columns are now
|
|
included in _projection_map during initialization when requested, we can
|
|
reliably extract them from the map.
|
|
|
|
Returns:
|
|
List of partition column names in the projection, None if there's
|
|
no projection (meaning include all partition columns), or [] if
|
|
partition columns aren't in the projection map (meaning include
|
|
no partition columns).
|
|
"""
|
|
if self._projection_map is None:
|
|
return None
|
|
|
|
if not self._partition_columns:
|
|
# If a projection is active but the dataset has no partition columns,
|
|
# then no partition columns should be included in the output.
|
|
# Returning [] ensures that no partition columns are added,
|
|
# `None` is interpreted as including all partition columns.
|
|
return []
|
|
|
|
# Extract partition columns that are in the projection map
|
|
partition_cols = [
|
|
col for col in self._projection_map.keys() if col in self._partition_columns
|
|
]
|
|
|
|
# If partition columns are found in projection map, return them
|
|
if partition_cols:
|
|
return partition_cols
|
|
|
|
# No partition columns in projection map.
|
|
# Since the projection map exists and is the source of truth after
|
|
# projection pushdown, return [] (no partition columns to include).
|
|
return []
|
|
|
|
def _get_data_columns(self) -> Optional[List[str]]:
|
|
"""Extract data columns from projection map, excluding partition columns.
|
|
|
|
Partition columns aren't in the physical file schema, so they must be
|
|
filtered out before passing to PyArrow's to_batches().
|
|
|
|
Similarly, the synthetic "path" column (when include_paths=True) isn't in
|
|
the physical file schema, so it must also be filtered out.
|
|
|
|
Returns:
|
|
List of data column names to read from files, or None if no projection.
|
|
Can return empty list if only partition columns are projected.
|
|
"""
|
|
if self._projection_map is None:
|
|
return None
|
|
|
|
# Get partition columns and filter them out from the projection
|
|
partition_cols = self._partition_columns
|
|
# Also filter out synthetic columns (path, row_hash) as they are
|
|
# added after reading from the file
|
|
cols_to_filter = set(partition_cols)
|
|
if self._include_paths:
|
|
cols_to_filter.add("path")
|
|
if self._include_row_hash:
|
|
cols_to_filter.add("row_hash")
|
|
data_cols = [
|
|
col for col in self._projection_map.keys() if col not in cols_to_filter
|
|
]
|
|
|
|
return data_cols
|
|
|
|
def apply_predicate(
|
|
self,
|
|
predicate_expr: Expr,
|
|
) -> "ParquetDatasource":
|
|
"""Apply a predicate with data pushdown and partition pruning.
|
|
|
|
This method optimizes predicates in three ways:
|
|
1. Data predicates → pushed to PyArrow (row-level filtering)
|
|
2. Partition predicates → used for partition pruning (file-level filtering)
|
|
3. Mixed predicates → both optimizations applied together
|
|
"""
|
|
partition_cols = set(self._partition_columns)
|
|
|
|
if not partition_cols:
|
|
# No partition columns - can push down everything normally
|
|
return super().apply_predicate(predicate_expr)
|
|
|
|
# Split predicate into data and partition parts
|
|
split_result = _split_predicate_by_columns(predicate_expr, partition_cols)
|
|
|
|
# If a mixed-column conjunct can't be safely split (e.g. ``data > 5
|
|
# OR partition == "US"``), the V1 ``Read.apply_predicate`` wrapper
|
|
# has no way to keep it as a ``Filter`` above the read — its return
|
|
# type is ``Read``, not ``LogicalOperator``. Pushing the splittable
|
|
# parts and silently dropping the residual would over-include rows.
|
|
# Return ``self`` so ``PredicatePushdown`` keeps the original
|
|
# ``Filter`` above intact.
|
|
if split_result.residual_predicate is not None:
|
|
return self
|
|
|
|
# Apply partition pruning if we have a partition predicate
|
|
if (
|
|
split_result.partition_predicate is not None
|
|
and self._partitioning is not None
|
|
):
|
|
parser = PathPartitionParser(self._partitioning)
|
|
pruned_fragments = []
|
|
pruned_paths = []
|
|
|
|
for fragment, path in zip(self._pq_fragments, self._pq_paths):
|
|
# Evaluate partition predicate - skip if it doesn't match
|
|
if parser.evaluate_predicate_on_partition(
|
|
path, split_result.partition_predicate
|
|
):
|
|
pruned_fragments.append(fragment)
|
|
pruned_paths.append(path)
|
|
|
|
# Apply partition pruning directly to self
|
|
self._pq_fragments = pruned_fragments
|
|
self._pq_paths = pruned_paths
|
|
|
|
# Push down data predicate to PyArrow if present
|
|
# Create a copy and push down the data predicate to PyArrow
|
|
import copy
|
|
|
|
datasource = copy.copy(self)
|
|
|
|
# Only call apply_predicate if there's a data predicate to push down
|
|
# If data_predicate is None (pure partition predicate), skip it to avoid
|
|
# creating invalid expressions like existing_expr & None
|
|
if split_result.data_predicate is not None:
|
|
return super(ParquetDatasource, datasource).apply_predicate(
|
|
split_result.data_predicate
|
|
)
|
|
|
|
return datasource
|
|
|
|
def _estimate_in_mem_size(self, fragments: List[_ParquetFragment]) -> int:
|
|
in_mem_size = sum([f.file_size for f in fragments]) * self._encoding_ratio
|
|
|
|
return round(in_mem_size)
|
|
|
|
@staticmethod
|
|
def _derive_schema(
|
|
read_schema: Optional["pyarrow.Schema"],
|
|
*,
|
|
file_schema: "pyarrow.Schema",
|
|
partition_schema: Optional["pyarrow.Schema"],
|
|
projected_columns: Optional[List[str]],
|
|
_block_udf,
|
|
include_paths: bool = False,
|
|
include_row_hash: bool = False,
|
|
) -> "pyarrow.Schema":
|
|
"""Derives target schema for read operation"""
|
|
|
|
import pyarrow as pa
|
|
|
|
# Use target read schema if provided
|
|
if read_schema is not None:
|
|
target_schema = read_schema
|
|
else:
|
|
file_schema_fields = list(file_schema)
|
|
partition_schema_fields = (
|
|
list(partition_schema) if partition_schema is not None else []
|
|
)
|
|
|
|
# Otherwise, fallback to file + partitioning schema by default
|
|
target_schema = pa.schema(
|
|
fields=(
|
|
file_schema_fields
|
|
+ [
|
|
f
|
|
for f in partition_schema_fields
|
|
# Ignore fields from partition schema overlapping with
|
|
# file's schema
|
|
if file_schema.get_field_index(f.name) == -1
|
|
]
|
|
),
|
|
metadata=file_schema.metadata,
|
|
)
|
|
|
|
# Add path column if include_paths is True and path column is not already present
|
|
if include_paths and target_schema.get_field_index("path") == -1:
|
|
target_schema = target_schema.append(pa.field("path", pa.string()))
|
|
|
|
if include_row_hash:
|
|
idx = target_schema.get_field_index("row_hash")
|
|
if idx == -1:
|
|
target_schema = target_schema.append(pa.field("row_hash", pa.uint64()))
|
|
elif target_schema.field(idx).type != pa.uint64():
|
|
target_schema = target_schema.set(
|
|
idx, pa.field("row_hash", pa.uint64())
|
|
)
|
|
|
|
# Project schema if necessary
|
|
if projected_columns is not None:
|
|
target_schema = pa.schema(
|
|
[target_schema.field(column) for column in projected_columns],
|
|
target_schema.metadata,
|
|
)
|
|
|
|
if _block_udf is not None:
|
|
# Try to infer dataset schema by passing dummy table through UDF.
|
|
try:
|
|
# An empty table with extensions will fail for pyarrow==9.0.0
|
|
dummy_table = target_schema.empty_table()
|
|
target_schema = _block_udf(dummy_table).schema.with_metadata(
|
|
target_schema.metadata
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to infer schema of dataset by passing dummy table "
|
|
"through UDF due to the following exception:",
|
|
exc_info=True,
|
|
)
|
|
|
|
check_for_legacy_tensor_type(target_schema)
|
|
|
|
return target_schema
|
|
|
|
|
|
def _check_for_pickle_object_columns(table: "pyarrow.Table") -> None:
|
|
pickle_cols = [
|
|
field.name
|
|
for field in table.schema
|
|
if isinstance(field.type, ArrowPythonObjectType)
|
|
]
|
|
if pickle_cols:
|
|
raise ValueError(
|
|
f"This Parquet file contains columns stored as "
|
|
f"'ray.data.arrow_pickled_object': {pickle_cols}. Reading these "
|
|
f"columns requires unpickling, which can execute arbitrary code "
|
|
f"and is unsafe with untrusted files.\n\n"
|
|
f"If you trust the source of this data, set the environment "
|
|
f"variable {AUTOLOAD_PICKLE_OBJECT_SCALAR_ENV_VAR}=1 to allow "
|
|
f"reading these columns. In a Ray cluster, this variable must "
|
|
f"be set on all worker nodes (e.g. via 'runtime_env')."
|
|
)
|
|
|
|
|
|
def read_fragments(
|
|
block_udf: Callable[[Block], Optional[Block]],
|
|
to_batches_kwargs: Dict[str, Any],
|
|
data_columns: Optional[List[str]],
|
|
partition_columns: Optional[List[str]],
|
|
schema: Optional[Union[type, "pyarrow.lib.Schema"]],
|
|
fragments: List[_ParquetFragment],
|
|
include_paths: bool,
|
|
include_row_hash: bool,
|
|
partitioning: Partitioning,
|
|
filter_expr: Optional["pyarrow.dataset.Expression"] = None,
|
|
filter_columns: Optional[List[str]] = None,
|
|
allow_pickle: bool = False,
|
|
) -> Iterator["pyarrow.Table"]:
|
|
"""Yield Arrow tables from Parquet fragments via ``to_batches_kwargs``."""
|
|
# This import is necessary to load the tensor extension type.
|
|
from ray.data.extensions.tensor_extension import ArrowTensorType # noqa
|
|
|
|
# Ensure that we're reading at least one dataset fragment.
|
|
assert len(fragments) > 0
|
|
|
|
logger.debug(f"Reading {len(fragments)} parquet fragments")
|
|
for fragment in fragments:
|
|
# S3 can raise transient errors during iteration, and PyArrow doesn't expose a
|
|
# way to retry specific batches.
|
|
ctx = ray.data.DataContext.get_current()
|
|
for table in iterate_with_retry(
|
|
lambda: _read_batches_from(
|
|
fragment.original,
|
|
schema=schema,
|
|
data_columns=data_columns,
|
|
partition_columns=partition_columns,
|
|
partitioning=partitioning,
|
|
include_path=include_paths,
|
|
include_row_hash=include_row_hash,
|
|
filter_expr=filter_expr,
|
|
filter_columns=filter_columns,
|
|
to_batches_kwargs=to_batches_kwargs,
|
|
),
|
|
"reading batches",
|
|
match=ctx.retried_io_errors,
|
|
):
|
|
# If the table is empty, drop it.
|
|
if table.num_rows > 0:
|
|
if not allow_pickle:
|
|
_check_for_pickle_object_columns(table)
|
|
if block_udf is not None:
|
|
yield block_udf(table)
|
|
else:
|
|
yield table
|
|
|
|
|
|
def _coerce_pyarrow_fragment_batch_size(batch_size: int) -> int:
|
|
"""Clamp batch size for ``ParquetFileFragment.to_batches`` to PyArrow's C int range.
|
|
|
|
Expects a value already converted with :func:`int` (callers reading from untyped
|
|
kwargs should do ``int(...)`` before calling). Values outside
|
|
``[1, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE]`` are clamped.
|
|
"""
|
|
if batch_size <= 0:
|
|
raise ValueError(f"Batch size must be > 0, got {batch_size}")
|
|
coerced = min(batch_size, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE)
|
|
if coerced != batch_size:
|
|
logger.debug(
|
|
"Clamping Parquet fragment read batch_size from %s to %s "
|
|
"(PyArrow ``to_batches`` requires batch_size in [1, %s]).",
|
|
batch_size,
|
|
coerced,
|
|
_MAX_PYARROW_TO_BATCHES_BATCH_SIZE,
|
|
)
|
|
return coerced
|
|
|
|
|
|
def _has_susceptible_nested_types(schema: "pyarrow.Schema") -> bool:
|
|
"""Check if a schema contains nested column types wrapping variable-length
|
|
leaves that are susceptible to Arrow's chunked array limitation (ARROW-5030).
|
|
|
|
The error only occurs when a nested container (list, struct, map) contains
|
|
a variable-length leaf (string, binary, and their large/view variants) whose
|
|
data exceeds ~2GB in a single row group. Fixed-width leaves (int, float,
|
|
bool, etc.) never trigger chunking.
|
|
"""
|
|
import pyarrow as pa
|
|
|
|
# is_string_view / is_binary_view only exist in PyArrow >= 16.0
|
|
_has_view_types = hasattr(pa.types, "is_string_view")
|
|
|
|
def _is_variable_length(t):
|
|
return (
|
|
pa.types.is_string(t)
|
|
or pa.types.is_binary(t)
|
|
or pa.types.is_large_string(t)
|
|
or pa.types.is_large_binary(t)
|
|
or (_has_view_types and pa.types.is_string_view(t))
|
|
or (_has_view_types and pa.types.is_binary_view(t))
|
|
)
|
|
|
|
def _is_nested(t):
|
|
return (
|
|
pa.types.is_list(t)
|
|
or pa.types.is_large_list(t)
|
|
or pa.types.is_struct(t)
|
|
or pa.types.is_map(t)
|
|
or pa.types.is_fixed_size_list(t)
|
|
)
|
|
|
|
def _nested_contains_variable_length(t):
|
|
"""Recursively check if a nested type contains a variable-length leaf."""
|
|
if _is_variable_length(t):
|
|
return True
|
|
if (
|
|
pa.types.is_list(t)
|
|
or pa.types.is_large_list(t)
|
|
or pa.types.is_fixed_size_list(t)
|
|
):
|
|
return _nested_contains_variable_length(t.value_type)
|
|
if pa.types.is_struct(t):
|
|
return any(_nested_contains_variable_length(f.type) for f in t)
|
|
if pa.types.is_map(t):
|
|
return _nested_contains_variable_length(
|
|
t.key_type
|
|
) or _nested_contains_variable_length(t.item_type)
|
|
return False
|
|
|
|
return any(
|
|
_is_nested(field.type) and _nested_contains_variable_length(field.type)
|
|
for field in schema
|
|
)
|
|
|
|
|
|
def _row_group_uncompressed_size(
|
|
rg_meta: "pyarrow.parquet.RowGroupMetaData",
|
|
column_indices: Optional[List[int]] = None,
|
|
) -> int:
|
|
"""Total uncompressed byte size of columns in a row group.
|
|
|
|
When *column_indices* is ``None`` all columns are summed, otherwise only
|
|
the listed (leaf-level) column indices are included.
|
|
|
|
NOTE: We intentionally avoid ``rg_meta.total_byte_size`` because it can
|
|
return the *compressed* size for some files (apache/arrow#48138).
|
|
"""
|
|
indices = range(rg_meta.num_columns) if column_indices is None else column_indices
|
|
return sum(rg_meta.column(i).total_uncompressed_size for i in indices)
|
|
|
|
|
|
def _resolve_leaf_column_indices(
|
|
metadata: "pyarrow.parquet.FileMetaData",
|
|
columns: List[str],
|
|
) -> List[int]:
|
|
"""Map top-level column names to Parquet metadata leaf column indices.
|
|
|
|
Parquet metadata enumerates *leaf* columns (nested types are flattened),
|
|
and each leaf's ``path_in_schema`` starts with the top-level field name.
|
|
"""
|
|
col_set = set(columns)
|
|
return [
|
|
i
|
|
for i in range(metadata.num_columns)
|
|
if metadata.row_group(0).column(i).path_in_schema.split(".")[0] in col_set
|
|
]
|
|
|
|
|
|
def _get_safe_batch_size_for_nested_types(
|
|
pf: "pyarrow.parquet.ParquetFile",
|
|
column_indices: Optional[List[int]] = None,
|
|
) -> int:
|
|
"""Compute a batch size that keeps each batch under Arrow's ~2GB nested-type
|
|
chunking threshold.
|
|
|
|
Uses Parquet row group metadata (uncompressed column sizes) to estimate
|
|
bytes per row, then picks a batch size with a 50% safety margin.
|
|
"""
|
|
safe_batch_size = pf.metadata.num_rows
|
|
for rg_idx in range(pf.metadata.num_row_groups):
|
|
rg_meta = pf.metadata.row_group(rg_idx)
|
|
if rg_meta.num_rows == 0:
|
|
continue
|
|
uncompressed = _row_group_uncompressed_size(rg_meta, column_indices)
|
|
if uncompressed == 0:
|
|
continue
|
|
bytes_per_row = uncompressed / rg_meta.num_rows
|
|
rg_safe = max(int(_ARROW_CHUNK_LIMIT // bytes_per_row // 2), 1)
|
|
safe_batch_size = min(safe_batch_size, rg_safe)
|
|
return safe_batch_size
|
|
|
|
|
|
def _needs_nested_type_fallback(
|
|
fragment: "ParquetFileFragment",
|
|
columns: Optional[List[str]] = None,
|
|
) -> bool:
|
|
"""Check if a fragment requires the fallback reader for nested types.
|
|
|
|
Returns True if the *requested* columns (or all columns when ``columns``
|
|
is ``None``) contain nested types AND any row group has uncompressed data
|
|
exceeding Arrow's ~2GB chunking threshold.
|
|
This is a metadata-only check (no data read).
|
|
"""
|
|
import pyarrow as pa
|
|
|
|
physical_schema = fragment.physical_schema
|
|
if columns is not None:
|
|
physical_schema = pa.schema(
|
|
[
|
|
physical_schema.field(c)
|
|
for c in columns
|
|
if physical_schema.get_field_index(c) != -1
|
|
]
|
|
)
|
|
if not _has_susceptible_nested_types(physical_schema):
|
|
return False
|
|
metadata = fragment.metadata
|
|
column_indices = (
|
|
_resolve_leaf_column_indices(metadata, columns)
|
|
if columns is not None and metadata.num_row_groups > 0
|
|
else None
|
|
)
|
|
# fragment.row_groups is non-None when the fragment is a subset of the
|
|
# file (e.g. only row group 0). Only inspect those row groups to avoid
|
|
# falsely triggering the fallback because of a *different* large row
|
|
# group elsewhere in the same file.
|
|
if fragment.row_groups is not None:
|
|
rg_indices = [rg.id for rg in fragment.row_groups]
|
|
else:
|
|
rg_indices = range(metadata.num_row_groups)
|
|
return any(
|
|
_row_group_uncompressed_size(metadata.row_group(rg_idx), column_indices)
|
|
>= _ARROW_CHUNK_LIMIT
|
|
for rg_idx in rg_indices
|
|
)
|
|
|
|
|
|
def _resolve_read_columns(
|
|
columns: Optional[List[str]],
|
|
filter_expr: Optional["pyarrow.dataset.Expression"],
|
|
filter_columns: Optional[List[str]],
|
|
) -> Optional[List[str]]:
|
|
"""Compute the union of projected and filter-referenced columns.
|
|
|
|
When a filter references columns outside the projection, we must read
|
|
the union so the filter can evaluate. Returns ``None`` (meaning "all
|
|
columns") when filter_columns is unknown.
|
|
"""
|
|
if filter_expr is not None and columns is not None:
|
|
if filter_columns is not None:
|
|
return list(dict.fromkeys(columns + filter_columns))
|
|
return None
|
|
return columns
|
|
|
|
|
|
def _iter_batches_with_nested_fallback(
|
|
fragment: "ParquetFileFragment",
|
|
*,
|
|
columns: Optional[List[str]] = None,
|
|
schema: Optional["pyarrow.Schema"] = None,
|
|
to_batches_kwargs: Optional[Dict[str, Any]] = None,
|
|
use_threads: bool = False,
|
|
filter_expr: Optional["pyarrow.dataset.Expression"] = None,
|
|
filter_columns: Optional[List[str]] = None,
|
|
) -> Iterable["pyarrow.RecordBatch"]:
|
|
"""Iterate over batches from a fragment, using the fallback reader when
|
|
the fragment has nested column types with row groups exceeding Arrow's
|
|
~2GB chunking threshold (ARROW-5030).
|
|
|
|
The reading strategy is chosen upfront based on schema and metadata to
|
|
avoid mid-stream fallback that could duplicate already-yielded batches.
|
|
|
|
In the normal path, ``filter_expr`` is pushed down to the scanner. In the
|
|
fallback path, row-group-level predicate pushdown is applied via
|
|
``fragment.subset(filter=)`` and row-level filtering is done post-read.
|
|
"""
|
|
to_batches_kwargs = dict(to_batches_kwargs or {})
|
|
|
|
read_columns = _resolve_read_columns(columns, filter_expr, filter_columns)
|
|
|
|
if not _needs_nested_type_fallback(fragment, read_columns):
|
|
yield from fragment.to_batches(
|
|
columns=columns,
|
|
filter=filter_expr,
|
|
schema=schema,
|
|
use_threads=use_threads,
|
|
**to_batches_kwargs,
|
|
)
|
|
return
|
|
|
|
yield from _iter_batches_fallback(
|
|
fragment,
|
|
columns=columns,
|
|
read_columns=read_columns,
|
|
schema=schema,
|
|
batch_size=to_batches_kwargs.get("batch_size"),
|
|
use_threads=use_threads,
|
|
filter_expr=filter_expr,
|
|
)
|
|
|
|
|
|
def _iter_batches_fallback(
|
|
fragment: "ParquetFileFragment",
|
|
*,
|
|
columns: Optional[List[str]],
|
|
read_columns: Optional[List[str]],
|
|
schema: Optional["pyarrow.Schema"],
|
|
batch_size: Optional[int],
|
|
use_threads: bool,
|
|
filter_expr: Optional["pyarrow.dataset.Expression"],
|
|
) -> Iterable["pyarrow.RecordBatch"]:
|
|
"""Row-level batched reader for fragments with nested types that exceed
|
|
Arrow's ~2GB chunking threshold (ARROW-5030).
|
|
"""
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
|
|
from ray.data._internal.arrow_ops.transform_pyarrow import (
|
|
_align_struct_fields,
|
|
)
|
|
|
|
if log_once("parquet_nested_fallback"):
|
|
logger.warning(
|
|
"Using pyarrow.parquet row-level batched reader for '%s' due to "
|
|
"Arrow nested type chunking limitation (ARROW-5030). Consider "
|
|
"writing Parquet files with smaller row group sizes to avoid this.",
|
|
fragment.path,
|
|
)
|
|
|
|
pf = pq.ParquetFile(fragment.path, filesystem=fragment.filesystem)
|
|
|
|
# Scope batch-size calculation to only the columns being decoded.
|
|
leaf_indices = (
|
|
_resolve_leaf_column_indices(pf.metadata, read_columns)
|
|
if read_columns is not None and pf.metadata.num_row_groups > 0
|
|
else None
|
|
)
|
|
safe = _get_safe_batch_size_for_nested_types(pf, leaf_indices)
|
|
fallback_batch_size = min(batch_size, safe) if batch_size else safe
|
|
|
|
# fragment.subset(filter=) respects the fragment's existing row-group
|
|
# subset, so a single call handles both the original constraint and
|
|
# filter-based predicate pushdown.
|
|
subset = (
|
|
fragment.subset(filter=filter_expr) if filter_expr is not None else fragment
|
|
)
|
|
row_groups = (
|
|
[rg.id for rg in subset.row_groups] if subset.row_groups is not None else None
|
|
)
|
|
|
|
# Filter pruned every row group — nothing to read.
|
|
if row_groups is not None and len(row_groups) == 0:
|
|
return
|
|
|
|
# Build a sub-schema covering only the output columns so alignment
|
|
# doesn't pad with unneeded null columns. Scoped to ``columns``
|
|
# (not ``read_columns``) because filter-referenced columns may not
|
|
# appear in the target schema.
|
|
if schema is not None and columns is not None:
|
|
align_schema = pa.schema(
|
|
[schema.field(c) for c in columns if schema.get_field_index(c) != -1]
|
|
)
|
|
else:
|
|
align_schema = schema
|
|
|
|
for batch in pf.iter_batches(
|
|
batch_size=fallback_batch_size,
|
|
columns=read_columns,
|
|
use_threads=use_threads,
|
|
row_groups=row_groups,
|
|
):
|
|
table = pa.Table.from_batches([batch])
|
|
|
|
# Row-level filter runs on the physical schema before alignment
|
|
# so that filter-referenced columns outside the output projection
|
|
# (and possibly outside the target schema) are still present.
|
|
if filter_expr is not None:
|
|
table = table.filter(filter_expr)
|
|
|
|
# Project down to the requested columns, dropping any
|
|
# filter-only columns before the (potentially expensive) align.
|
|
if columns is not None:
|
|
table = table.select(columns)
|
|
|
|
# pq.ParquetFile.iter_batches() doesn't accept a schema arg, so
|
|
# batches come back with the file's physical schema. Align to the
|
|
# unified dataset schema to match the normal (scanner) path which
|
|
# handles type promotion and missing-column filling automatically.
|
|
if align_schema is not None:
|
|
table = _align_struct_fields([table], align_schema)[0].cast(align_schema)
|
|
|
|
yield from table.to_batches()
|
|
|
|
|
|
def _read_batches_from(
|
|
fragment: "ParquetFileFragment",
|
|
*,
|
|
schema: "pyarrow.Schema",
|
|
data_columns: Optional[List[str]],
|
|
partition_columns: Optional[List[str]],
|
|
partitioning: Partitioning,
|
|
filter_expr: Optional["pyarrow.dataset.Expression"] = None,
|
|
filter_columns: Optional[List[str]] = None,
|
|
include_path: bool = False,
|
|
include_row_hash: bool = False,
|
|
use_threads: bool = False,
|
|
to_batches_kwargs: Optional[Dict[str, Any]] = None,
|
|
) -> Iterable["pyarrow.Table"]:
|
|
"""Get an iterable of batches from a parquet fragment.
|
|
|
|
Row batching is controlled via ``to_batches_kwargs["batch_size"]`` (when
|
|
present), which is coerced to values PyArrow accepts as a C ``int``.
|
|
"""
|
|
|
|
import pyarrow as pa
|
|
|
|
# Copy to avoid modifying passed in arg
|
|
to_batches_kwargs = dict(to_batches_kwargs or {})
|
|
|
|
# NOTE: Passed in kwargs overrides always take precedence
|
|
# TODO deprecate to_batches_kwargs
|
|
use_threads = to_batches_kwargs.pop("use_threads", use_threads)
|
|
# TODO: We should deprecate filter through the read_parquet API and only allow through dataset.filter()
|
|
filter_from_kwargs = to_batches_kwargs.pop("filter", None)
|
|
if filter_from_kwargs is not None:
|
|
filter_expr = (
|
|
filter_from_kwargs
|
|
if filter_expr is None
|
|
else filter_expr & filter_from_kwargs
|
|
)
|
|
# Cannot determine columns from an opaque PyArrow filter expression,
|
|
# so invalidate filter_columns to fall back to reading all columns.
|
|
filter_columns = None
|
|
|
|
if to_batches_kwargs.get("batch_size") is not None:
|
|
to_batches_kwargs["batch_size"] = _coerce_pyarrow_fragment_batch_size(
|
|
int(to_batches_kwargs["batch_size"])
|
|
)
|
|
|
|
partition_col_values = _parse_partition_column_values(
|
|
fragment, partition_columns, partitioning
|
|
)
|
|
|
|
row_offset = 0
|
|
|
|
def _generate_tables() -> "pa.Table":
|
|
nonlocal row_offset
|
|
|
|
def _postprocess_table(table):
|
|
if partition_col_values:
|
|
table = _add_partitions_to_table(partition_col_values, table)
|
|
|
|
if include_path:
|
|
table = ArrowBlockAccessor.for_block(table).fill_column(
|
|
"path", fragment.path
|
|
)
|
|
|
|
# ``ParquetFileFragment.to_batches`` returns ``RecordBatch``,
|
|
# which could have empty projection (ie ``num_columns`` == 0)
|
|
# while having non-empty rows (ie ``num_rows`` > 0), which
|
|
# could occur when list of requested columns is empty.
|
|
#
|
|
# However, when ``RecordBatches`` are concatenated using
|
|
# ``pyarrow.concat_tables`` it will return a single ``Table``
|
|
# with 0 columns and therefore 0 rows (since ``Table``s number of
|
|
# rows is determined as the length of its columns).
|
|
#
|
|
# To avoid running into this pitfall, we introduce a stub column
|
|
# holding just nulls to maintain invariance of the number of rows.
|
|
#
|
|
# NOTE: There's no impact from this as the binary size of the
|
|
# extra column is basically 0
|
|
if table.num_columns == 0 and table.num_rows > 0:
|
|
table = table.append_column(
|
|
_BATCH_SIZE_PRESERVING_STUB_COL_NAME, pa.nulls(table.num_rows)
|
|
)
|
|
return table
|
|
|
|
try:
|
|
for batch in _iter_batches_with_nested_fallback(
|
|
fragment,
|
|
columns=data_columns,
|
|
filter_expr=filter_expr,
|
|
filter_columns=filter_columns,
|
|
schema=schema,
|
|
use_threads=use_threads,
|
|
to_batches_kwargs=to_batches_kwargs,
|
|
):
|
|
table = _postprocess_table(pa.Table.from_batches([batch]))
|
|
if include_row_hash:
|
|
hashes = _compute_row_hashes(
|
|
fragment.path, row_offset, table.num_rows
|
|
)
|
|
table = ArrowBlockAccessor.for_block(table).fill_column(
|
|
"row_hash", pa.array(hashes, type=pa.uint64())
|
|
)
|
|
row_offset += table.num_rows
|
|
yield table
|
|
|
|
except pa.lib.ArrowInvalid as e:
|
|
error_message = str(e)
|
|
if (
|
|
"No match for FieldRef.Name" in error_message
|
|
and filter_expr is not None
|
|
):
|
|
filename = os.path.basename(fragment.path)
|
|
file_columns = set(fragment.physical_schema.names)
|
|
raise RuntimeError(
|
|
f"Filter expression: '{filter_expr}' failed on parquet "
|
|
f"file: '{filename}' with columns: {file_columns}"
|
|
)
|
|
raise
|
|
|
|
yield from _generate_tables()
|
|
|
|
|
|
def _compute_row_hashes(file_path: str, start_row: int, num_rows: int) -> np.ndarray:
|
|
"""Compute deterministic uint64 hashes from file path and output row position.
|
|
|
|
``start_row`` is the position within the output stream (post-filter), not
|
|
the physical file offset. This means hashes are reproducible for a given
|
|
pipeline configuration (same file + same filter) but will differ across
|
|
reads with different filters.
|
|
|
|
Hashes the file path with MD5 to obtain a 64-bit seed, adds the row indices,
|
|
then applies the splitmix64 finalizer (a bijective 64-bit mixing function) to
|
|
produce well-distributed, reproducible hashes. Fully vectorized via numpy.
|
|
"""
|
|
path_seed = np.uint64(
|
|
int.from_bytes(
|
|
hashlib.md5(file_path.encode("utf-8")).digest()[:8], byteorder="little"
|
|
)
|
|
)
|
|
keys = path_seed + np.arange(start_row, start_row + num_rows, dtype=np.uint64)
|
|
|
|
# splitmix64 finalizer – a bijective 64-bit mixing function from
|
|
# Steele, Lea & Flood, "Fast Splittable Pseudorandom Number Generators",
|
|
# OOPSLA 2014. Also used in Java's SplittableRandom.
|
|
# Reference: https://xorshift.di.unimi.it/splitmix64.c
|
|
keys ^= keys >> np.uint64(30)
|
|
keys *= np.uint64(0xBF58476D1CE4E5B9)
|
|
keys ^= keys >> np.uint64(27)
|
|
keys *= np.uint64(0x94D049BB133111EB)
|
|
keys ^= keys >> np.uint64(31)
|
|
|
|
return keys
|
|
|
|
|
|
def _parse_partition_column_values(
|
|
fragment: "ParquetFileFragment",
|
|
partition_columns: Optional[List[str]],
|
|
partitioning: Partitioning,
|
|
) -> Dict[str, PartitionDataType]:
|
|
partitions = {}
|
|
|
|
if partitioning is not None:
|
|
parse = PathPartitionParser(partitioning)
|
|
partitions = parse(fragment.path)
|
|
|
|
# Filter out partitions that aren't in the user-specified columns list.
|
|
if partition_columns is not None:
|
|
partitions = {
|
|
field_name: value
|
|
for field_name, value in partitions.items()
|
|
if field_name in partition_columns
|
|
}
|
|
|
|
return partitions
|
|
|
|
|
|
def _fetch_parquet_file_info(
|
|
fragment: _ParquetFragment,
|
|
*,
|
|
columns: Optional[List[str]],
|
|
schema: Optional["pyarrow.Schema"],
|
|
) -> Optional["_ParquetFileInfo"]:
|
|
# If the fragment has no row groups, it's an empty or metadata-only file.
|
|
# Skip it by returning empty sample info.
|
|
#
|
|
# NOTE: Accessing `ParquetFileFragment.metadata` does fetch a parquet footer
|
|
# from storage
|
|
metadata = fragment.original.metadata
|
|
|
|
if metadata.num_row_groups == 0:
|
|
return None
|
|
|
|
# Only sample the first row group.
|
|
row_group_fragment = fragment.original.subset(row_group_ids=[0])
|
|
batch_size = max(
|
|
min(
|
|
row_group_fragment.metadata.num_rows,
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_NUM_ROWS,
|
|
),
|
|
1,
|
|
)
|
|
|
|
to_batches_kwargs = {}
|
|
|
|
if get_pyarrow_version() >= MIN_PYARROW_TO_BATCHES_READAHEAD:
|
|
# Limit prefetching to just 1 batch
|
|
to_batches_kwargs["batch_readahead"] = 1
|
|
|
|
to_batches_kwargs["batch_size"] = batch_size
|
|
|
|
avg_row_size: Optional[int] = None
|
|
# Use first non-empty batch to estimate the avg size of the row in-memory
|
|
for batch in _iter_batches_with_nested_fallback(
|
|
row_group_fragment,
|
|
columns=columns,
|
|
schema=schema,
|
|
to_batches_kwargs=to_batches_kwargs,
|
|
):
|
|
if batch.num_rows > 0:
|
|
avg_row_size = math.ceil(batch.nbytes / batch.num_rows)
|
|
break
|
|
|
|
return _ParquetFileInfo(
|
|
avg_row_in_mem_bytes=avg_row_size,
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _ParquetFileInfo:
|
|
# Estimated avg byte size of a row (in-memory)
|
|
avg_row_in_mem_bytes: Optional[int]
|
|
# Corresponding file metadata
|
|
metadata: "pyarrow._parquet.FileMetaData"
|
|
|
|
def estimate_in_memory_bytes(self) -> Optional[int]:
|
|
if self.avg_row_in_mem_bytes is None:
|
|
return None
|
|
|
|
return self.avg_row_in_mem_bytes * self.metadata.num_rows
|
|
|
|
|
|
def _estimate_files_encoding_ratio(
|
|
fragments: List[_ParquetFragment],
|
|
file_infos: List[_ParquetFileInfo],
|
|
) -> float:
|
|
"""Return an estimate of the Parquet files encoding ratio.
|
|
|
|
To avoid OOMs, it is safer to return an over-estimate than an underestimate.
|
|
"""
|
|
if not DataContext.get_current().decoding_size_estimation:
|
|
return PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT
|
|
|
|
assert len(file_infos) == len(fragments)
|
|
|
|
# Estimate size of the rows in a file in memory
|
|
estimated_in_mem_size_arr = [
|
|
fi.estimate_in_memory_bytes() if fi is not None else None for fi in file_infos
|
|
]
|
|
|
|
file_size_arr = [f.file_size for f in fragments]
|
|
|
|
estimated_encoding_ratios = [
|
|
float(in_mem_size) / file_size
|
|
for in_mem_size, file_size in zip(estimated_in_mem_size_arr, file_size_arr)
|
|
if file_size > 0 and in_mem_size is not None
|
|
]
|
|
|
|
# Return default estimate of 5 if all sampled files turned out to be empty
|
|
if not estimated_encoding_ratios:
|
|
return PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT
|
|
|
|
estimated_ratio = np.mean(estimated_encoding_ratios)
|
|
|
|
logger.info(f"Estimated parquet encoding ratio is {estimated_ratio:.3f}.")
|
|
|
|
return max(estimated_ratio, PARQUET_ENCODING_RATIO_ESTIMATE_LOWER_BOUND)
|
|
|
|
|
|
def _fetch_file_infos(
|
|
sampled_fragments: List[_ParquetFragment],
|
|
*,
|
|
columns: Optional[List[str]],
|
|
schema: Optional["pyarrow.Schema"],
|
|
local_scheduling: Optional[Dict[str, str]],
|
|
) -> List[Optional[_ParquetFileInfo]]:
|
|
fetch_file_info = cached_remote_fn(_fetch_parquet_file_info)
|
|
futures = []
|
|
|
|
# Retry in case of transient errors during sampling.
|
|
# Cap retries to avoid hanging indefinitely on permanent errors
|
|
# (e.g., permission denied, invalid credentials).
|
|
task_options = {"retry_exceptions": [OSError], "max_retries": 3}
|
|
ctx = DataContext.get_current()
|
|
if local_scheduling:
|
|
task_options["label_selector"] = local_scheduling
|
|
else:
|
|
task_options["scheduling_strategy"] = ctx.scheduling_strategy
|
|
task_options = merge_label_selector(
|
|
task_options, ctx.execution_options.label_selector
|
|
)
|
|
|
|
for fragment in sampled_fragments:
|
|
# Sample the first rows batch in i-th file.
|
|
# Use SPREAD scheduling strategy to avoid packing many sampling tasks on
|
|
# same machine to cause OOM issue, as sampling can be memory-intensive.
|
|
futures.append(
|
|
fetch_file_info.options(**task_options).remote(
|
|
fragment,
|
|
columns=columns,
|
|
schema=schema,
|
|
)
|
|
)
|
|
|
|
sample_bar = ProgressBar("Parquet dataset sampling", len(futures), unit="file")
|
|
try:
|
|
file_infos = sample_bar.fetch_until_complete(futures)
|
|
except ray.exceptions.RayTaskError as e:
|
|
logger.warning(
|
|
"Parquet dataset sampling failed. "
|
|
"If this is a credentials or permissions issue, "
|
|
"check your cloud storage access configuration. "
|
|
f"Underlying error: {e.cause}"
|
|
)
|
|
raise
|
|
finally:
|
|
sample_bar.close()
|
|
|
|
return file_infos
|
|
|
|
|
|
def _estimate_reader_batch_size(
|
|
file_infos: List[Optional[_ParquetFileInfo]], target_block_size: Optional[int]
|
|
) -> Optional[int]:
|
|
if target_block_size is None:
|
|
return None
|
|
|
|
avg_num_rows_per_block = [
|
|
target_block_size / fi.avg_row_in_mem_bytes
|
|
for fi in file_infos
|
|
if (
|
|
fi is not None
|
|
and fi.avg_row_in_mem_bytes is not None
|
|
and fi.avg_row_in_mem_bytes > 0
|
|
)
|
|
]
|
|
|
|
if not avg_num_rows_per_block:
|
|
return DEFAULT_PARQUET_READER_ROW_BATCH_SIZE
|
|
|
|
estimated_batch_size: int = max(math.ceil(np.mean(avg_num_rows_per_block)), 1)
|
|
|
|
logger.info(f"Estimated parquet reader batch size at {estimated_batch_size} rows")
|
|
|
|
return estimated_batch_size
|
|
|
|
|
|
def get_parquet_dataset(
|
|
paths: List[str],
|
|
schema: Optional["pyarrow.Schema"] = None,
|
|
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
|
inspect_num_fragments: Optional[int] = 0,
|
|
dataset_kwargs: Optional[Dict[str, Any]] = None,
|
|
):
|
|
assert inspect_num_fragments is None or inspect_num_fragments >= 0, (
|
|
f"`inspect_num_fragments` could either be null (inspect all fragments) "
|
|
f"or >= 0 (got {inspect_num_fragments})"
|
|
)
|
|
|
|
paths = paths if isinstance(paths, list) else [paths]
|
|
|
|
# For linter
|
|
dataset = None
|
|
|
|
try:
|
|
dataset = _get_parquet_dataset_internal(
|
|
paths,
|
|
schema,
|
|
filesystem,
|
|
inspect_num_fragments,
|
|
dataset_kwargs,
|
|
)
|
|
|
|
except TypeError:
|
|
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
|
|
|
|
try:
|
|
# Fallback: resolve filesystem locally in the worker
|
|
resolved_paths, resolved_filesystem = _resolve_paths_and_filesystem(
|
|
paths, filesystem=None
|
|
)
|
|
resolved_filesystem = RetryingPyFileSystem.wrap(
|
|
resolved_filesystem,
|
|
retryable_errors=DataContext.get_current().retried_io_errors,
|
|
)
|
|
|
|
dataset = _get_parquet_dataset_internal(
|
|
resolved_paths,
|
|
schema,
|
|
resolved_filesystem,
|
|
inspect_num_fragments,
|
|
dataset_kwargs,
|
|
)
|
|
except OSError as os_e:
|
|
_handle_read_os_error(os_e, paths)
|
|
|
|
except OSError as e:
|
|
_handle_read_os_error(e, paths)
|
|
|
|
return dataset
|
|
|
|
|
|
def _get_parquet_dataset_internal(
|
|
paths: List[str],
|
|
schema: Optional["pyarrow.Schema"],
|
|
filesystem: Optional["pyarrow.fs.FileSystem"],
|
|
inspect_num_fragments: Optional[int],
|
|
dataset_kwargs: Optional[Dict[str, Any]] = None,
|
|
) -> "pyarrow.parquet.ParquetDataset":
|
|
|
|
import pyarrow.parquet as pq
|
|
|
|
should_inspect = inspect_num_fragments != 0
|
|
|
|
if schema is None and should_inspect:
|
|
# NOTE: In case no schema is provided we must infer
|
|
schema = _infer_schema(
|
|
paths,
|
|
inspect_num_fragments,
|
|
filesystem,
|
|
)
|
|
|
|
dataset_kwargs = dataset_kwargs or {}
|
|
|
|
return pq.ParquetDataset(
|
|
# When passing directories, Pyarrow expects single items and not
|
|
# a list (otherwise erroring out)
|
|
paths[0] if len(paths) == 1 else paths,
|
|
schema=schema,
|
|
filesystem=filesystem,
|
|
**dataset_kwargs,
|
|
)
|
|
|
|
|
|
def _infer_schema(
|
|
paths: List[str],
|
|
inspect_num_fragments: Optional[int],
|
|
filesystem: Optional["pyarrow.fs.FileSystem"],
|
|
) -> "pyarrow.Schema":
|
|
import pyarrow as pa
|
|
import pyarrow.dataset as pds
|
|
|
|
factory = pds.FileSystemDatasetFactory(
|
|
filesystem,
|
|
paths,
|
|
format=pds.ParquetFileFormat(),
|
|
)
|
|
|
|
# NOTE: By default we're inspecting all the fragments.
|
|
# The ``fragments`` kwarg was added in PyArrow 21.0 (previously
|
|
# all fragments were inspected unconditionally).
|
|
# PyArrow 22.0 added ``promote_options`` for proper null→concrete
|
|
# type promotion across fragments (GH-46629).
|
|
pa_version = get_pyarrow_version()
|
|
|
|
if pa_version >= _MIN_PYARROW_VERSION_FS_FACTORY_INSPECT_PROMOTE_OPTIONS:
|
|
inspect_kwargs = {
|
|
"fragments": inspect_num_fragments,
|
|
"promote_options": "permissive",
|
|
}
|
|
else:
|
|
inspect_kwargs = {}
|
|
|
|
schema = factory.inspect(**inspect_kwargs)
|
|
|
|
# Before Pyarrow 22.0, ``factory.inspect`` doesn't promote ``null`` types
|
|
# to concrete types when unifying schemas across fragments (which
|
|
# happens when some files have all-null values for a column).
|
|
#
|
|
# In that case we manually collect physical schemas from all fragments and
|
|
# call ``pa.unify_schemas`` to correctly promote the types.
|
|
if pa_version < _MIN_PYARROW_VERSION_FS_FACTORY_INSPECT_PROMOTE_OPTIONS and any(
|
|
field.type == pa.null() for field in schema
|
|
):
|
|
dataset = factory.finish(schema)
|
|
fragment_schemas = [f.physical_schema for f in dataset.get_fragments()]
|
|
schema = pa.unify_schemas([schema] + fragment_schemas)
|
|
|
|
return schema
|
|
|
|
|
|
def _sample_fragments(
|
|
fragments: List[_ParquetFragment],
|
|
) -> List[_ParquetFragment]:
|
|
if not fragments:
|
|
return []
|
|
|
|
target_num_samples = math.ceil(
|
|
len(fragments) * PARQUET_ENCODING_RATIO_ESTIMATE_SAMPLING_RATIO
|
|
)
|
|
|
|
target_num_samples = max(
|
|
min(target_num_samples, PARQUET_ENCODING_RATIO_ESTIMATE_MAX_NUM_SAMPLES),
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_MIN_NUM_SAMPLES,
|
|
)
|
|
|
|
# Make sure number of samples doesn't exceed total # of files
|
|
target_num_samples = min(target_num_samples, len(fragments))
|
|
|
|
# Evenly distributed to choose which file to sample, to avoid biased prediction
|
|
# if data is skewed.
|
|
pivots = np.linspace(0, len(fragments) - 1, target_num_samples).astype(int)
|
|
|
|
return [fragments[idx] for idx in pivots.tolist()]
|
|
|
|
|
|
def _add_partitions_to_table(
|
|
partition_col_values: Dict[str, PartitionDataType], table: "pyarrow.Table"
|
|
) -> "pyarrow.Table":
|
|
|
|
for partition_col, value in partition_col_values.items():
|
|
field_index = table.schema.get_field_index(partition_col)
|
|
if field_index == -1:
|
|
table = BlockAccessor.for_block(table).fill_column(partition_col, value)
|
|
elif log_once(f"duplicate_partition_field_{partition_col}"):
|
|
logger.warning(
|
|
f"The partition field '{partition_col}' also exists in the Parquet "
|
|
f"file. Ray Data will default to using the value in the Parquet file."
|
|
)
|
|
|
|
return table
|
|
|
|
|
|
def _get_partition_columns_schema(
|
|
partitioning: Partitioning,
|
|
file_paths: List[str],
|
|
) -> "pyarrow.Schema":
|
|
"""Return a new schema with partition fields added.
|
|
|
|
This function infers the partition fields from the first file path in the dataset.
|
|
"""
|
|
import pyarrow as pa
|
|
|
|
# If the dataset is empty, we can't infer the partitioning
|
|
if len(file_paths) == 0:
|
|
return pa.schema([])
|
|
# If the dataset isn't partitioned, there's no partition schema
|
|
elif partitioning is None:
|
|
return pa.schema([])
|
|
|
|
first_path = file_paths[0]
|
|
|
|
fields = []
|
|
|
|
parser = PathPartitionParser(partitioning)
|
|
partitions = parser(first_path)
|
|
for field_name in partitions:
|
|
if field_name in partitioning.field_types:
|
|
field_type = pa.from_numpy_dtype(partitioning.field_types[field_name])
|
|
else:
|
|
field_type = pa.string()
|
|
|
|
# Without this check, we would add the same partition field multiple times,
|
|
# which silently fails when asking for `pa.field()`.
|
|
fields.append(pa.field(field_name, field_type))
|
|
|
|
return pa.schema(fields)
|
|
|
|
|
|
def _infer_data_and_partition_columns(
|
|
user_specified_columns: List[str],
|
|
fragment: "ParquetFileFragment",
|
|
partitioning: Optional[Partitioning],
|
|
) -> Tuple[List[str], List[str]]:
|
|
"""Infer which columns are in the files and which columns are partition columns.
|
|
|
|
This function uses the schema and path of the first file to infer what columns
|
|
represent.
|
|
|
|
Args:
|
|
user_specified_columns: A list of column names that the user specified.
|
|
fragment: The first fragment in the dataset.
|
|
partitioning: The partitioning scheme used to partition the data.
|
|
|
|
Returns:
|
|
A tuple of lists of column names. The first list contains the columns that are
|
|
in the file, and the second list contains the columns that are partition
|
|
columns.
|
|
"""
|
|
data_columns = [
|
|
column
|
|
for column in user_specified_columns
|
|
if column in fragment.physical_schema.names
|
|
]
|
|
if partitioning is not None:
|
|
parse = PathPartitionParser(partitioning)
|
|
partitions = parse(fragment.path)
|
|
partition_columns = [
|
|
column for column in user_specified_columns if column in partitions
|
|
]
|
|
else:
|
|
partition_columns = []
|
|
|
|
return data_columns, partition_columns
|