chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from typing import TypeVar
|
||||
|
||||
InputSplit = TypeVar("InputSplit")
|
||||
@@ -0,0 +1,286 @@
|
||||
"""File chunkers for DataSourceV2.
|
||||
|
||||
A ``FileChunker`` decides how a single listed file is split into one or
|
||||
more parallel-read units. The indexer drives the chunker once per file
|
||||
and emits one manifest row per chunk; downstream the partitioner /
|
||||
reader carry the per-chunk metadata through to the read task.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
import math
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Iterable,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
cast,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from ray.data._internal.util import MiB, infer_compression
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyarrow.fs import FileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChunkMetadata(TypedDict):
|
||||
"""Base interface for chunk metadata types."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_ChunkMetadataT = TypeVar("_ChunkMetadataT", bound=ChunkMetadata)
|
||||
|
||||
|
||||
def create_chunk_metadata(cls: Type[_ChunkMetadataT], **kwargs) -> _ChunkMetadataT:
|
||||
"""Create a metadata instance with validation, ensure the keys are correct."""
|
||||
required_keys = list(get_type_hints(cls).keys())
|
||||
|
||||
missing_keys = [key for key in required_keys if key not in kwargs]
|
||||
if missing_keys:
|
||||
raise ValueError(f"Missing required keys: {missing_keys}")
|
||||
|
||||
extra_keys = [key for key in kwargs if key not in required_keys]
|
||||
if extra_keys:
|
||||
raise ValueError(f"Unexpected keys: {extra_keys}")
|
||||
|
||||
return cast(_ChunkMetadataT, kwargs)
|
||||
|
||||
|
||||
class LineDelimitedFileChunkMetadata(ChunkMetadata):
|
||||
"""Metadata for line-delimited file chunks."""
|
||||
|
||||
chunk_byte_start_idx: int
|
||||
chunk_byte_end_idx: int
|
||||
|
||||
|
||||
class ParquetFileChunkMetadata(ChunkMetadata):
|
||||
"""Metadata for Parquet file chunks.
|
||||
|
||||
A chunk is an explicit, half-open range of consecutive row groups
|
||||
``[row_group_start, row_group_end)`` within a single file, computed at
|
||||
listing time from the file's footer. The reader slices the fragment to
|
||||
exactly this range — no estimation or read-time reconciliation.
|
||||
"""
|
||||
|
||||
row_group_start: int # inclusive
|
||||
row_group_end: int # exclusive
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class FileChunker(abc.ABC):
|
||||
"""Abstract base class for chunking files into smaller pieces for parallel processing.
|
||||
|
||||
File chunkers determine how large files should be split into chunks that can be
|
||||
processed in parallel. Different file formats may require different chunking strategies.
|
||||
|
||||
For example:
|
||||
- Line-delimited files (JSONL, CSV) can be chunked by byte ranges
|
||||
- Parquet files can be chunked by row groups
|
||||
"""
|
||||
|
||||
# Whether ``generate_chunk_metadatas`` performs file I/O (e.g. reading a
|
||||
# Parquet footer). When True, the indexer fans chunking across its thread
|
||||
# pool so the per-file reads parallelize even for a single input
|
||||
# directory. When False, the indexer chunks inline (no thread hand-off).
|
||||
reads_file_metadata: bool = False
|
||||
|
||||
@abc.abstractmethod
|
||||
def generate_chunk_metadatas(
|
||||
self,
|
||||
path: str,
|
||||
file_size: int,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
|
||||
"""Generate metadata for file chunks.
|
||||
|
||||
Args:
|
||||
path: The file path being chunked.
|
||||
file_size: The total size in bytes of the file to be chunked.
|
||||
filesystem: PyArrow filesystem used to read per-file metadata
|
||||
(e.g. the Parquet footer). Ignored by chunkers that do not
|
||||
read file metadata.
|
||||
|
||||
Returns:
|
||||
An iterable of tuples containing (metadata, chunk_size) where metadata
|
||||
describes the chunk and chunk_size is the size of the chunk in bytes.
|
||||
Metadata can be None for chunks that don't require metadata
|
||||
(e.g., whole file processing).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class WholeFileChunker(FileChunker):
|
||||
"""File chunker that treats the whole file as a single chunk.
|
||||
|
||||
This chunker is used when files should be processed as a single unit,
|
||||
typically for smaller files or when the file format doesn't support
|
||||
efficient chunking (e.g., compressed files).
|
||||
|
||||
Yields a single chunk with no metadata, indicating the entire file
|
||||
should be processed as one unit.
|
||||
"""
|
||||
|
||||
def generate_chunk_metadatas(
|
||||
self,
|
||||
path: str,
|
||||
file_size: int,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
|
||||
yield None, file_size
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class LineDelimitedFileChunker(FileChunker):
|
||||
"""File chunker for line-delimited files (JSONL, CSV, TSV, etc.).
|
||||
|
||||
This chunker splits files into fixed-size byte chunks (default: 256 MiB)
|
||||
and provides metadata about the byte ranges for each chunk. The actual
|
||||
line boundaries are handled by the reader to ensure complete records.
|
||||
"""
|
||||
|
||||
_CHUNK_BYTE_SIZE = 256 * MiB # 256 MiB
|
||||
|
||||
def generate_chunk_metadatas(
|
||||
self,
|
||||
path: str,
|
||||
file_size: int,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
|
||||
compression = infer_compression(path)
|
||||
if compression is not None:
|
||||
yield None, file_size
|
||||
else:
|
||||
num_chunks = math.ceil(file_size / self._CHUNK_BYTE_SIZE)
|
||||
for chunk_idx in range(num_chunks):
|
||||
chunk_start = self._CHUNK_BYTE_SIZE * chunk_idx
|
||||
chunk_end = min(self._CHUNK_BYTE_SIZE * (chunk_idx + 1), file_size)
|
||||
chunk_size = chunk_end - chunk_start
|
||||
yield (
|
||||
create_chunk_metadata(
|
||||
LineDelimitedFileChunkMetadata,
|
||||
chunk_byte_start_idx=chunk_start,
|
||||
chunk_byte_end_idx=chunk_end,
|
||||
),
|
||||
chunk_size,
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ParquetFileChunker(FileChunker):
|
||||
"""File chunker for Parquet files.
|
||||
|
||||
Reads each file's footer at listing time and chunks on **true row-group
|
||||
boundaries**: consecutive row groups are bundled into a chunk until the
|
||||
bundle's on-disk size reaches ``target_chunk_size`` (always at least one
|
||||
row group per chunk). Each chunk carries an explicit half-open row-group
|
||||
range, so the reader slices to exactly those row groups with no
|
||||
estimation or read-time reconciliation, and the listing stage never
|
||||
produces empty read tasks.
|
||||
|
||||
The row group is Parquet's atomic read unit, so a chunk can never be
|
||||
smaller than a single row group. With the default target (which falls
|
||||
back to ``DataContext.target_min_block_size``), a file's row groups map
|
||||
1:1 to chunks unless they are smaller than the target, in which case
|
||||
consecutive small row groups are bundled to avoid an excessive number of
|
||||
tiny chunks.
|
||||
"""
|
||||
|
||||
# Hard fallback used only when neither an explicit target nor the
|
||||
# DataContext size knobs are set.
|
||||
_FALLBACK_TARGET_CHUNK_SIZE = 1 * MiB
|
||||
|
||||
# Footer reads are file I/O — let the indexer parallelize them.
|
||||
reads_file_metadata: bool = True
|
||||
|
||||
def __init__(self, target_chunk_size: Optional[int] = None):
|
||||
from ray.data.context import DataContext
|
||||
|
||||
ctx = DataContext.get_current()
|
||||
# Resolve with explicit ``is not None`` checks rather than ``or`` so an
|
||||
# explicit ``0`` (e.g. to force one row group per chunk) isn't treated as
|
||||
# "unset" and silently overridden by a falsy-coalescing fallback.
|
||||
if target_chunk_size is not None:
|
||||
self._target_chunk_size = target_chunk_size
|
||||
elif ctx.parquet_chunker_target_chunk_size is not None:
|
||||
self._target_chunk_size = ctx.parquet_chunker_target_chunk_size
|
||||
elif ctx.target_min_block_size is not None:
|
||||
self._target_chunk_size = ctx.target_min_block_size
|
||||
else:
|
||||
self._target_chunk_size = self._FALLBACK_TARGET_CHUNK_SIZE
|
||||
|
||||
def generate_chunk_metadatas(
|
||||
self,
|
||||
path: str,
|
||||
file_size: int,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
) -> Iterable[Tuple[Optional[ChunkMetadata], int]]:
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
try:
|
||||
# Reads only the Parquet footer (file metadata), not data.
|
||||
metadata = pq.read_metadata(path, filesystem=filesystem)
|
||||
except Exception as e:
|
||||
# Corrupt / unreadable footer (or a non-Parquet file that slipped
|
||||
# through). Fall back to a single whole-file chunk so the file is
|
||||
# still read rather than dropped.
|
||||
logger.debug(
|
||||
"Could not read Parquet footer for chunking (%s): %s; "
|
||||
"falling back to a whole-file chunk.",
|
||||
path,
|
||||
e,
|
||||
)
|
||||
yield None, file_size
|
||||
return
|
||||
|
||||
num_row_groups = metadata.num_row_groups
|
||||
if num_row_groups == 0:
|
||||
yield None, file_size
|
||||
return
|
||||
|
||||
# Greedily bundle consecutive row groups until the running on-disk
|
||||
# size reaches the target. Always emit at least one row group per
|
||||
# chunk (the atomic read unit).
|
||||
start = 0
|
||||
running_size = 0
|
||||
for rg_idx in range(num_row_groups):
|
||||
rg_meta = metadata.row_group(rg_idx)
|
||||
# On-disk (compressed) row-group size. ``RowGroupMetaData`` exposes
|
||||
# only the *uncompressed* ``total_byte_size``; the on-disk size lives
|
||||
# on each ``ColumnChunkMetaData``, so sum the per-column compressed
|
||||
# sizes. Keeping chunk sizes in on-disk units matches the manifest's
|
||||
# ``file_sizes`` and the ``×encoding_ratio`` in-memory estimator.
|
||||
rg_size = sum(
|
||||
rg_meta.column(c).total_compressed_size
|
||||
for c in range(rg_meta.num_columns)
|
||||
)
|
||||
if running_size > 0 and running_size + rg_size > self._target_chunk_size:
|
||||
yield (
|
||||
create_chunk_metadata(
|
||||
ParquetFileChunkMetadata,
|
||||
row_group_start=start,
|
||||
row_group_end=rg_idx,
|
||||
),
|
||||
running_size,
|
||||
)
|
||||
start = rg_idx
|
||||
running_size = 0
|
||||
running_size += rg_size
|
||||
|
||||
# Flush the final bundle.
|
||||
yield (
|
||||
create_chunk_metadata(
|
||||
ParquetFileChunkMetadata,
|
||||
row_group_start=start,
|
||||
row_group_end=num_row_groups,
|
||||
),
|
||||
running_size,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Parquet file-level chunking helpers for DataSourceV2.
|
||||
|
||||
Maps planner chunk metadata (``ParquetFileChunkMetadata``) to PyArrow
|
||||
``ParquetFileFragment`` subsets for parallel reads. Chunk metadata carries
|
||||
an explicit half-open row-group range computed at listing time from the
|
||||
file's footer, so no estimation or reconciliation is needed here.
|
||||
"""
|
||||
from typing import List, Tuple
|
||||
|
||||
import pyarrow.dataset as pds
|
||||
|
||||
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
|
||||
ParquetFileChunkMetadata,
|
||||
)
|
||||
|
||||
|
||||
def _fragments_from_chunk_metadata(
|
||||
fragment: pds.ParquetFileFragment,
|
||||
chunk_metadata: ParquetFileChunkMetadata,
|
||||
) -> List[Tuple[pds.ParquetFileFragment, int]]:
|
||||
"""Slice ``fragment`` into per-row-group sub-fragments for the chunk's range.
|
||||
|
||||
The chunk carries an explicit ``[row_group_start, row_group_end)`` range.
|
||||
Returns one ``(ParquetFileFragment, file_row_offset)`` pair per row group
|
||||
in that range, where ``file_row_offset`` is the sum of ``num_rows`` across
|
||||
all row groups that precede the sub-fragment in the underlying file.
|
||||
Callers seed per-fragment hashing offsets with this value so sub-fragments
|
||||
of the same file don't collide on ``(path, 0, n)``.
|
||||
|
||||
The range is defensively clamped to the file's actual row-group count;
|
||||
since ranges are computed from the same footer the reader sees, the clamp
|
||||
is a no-op in practice and never drops real row groups.
|
||||
"""
|
||||
start = chunk_metadata["row_group_start"]
|
||||
end = chunk_metadata["row_group_end"]
|
||||
metadata = fragment.metadata
|
||||
total_row_groups = metadata.num_row_groups
|
||||
|
||||
start = min(start, total_row_groups)
|
||||
end = min(end, total_row_groups)
|
||||
|
||||
file_row_offset = sum(metadata.row_group(i).num_rows for i in range(start))
|
||||
sub_fragments: List[Tuple[pds.ParquetFileFragment, int]] = []
|
||||
for row_group_index in range(start, end):
|
||||
sub_fragments.append(
|
||||
(fragment.subset(row_group_ids=[row_group_index]), file_row_offset)
|
||||
)
|
||||
file_row_offset += metadata.row_group(row_group_index).num_rows
|
||||
return sub_fragments
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
DataSourceV2 API - Unified Abstraction for Reading Data Sources
|
||||
|
||||
This module defines a unified, extensible API for reading data from diverse sources
|
||||
in Ray Data. The API provides a common abstraction layer that enables datasources to
|
||||
declaratively expose their capabilities—such as filter pushdown, projection pruning,
|
||||
and parallel reads—while allowing the execution engine to leverage these capabilities
|
||||
transparently.
|
||||
|
||||
Core Principles:
|
||||
- Modularity: Separate concerns (indexing, scanning, reading)
|
||||
- Expressivity: Declarative capability exposure via mixins
|
||||
- Extensibility: Easy to add new datasources with custom optimizations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Generic,
|
||||
Optional,
|
||||
)
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.datasource_v2 import InputSplit
|
||||
from ray.data._internal.datasource_v2.listing.file_indexer import FileIndexer
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyarrow.fs import FileSystem
|
||||
|
||||
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
|
||||
InMemorySizeEstimator,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DatasourceCategory(Enum):
|
||||
"""Categories of datasources with different capability profiles.
|
||||
|
||||
Each category has a distinct set of applicable optimizations:
|
||||
- FILE_BASED: Local/cloud files (parquet, csv, json, images)
|
||||
- DATABASE: SQL databases (postgres, mysql, snowflake)
|
||||
- DATA_LAKE: Table formats (iceberg, delta, hudi)
|
||||
- IN_MEMORY: In-process data (pandas, numpy, arrow)
|
||||
- SYNTHETIC: Generated data (range, range_tensor)
|
||||
- STREAMING: Unbounded sources (kafka, kinesis)
|
||||
"""
|
||||
|
||||
FILE_BASED = "file_based"
|
||||
DATABASE = "database"
|
||||
DATA_LAKE = "data_lake"
|
||||
IN_MEMORY = "in_memory"
|
||||
SYNTHETIC = "synthetic"
|
||||
STREAMING = "streaming"
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class DataSourceV2(ABC, Generic[InputSplit]):
|
||||
"""Abstract base class for V2 datasources.
|
||||
|
||||
DataSourceV2 is the entry point for reading data from a source. It provides:
|
||||
1. File listing (for file-based sources) - via _get_file_indexer()
|
||||
2. Schema inference
|
||||
3. Size estimation
|
||||
4. Scanner creation
|
||||
|
||||
Subclasses should implement the abstract methods and can optionally
|
||||
override _get_file_indexer() and get_size_estimator() for file-based sources.
|
||||
|
||||
Example::
|
||||
|
||||
datasource = ParquetDatasourceV2()
|
||||
indexer = datasource._get_file_indexer()
|
||||
# List files with optional sampling
|
||||
for manifest in indexer.list_files(paths, filesystem=fs):
|
||||
schema = datasource.infer_schema(manifest)
|
||||
break # Just need first manifest for schema
|
||||
scanner = datasource.create_scanner(schema)
|
||||
scanner = scanner.prune_columns(["col1", "col2"])
|
||||
reader = scanner.create_reader()
|
||||
for table in reader.read(manifest):
|
||||
process(table)
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, category: DatasourceCategory):
|
||||
"""Initialize the datasource.
|
||||
|
||||
Args:
|
||||
name: Human-readable name for this datasource.
|
||||
category: Category of this datasource.
|
||||
"""
|
||||
self._name = name
|
||||
self._category = category
|
||||
# File-based subclasses set this to ``False`` in their ``__init__``
|
||||
# when the user-supplied paths are in the ``local://`` scheme —
|
||||
# the driver node is the only one that can read those files.
|
||||
# ``_read_datasource_v2`` consults the flag to decide whether to
|
||||
# pin read tasks via a ``label_selector``.
|
||||
self._supports_distributed_reads: bool = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Human-readable name for this datasource."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def category(self) -> DatasourceCategory:
|
||||
"""Category of this datasource."""
|
||||
return self._category
|
||||
|
||||
@property
|
||||
def supports_distributed_reads(self) -> bool:
|
||||
"""Whether read tasks may run on any cluster node.
|
||||
|
||||
Defaults to ``True``. File-based subclasses (e.g.
|
||||
:class:`ParquetDatasourceV2`) flip this to ``False`` when the
|
||||
user supplies ``local://``-scheme paths so ``_read_datasource_v2``
|
||||
can pin reads to the driver node via a ``ray.io/node-id``
|
||||
label selector. Mirrors V1 ``Datasource.supports_distributed_reads``.
|
||||
"""
|
||||
return self._supports_distributed_reads
|
||||
|
||||
def _get_file_indexer(self) -> Optional[FileIndexer]:
|
||||
"""Return FileIndexer component if applicable.
|
||||
|
||||
Override this for file-based datasources to provide file discovery.
|
||||
|
||||
Returns:
|
||||
FileIndexer instance, or None for non-file-based sources.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_size_estimator(self) -> Optional[InMemorySizeEstimator]:
|
||||
"""Return size estimator for this datasource.
|
||||
|
||||
Override this to provide format-specific size estimation.
|
||||
|
||||
Returns:
|
||||
InMemorySizeEstimator instance, or None if not supported.
|
||||
"""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def infer_schema(self, sample: InputSplit) -> pa.Schema:
|
||||
"""Infer schema from a sample of data.
|
||||
|
||||
Args:
|
||||
sample: Sample data to infer schema from.
|
||||
|
||||
Returns:
|
||||
PyArrow Schema inferred from the sample.
|
||||
|
||||
Raises:
|
||||
ValueError: If schema cannot be inferred from the sample.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def create_scanner(
|
||||
self,
|
||||
schema: pa.Schema,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
**options: Any,
|
||||
) -> Scanner[InputSplit]:
|
||||
"""Create a Scanner for reading data.
|
||||
|
||||
Args:
|
||||
schema: Schema for the data to read.
|
||||
filesystem: Optional filesystem for file-based sources.
|
||||
**options: Additional datasource-specific options.
|
||||
|
||||
Returns:
|
||||
Configured Scanner instance.
|
||||
"""
|
||||
...
|
||||
|
||||
def resolve_partitioning(self, sample: InputSplit) -> Optional[Any]:
|
||||
"""Return a partitioning descriptor derived from ``sample``, or ``None``.
|
||||
|
||||
Override this for file-based sources whose partition keys must be
|
||||
discovered from a sample path (e.g. hive layouts where field names
|
||||
are not known up front). The resolved descriptor is passed into
|
||||
:meth:`create_scanner`.
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,376 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, Iterator, List, Optional, Tuple, Union
|
||||
|
||||
from pyarrow.fs import FileSystem
|
||||
|
||||
from ray._common.utils import env_integer
|
||||
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
|
||||
ChunkMetadata,
|
||||
FileChunker,
|
||||
WholeFileChunker,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.listing.file_pruners import FilePruner
|
||||
from ray.data._internal.datasource_v2.listing.indexing_utils import (
|
||||
_get_file_infos,
|
||||
_get_path_contents,
|
||||
)
|
||||
from ray.data._internal.dynamic_work_queue import parallel_process_work_stealing
|
||||
from ray.data._internal.util import make_async_gen
|
||||
from ray.data.block import BlockColumn
|
||||
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileIndexer(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def file_chunker(self) -> FileChunker:
|
||||
"""The file chunker that this indexer uses."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def list_files(
|
||||
self,
|
||||
paths: "BlockColumn",
|
||||
*,
|
||||
filesystem: "FileSystem",
|
||||
pruners: Optional[List[FilePruner]] = None,
|
||||
preserve_order: bool = False,
|
||||
) -> Iterable[FileManifest]:
|
||||
"""List files and their on-disk sizes for the given path.
|
||||
|
||||
Args:
|
||||
paths: A column of paths pointing to files or directories.
|
||||
filesystem: A PyArrow filesystem object.
|
||||
pruners: A list of file pruners to apply.
|
||||
preserve_order: Whether to preserve order in file listing.
|
||||
|
||||
Returns:
|
||||
An iterator of `FileManifest` objects, each of which contains a file path
|
||||
and the on-disk size of the file in bytes.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileInfo:
|
||||
"""File information for file listing."""
|
||||
|
||||
path: str
|
||||
size: Optional[int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TraversalWorkItem:
|
||||
"""Work item for parallel directory traversal. Distinguishes seed paths
|
||||
(user-provided, need resolution) from subdir paths (from filesystem listing,
|
||||
use directly to avoid redundant resolution that breaks non-local
|
||||
filesystems)."""
|
||||
|
||||
# Could be a file path or a directory path.
|
||||
path: str
|
||||
# True for subdirectories discovered during traversal; False for seed input paths.
|
||||
is_discovered_subdir: bool = False
|
||||
# Original seed-path index used to restore deterministic ordering when requested.
|
||||
input_path_index: Optional[int] = None
|
||||
# Top-level path the traversal started from, used to scope hidden-prefix
|
||||
# exclusion to entries whose path relative to the root is hidden.
|
||||
root_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderedFileResult:
|
||||
"""File result with order information for sorting when preserve_order is True."""
|
||||
|
||||
input_path_index: int
|
||||
# The leaf file path.
|
||||
file_path: str
|
||||
file_info: FileInfo
|
||||
|
||||
|
||||
class NonSamplingFileIndexer(FileIndexer):
|
||||
"""A file indexer that exhaustively lists files.
|
||||
|
||||
This implementation works with paths that point to files or directories,
|
||||
although it's slow if you try to list lots of paths pointing to files
|
||||
rather than a single directory.
|
||||
"""
|
||||
|
||||
_DEFAULT_MAX_PATHS_PER_OUTPUT = env_integer(
|
||||
"RAY_DATA_MAX_PATHS_PER_LIST_FILES_OUTPUT", 1000
|
||||
)
|
||||
|
||||
_DEFAULT_NUM_WORKERS = env_integer("RAY_DATA_LIST_FILES_THREADED_NUM_WORKERS", 8)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ignore_missing_paths: bool,
|
||||
num_workers: Optional[int] = None,
|
||||
max_paths_per_output: Optional[int] = None,
|
||||
file_chunker: Optional[FileChunker] = None,
|
||||
):
|
||||
self._ignore_missing_paths = ignore_missing_paths
|
||||
self._max_paths_per_output = (
|
||||
max_paths_per_output
|
||||
if max_paths_per_output is not None
|
||||
else self._DEFAULT_MAX_PATHS_PER_OUTPUT
|
||||
)
|
||||
self._num_workers = (
|
||||
num_workers if num_workers is not None else self._DEFAULT_NUM_WORKERS
|
||||
)
|
||||
self._queue_size_per_thread = env_integer(
|
||||
"RAY_DATA_LIST_FILES_QUEUE_SIZE_PER_THREAD",
|
||||
self._max_paths_per_output * 4,
|
||||
)
|
||||
self._file_chunker: FileChunker = (
|
||||
file_chunker if file_chunker is not None else WholeFileChunker()
|
||||
)
|
||||
|
||||
@property
|
||||
def file_chunker(self) -> FileChunker:
|
||||
"""The file chunker that this indexer uses.
|
||||
|
||||
Exposed primarily for tests and shuffle-aware planning code that needs
|
||||
to introspect or override the chunking strategy.
|
||||
"""
|
||||
return self._file_chunker
|
||||
|
||||
def list_files(
|
||||
self,
|
||||
paths: "BlockColumn",
|
||||
*,
|
||||
filesystem: "FileSystem",
|
||||
pruners: Optional[List[FilePruner]] = None,
|
||||
preserve_order: bool = False,
|
||||
) -> Iterable[FileManifest]:
|
||||
file_info_iterator = (
|
||||
self._get_file_info_iterator_threaded(paths, filesystem, preserve_order)
|
||||
if self._num_workers > 1
|
||||
else self._get_file_info_iterator_sequential(paths, filesystem)
|
||||
)
|
||||
|
||||
# Stage pipeline: list → prune (cheap, inline) → chunk (may read
|
||||
# per-file metadata) → batch into manifests. Pruning runs *before*
|
||||
# chunking so we never read a footer for a file we'd discard.
|
||||
pruned = self._filter_file_infos(file_info_iterator, pruners or [])
|
||||
chunk_records = self._generate_chunk_records(pruned, filesystem, preserve_order)
|
||||
yield from self._batch_chunk_records_to_manifests(chunk_records)
|
||||
|
||||
def _get_file_info_iterator_sequential(
|
||||
self,
|
||||
paths: "BlockColumn",
|
||||
filesystem: "FileSystem",
|
||||
) -> Iterable[FileInfo]:
|
||||
for input_path in paths.to_pylist():
|
||||
resolved_paths, _ = _resolve_paths_and_filesystem(input_path, filesystem)
|
||||
assert len(resolved_paths) == 1
|
||||
|
||||
for path, file_size in _get_file_infos(
|
||||
resolved_paths[0], filesystem, self._ignore_missing_paths
|
||||
):
|
||||
yield FileInfo(path=path, size=file_size)
|
||||
|
||||
def _get_file_info_iterator_threaded(
|
||||
self,
|
||||
paths: "BlockColumn",
|
||||
filesystem: "FileSystem",
|
||||
preserve_order: bool = False,
|
||||
) -> Iterable[FileInfo]:
|
||||
"""Threaded file info iterator with work stealing for parallel directory
|
||||
traversal. Subdirectories are added as work items for idle workers to
|
||||
process."""
|
||||
paths_list = paths.to_pylist()
|
||||
if len(paths_list) == 0:
|
||||
return
|
||||
|
||||
num_workers = self._num_workers
|
||||
|
||||
seed_items = [
|
||||
_TraversalWorkItem(
|
||||
path=p,
|
||||
is_discovered_subdir=False,
|
||||
input_path_index=i if preserve_order else None,
|
||||
)
|
||||
for i, p in enumerate(paths_list)
|
||||
]
|
||||
|
||||
def process_fn(
|
||||
item: _TraversalWorkItem,
|
||||
add_work: Callable[[_TraversalWorkItem], None],
|
||||
add_result: Callable[[Union[OrderedFileResult, FileInfo]], None],
|
||||
) -> None:
|
||||
"""Process a single item, adding discovered subdirs as work and
|
||||
files as results."""
|
||||
input_path_index = item.input_path_index
|
||||
|
||||
if item.is_discovered_subdir:
|
||||
# Subdir paths from filesystem listing: use directly. Re-resolution
|
||||
# would infer LocalFileSystem for scheme-less paths on S3/GCS,
|
||||
# and add redundant overhead.
|
||||
path = item.path
|
||||
root_path = item.root_path
|
||||
else:
|
||||
# Seed paths from user: resolve once to get normalized path + fs.
|
||||
resolved_paths, _ = _resolve_paths_and_filesystem(item.path, filesystem)
|
||||
assert len(resolved_paths) == 1
|
||||
path = resolved_paths[0]
|
||||
root_path = path
|
||||
|
||||
contents = _get_path_contents(
|
||||
path, filesystem, self._ignore_missing_paths, root_path=root_path
|
||||
)
|
||||
for file_path, file_size in contents.files:
|
||||
file_info_result = FileInfo(path=file_path, size=file_size)
|
||||
if preserve_order:
|
||||
add_result(
|
||||
OrderedFileResult(
|
||||
input_path_index=input_path_index,
|
||||
file_path=file_path,
|
||||
file_info=file_info_result,
|
||||
)
|
||||
)
|
||||
else:
|
||||
add_result(file_info_result)
|
||||
for subdir_path in contents.subdirs:
|
||||
add_work(
|
||||
_TraversalWorkItem(
|
||||
path=subdir_path,
|
||||
is_discovered_subdir=True,
|
||||
input_path_index=input_path_index,
|
||||
root_path=root_path,
|
||||
)
|
||||
)
|
||||
|
||||
def _ordered_result_key(result: OrderedFileResult) -> Tuple[int, str]:
|
||||
return (result.input_path_index, result.file_path)
|
||||
|
||||
if preserve_order:
|
||||
for result in parallel_process_work_stealing(
|
||||
seed_items=seed_items,
|
||||
process_fn=process_fn,
|
||||
num_workers=num_workers,
|
||||
preserve_order=True,
|
||||
order_key=_ordered_result_key,
|
||||
):
|
||||
# Ordered mode returns `OrderedFileResult` for sorting, so unwrap.
|
||||
yield result.file_info
|
||||
else:
|
||||
yield from parallel_process_work_stealing(
|
||||
seed_items=seed_items,
|
||||
process_fn=process_fn,
|
||||
num_workers=num_workers,
|
||||
)
|
||||
|
||||
def _filter_file_infos(
|
||||
self,
|
||||
file_infos: Iterable[FileInfo],
|
||||
pruners: List[FilePruner],
|
||||
) -> Iterator[FileInfo]:
|
||||
"""Drop zero-size and pruned files before any per-file metadata read."""
|
||||
for file_info in file_infos:
|
||||
if file_info.size is None or file_info.size == 0:
|
||||
logger.warning(f"Skipping zero-size file: {file_info.path!r}")
|
||||
continue
|
||||
if not all(pruner.should_include(file_info.path) for pruner in pruners):
|
||||
continue
|
||||
yield file_info
|
||||
|
||||
def _generate_chunk_records(
|
||||
self,
|
||||
file_infos: Iterable[FileInfo],
|
||||
filesystem: "FileSystem",
|
||||
preserve_order: bool,
|
||||
) -> Iterator[Tuple[str, int, Optional[ChunkMetadata]]]:
|
||||
"""Drive the chunker per file, yielding ``(path, chunk_size, metadata)``.
|
||||
|
||||
When the chunker reads per-file metadata (e.g. ``ParquetFileChunker``
|
||||
reading footers), fan the work across the indexer's thread pool so the
|
||||
I/O parallelizes even for a single input directory — ``make_async_gen``
|
||||
over the *discovered files*, not the input paths. Chunkers that don't
|
||||
read metadata (whole-file / line-delimited) are driven inline to avoid
|
||||
a pointless thread hand-off.
|
||||
"""
|
||||
chunker = self._file_chunker
|
||||
|
||||
def chunk_file(
|
||||
fi: FileInfo,
|
||||
) -> List[Tuple[str, int, Optional[ChunkMetadata]]]:
|
||||
return [
|
||||
(fi.path, chunk_size, chunk_metadata)
|
||||
for chunk_metadata, chunk_size in chunker.generate_chunk_metadatas(
|
||||
fi.path, fi.size, filesystem
|
||||
)
|
||||
]
|
||||
|
||||
if chunker.reads_file_metadata and self._num_workers > 1:
|
||||
# Fan per-file footer reads across the thread pool. ``make_async_gen``
|
||||
# only preserves ordering for a 1:1 map (one output per input item), so
|
||||
# emit ONE record list per file and flatten here. Yielding chunk rows
|
||||
# individually would let its round-robin merge interleave chunks from
|
||||
# the files processed concurrently -- breaking per-file contiguity and
|
||||
# discovery order under ``preserve_order=True``.
|
||||
def chunk_files(
|
||||
infos: Iterator[FileInfo],
|
||||
) -> Iterator[List[Tuple[str, int, Optional[ChunkMetadata]]]]:
|
||||
for fi in infos:
|
||||
yield chunk_file(fi)
|
||||
|
||||
for records in make_async_gen(
|
||||
# ``iter(...)`` so a non-iterator iterable (e.g. a list from a
|
||||
# test or subclass) is still consumed correctly by the helper.
|
||||
base_iterator=iter(file_infos),
|
||||
fn=chunk_files,
|
||||
preserve_ordering=preserve_order,
|
||||
num_workers=self._num_workers,
|
||||
buffer_size=self._queue_size_per_thread,
|
||||
):
|
||||
yield from records
|
||||
else:
|
||||
for fi in file_infos:
|
||||
yield from chunk_file(fi)
|
||||
|
||||
def _batch_chunk_records_to_manifests(
|
||||
self,
|
||||
chunk_records: Iterable[Tuple[str, int, Optional[ChunkMetadata]]],
|
||||
) -> Iterable[FileManifest]:
|
||||
"""Batch chunk records into ``FileManifest`` blocks of bounded size."""
|
||||
running_paths: List[str] = []
|
||||
running_file_sizes: List[int] = []
|
||||
running_chunk_metadatas: List[Optional[ChunkMetadata]] = []
|
||||
manifests_count = 0
|
||||
chunks_count = 0
|
||||
|
||||
for path, chunk_size, chunk_metadata in chunk_records:
|
||||
running_paths.append(path)
|
||||
running_file_sizes.append(chunk_size)
|
||||
running_chunk_metadatas.append(chunk_metadata)
|
||||
chunks_count += 1
|
||||
|
||||
if len(running_paths) >= self._max_paths_per_output:
|
||||
manifests_count += 1
|
||||
yield FileManifest.construct_manifest(
|
||||
running_paths,
|
||||
running_file_sizes,
|
||||
running_chunk_metadatas,
|
||||
)
|
||||
running_paths = []
|
||||
running_file_sizes = []
|
||||
running_chunk_metadatas = []
|
||||
|
||||
if running_paths:
|
||||
manifests_count += 1
|
||||
yield FileManifest.construct_manifest(
|
||||
running_paths,
|
||||
running_file_sizes,
|
||||
running_chunk_metadatas,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Listing files: constructed {manifests_count} manifests "
|
||||
f"with {chunks_count} file chunks"
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
from functools import cached_property
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.datasource_v2.chunkers.file_chunker import ChunkMetadata
|
||||
from ray.data.block import Block, BlockAccessor, BlockColumnAccessor
|
||||
|
||||
# File manifest column names
|
||||
PATH_COLUMN_NAME = "__path"
|
||||
FILE_SIZE_COLUMN_NAME = "__file_size"
|
||||
FILE_CHUNK_METADATA_COLUMN_NAME = "__file_chunk_metadata"
|
||||
|
||||
|
||||
class FileManifest:
|
||||
"""Structured view over file paths, sizes, and per-chunk metadata.
|
||||
|
||||
Provides structured access to file paths, sizes, and chunk metadata. This avoids
|
||||
making implicit assumptions about block structure as data moves between file
|
||||
listing, partitioning, and reading stages.
|
||||
|
||||
All extracted views (i.e., `paths`, `file_sizes`, `file_chunk_metadatas`) share
|
||||
the same row order as the underlying block. Any transformation must preserve this.
|
||||
|
||||
Each row represents a single chunk of a file. For unchunked files (whole-file
|
||||
reads), the chunk-metadata entry is ``None`` and ``file_sizes`` equals the
|
||||
on-disk file size. For chunked files, multiple rows can share the same path
|
||||
but carry different chunk metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, block: Block):
|
||||
"""Create a new `FileManifest` from a block.
|
||||
|
||||
Args:
|
||||
block: Block with `PATH_COLUMN_NAME`, `FILE_SIZE_COLUMN_NAME`, and
|
||||
`FILE_CHUNK_METADATA_COLUMN_NAME` columns. Any other columns are
|
||||
optional and treated as input data.
|
||||
"""
|
||||
column_names = BlockAccessor.for_block(block).column_names()
|
||||
assert FILE_SIZE_COLUMN_NAME in column_names
|
||||
assert PATH_COLUMN_NAME in column_names
|
||||
assert FILE_CHUNK_METADATA_COLUMN_NAME in column_names
|
||||
|
||||
self._block = block
|
||||
|
||||
self._paths = block[PATH_COLUMN_NAME]
|
||||
self._file_sizes = block[FILE_SIZE_COLUMN_NAME]
|
||||
self._file_chunk_metadatas = block[FILE_CHUNK_METADATA_COLUMN_NAME]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._block)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} length={len(self._block)}>"
|
||||
|
||||
# TODO Use arrow arrays instead of numpy for these properties.
|
||||
@cached_property
|
||||
def paths(self) -> np.ndarray:
|
||||
return BlockColumnAccessor.for_column(self._paths).to_numpy()
|
||||
|
||||
@cached_property
|
||||
def file_sizes(self) -> np.ndarray:
|
||||
return BlockColumnAccessor.for_column(self._file_sizes).to_numpy()
|
||||
|
||||
@cached_property
|
||||
def file_chunk_metadatas(self) -> np.ndarray:
|
||||
return BlockColumnAccessor.for_column(self._file_chunk_metadatas).to_numpy()
|
||||
|
||||
def as_block(self) -> Block:
|
||||
"""Return the underlying block for the `FileManifest`.
|
||||
|
||||
This doesn't make a copy of the underlying data.
|
||||
"""
|
||||
return self._block
|
||||
|
||||
@classmethod
|
||||
def concat(cls, manifests: List["FileManifest"]) -> "FileManifest":
|
||||
"""Return a new `FileManifest` whose rows are the concatenation of
|
||||
``manifests`` in order.
|
||||
|
||||
Row alignment of ``paths`` / ``file_sizes`` is preserved because
|
||||
each input already satisfies it.
|
||||
"""
|
||||
assert len(manifests) > 0, "concat requires at least one manifest"
|
||||
if len(manifests) == 1:
|
||||
return manifests[0]
|
||||
|
||||
merged = pa.concat_tables(
|
||||
[
|
||||
BlockAccessor.for_block(manifest._block).to_arrow()
|
||||
for manifest in manifests
|
||||
]
|
||||
)
|
||||
return cls(merged)
|
||||
|
||||
def shuffle(self, seed: Optional[int]) -> "FileManifest":
|
||||
"""Return a new `FileManifest` with rows permuted.
|
||||
|
||||
Args:
|
||||
seed: Random seed. ``None`` for non-deterministic shuffling.
|
||||
When set, input rows are first sorted by path so the shuffle
|
||||
is reproducible regardless of upstream listing order
|
||||
(the threaded ``FileIndexer`` doesn't preserve order).
|
||||
|
||||
Returns:
|
||||
A new `FileManifest` with the same rows in a shuffled order. The
|
||||
underlying row alignment between `paths` and `file_sizes` is
|
||||
preserved because the permutation is applied to the block as a
|
||||
whole.
|
||||
"""
|
||||
n = len(self)
|
||||
if n <= 1:
|
||||
return self
|
||||
block = self._block
|
||||
if seed is not None:
|
||||
sort_indices = pa.compute.sort_indices(
|
||||
BlockAccessor.for_block(block).to_arrow(),
|
||||
sort_keys=[(PATH_COLUMN_NAME, "ascending")],
|
||||
)
|
||||
block = block.take(sort_indices)
|
||||
permutation = np.random.default_rng(seed).permutation(n)
|
||||
return FileManifest(block.take(permutation))
|
||||
|
||||
@classmethod
|
||||
def construct_manifest(
|
||||
cls,
|
||||
paths: List[str],
|
||||
sizes: List[int],
|
||||
chunk_metadatas: List[Optional[ChunkMetadata]],
|
||||
) -> "FileManifest":
|
||||
assert len(paths) == len(sizes) == len(chunk_metadatas)
|
||||
|
||||
block = pa.table(
|
||||
{
|
||||
PATH_COLUMN_NAME: paths,
|
||||
FILE_SIZE_COLUMN_NAME: sizes,
|
||||
FILE_CHUNK_METADATA_COLUMN_NAME: chunk_metadatas,
|
||||
}
|
||||
)
|
||||
return cls(block)
|
||||
@@ -0,0 +1,34 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
|
||||
from ray.data.datasource import PathPartitionFilter
|
||||
from ray.data.datasource.path_util import _has_file_extension
|
||||
|
||||
|
||||
class FilePruner(ABC):
|
||||
"""Generic file-level filter applied during listing."""
|
||||
|
||||
@abstractmethod
|
||||
def should_include(self, path: str) -> bool:
|
||||
"""Return True if this file should be included, False to skip it."""
|
||||
...
|
||||
|
||||
|
||||
class FileExtensionPruner(FilePruner):
|
||||
"""Skip files that don't match the expected extensions."""
|
||||
|
||||
def __init__(self, file_extensions: List[str]):
|
||||
self._file_extensions = file_extensions
|
||||
|
||||
def should_include(self, path: str) -> bool:
|
||||
return _has_file_extension(path, self._file_extensions)
|
||||
|
||||
|
||||
class PartitionPruner(FilePruner):
|
||||
"""Skip files based on partition column predicates (e.g., hive partitioning)."""
|
||||
|
||||
def __init__(self, partition_filter: PathPartitionFilter):
|
||||
self._filter = partition_filter
|
||||
|
||||
def should_include(self, path: str) -> bool:
|
||||
return self._filter.apply(path)
|
||||
@@ -0,0 +1,123 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
import pyarrow as pa
|
||||
from pyarrow.fs import FileSelector, FileType
|
||||
|
||||
from ray.data.datasource.file_meta_provider import _handle_read_os_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathContents:
|
||||
"""Contents of a path: files (path, size) and subdirectories to expand."""
|
||||
|
||||
files: List[Tuple[str, Optional[int]]]
|
||||
subdirs: List[str]
|
||||
|
||||
|
||||
def _expand_directory(
|
||||
base_path: str,
|
||||
filesystem: pa.fs.FileSystem,
|
||||
ignore_missing_path: bool,
|
||||
*,
|
||||
root_path: Optional[str] = None,
|
||||
) -> PathContents:
|
||||
"""List one level of a directory.
|
||||
|
||||
Hidden-prefix (``.``/``_``) exclusion is applied relative to ``root_path``
|
||||
(the top-level path the traversal started from), not the immediate parent,
|
||||
so a nested entry is only excluded when its path *relative to the root*
|
||||
begins with an excluded prefix. When ``root_path`` is ``None`` it defaults
|
||||
to ``base_path``.
|
||||
"""
|
||||
exclude_prefixes = [".", "_"]
|
||||
|
||||
if root_path is None:
|
||||
root_path = base_path
|
||||
|
||||
selector = FileSelector(
|
||||
base_path, recursive=False, allow_not_found=ignore_missing_path
|
||||
)
|
||||
children = filesystem.get_file_info(selector)
|
||||
|
||||
# Lineage reconstruction doesn't work if tasks aren't deterministic, and
|
||||
# `filesystem.get_file_info` might return files in a non-deterministic order. So, we
|
||||
# sort the files.
|
||||
assert isinstance(children, list), type(children)
|
||||
children.sort(key=lambda file_: file_.path)
|
||||
|
||||
files: List[Tuple[str, Optional[int]]] = []
|
||||
subdirs: List[str] = []
|
||||
|
||||
for child in children:
|
||||
if not child.path.startswith(root_path):
|
||||
continue
|
||||
|
||||
relative = child.path[len(root_path) :].lstrip("/")
|
||||
if any(relative.startswith(prefix) for prefix in exclude_prefixes):
|
||||
continue
|
||||
|
||||
if child.type == FileType.File:
|
||||
files.append((child.path, child.size))
|
||||
elif child.type == FileType.Directory:
|
||||
subdirs.append(child.path)
|
||||
elif child.type == FileType.UNKNOWN:
|
||||
logger.warning(f"Discovered file with unknown type: '{child.path}'")
|
||||
continue
|
||||
else:
|
||||
assert child.type == FileType.NotFound
|
||||
raise FileNotFoundError(child.path)
|
||||
|
||||
return PathContents(files=files, subdirs=subdirs)
|
||||
|
||||
|
||||
def _get_path_contents(
|
||||
path: str,
|
||||
filesystem: pa.fs.FileSystem,
|
||||
ignore_missing_path: bool,
|
||||
*,
|
||||
root_path: Optional[str] = None,
|
||||
) -> PathContents:
|
||||
"""Get files and subdirs for a path. Handles File, Directory, and NotFound.
|
||||
|
||||
Only one level of a directory is expanded; discovered subdirectories are
|
||||
returned in :attr:`PathContents.subdirs` for the caller to expand.
|
||||
"""
|
||||
try:
|
||||
file_info = filesystem.get_file_info(path)
|
||||
except OSError as e:
|
||||
_handle_read_os_error(e, path)
|
||||
|
||||
if file_info.type == FileType.File:
|
||||
return PathContents(files=[(path, file_info.size)], subdirs=[])
|
||||
elif file_info.type == FileType.Directory:
|
||||
return _expand_directory(
|
||||
path, filesystem, ignore_missing_path, root_path=root_path
|
||||
)
|
||||
elif file_info.type == FileType.NotFound and ignore_missing_path:
|
||||
return PathContents(files=[], subdirs=[])
|
||||
else:
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
|
||||
def _get_file_infos(
|
||||
path: str,
|
||||
filesystem: pa.fs.FileSystem,
|
||||
ignore_missing_path: bool,
|
||||
*,
|
||||
_root_path: Optional[str] = None,
|
||||
) -> Iterable[Tuple[str, Optional[int]]]:
|
||||
"""Recursively expand a path (file or directory) into ``(path, size)`` tuples."""
|
||||
if _root_path is None:
|
||||
_root_path = path
|
||||
contents = _get_path_contents(
|
||||
path, filesystem, ignore_missing_path, root_path=_root_path
|
||||
)
|
||||
yield from contents.files
|
||||
for subdir in contents.subdirs:
|
||||
yield from _get_file_infos(
|
||||
subdir, filesystem, ignore_missing_path, _root_path=_root_path
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
from typing import TYPE_CHECKING, Iterable, List, Optional
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import (
|
||||
PATH_COLUMN_NAME,
|
||||
FileManifest,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.file_pruners import (
|
||||
FileExtensionPruner,
|
||||
FilePruner,
|
||||
PartitionPruner,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.partitioners.file_partitioner import (
|
||||
FilePartitioner,
|
||||
)
|
||||
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
|
||||
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
||||
from ray.data.block import Block, BlockAccessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyarrow.fs import FileSystem
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_indexer import FileIndexer
|
||||
from ray.data.datasource.file_based_datasource import FileShuffleConfig
|
||||
from ray.data.datasource.partitioning import PathPartitionFilter
|
||||
|
||||
|
||||
def partition_files(
|
||||
blocks: Iterable[Block],
|
||||
_: TaskContext,
|
||||
partitioner: FilePartitioner,
|
||||
) -> Iterable[Block]:
|
||||
for block in blocks:
|
||||
partitioner.add_input(FileManifest(block))
|
||||
while partitioner.has_partition():
|
||||
yield partitioner.next_partition().as_block()
|
||||
|
||||
partitioner.finalize()
|
||||
while partitioner.has_partition():
|
||||
yield partitioner.next_partition().as_block()
|
||||
|
||||
|
||||
def _build_pruners(
|
||||
file_extensions: Optional[List[str]],
|
||||
partition_filter: Optional["PathPartitionFilter"],
|
||||
) -> List[FilePruner]:
|
||||
pruners: List[FilePruner] = []
|
||||
if file_extensions is not None:
|
||||
pruners.append(FileExtensionPruner(file_extensions))
|
||||
if partition_filter is not None:
|
||||
pruners.append(PartitionPruner(partition_filter))
|
||||
return pruners
|
||||
|
||||
|
||||
def list_files_for_each_block(
|
||||
blocks: Iterable[Block],
|
||||
_: TaskContext,
|
||||
*,
|
||||
indexer: "FileIndexer",
|
||||
filesystem: "FileSystem",
|
||||
file_extensions: Optional[List[str]] = None,
|
||||
partition_filter: Optional["PathPartitionFilter"] = None,
|
||||
preserve_order: bool = False,
|
||||
) -> Iterable[Block]:
|
||||
"""Expand path blocks into ``FileManifest`` blocks.
|
||||
|
||||
Each input block carries a single ``__path`` column of path strings.
|
||||
For every path, the indexer is invoked to produce a stream of
|
||||
``FileManifest`` objects; each manifest's backing block is yielded.
|
||||
Pruners are constructed once per task from ``file_extensions`` /
|
||||
``partition_filter`` — keeps pruner construction out of the
|
||||
``_read_datasource_v2`` entry point.
|
||||
"""
|
||||
pruners = _build_pruners(file_extensions, partition_filter)
|
||||
for block in blocks:
|
||||
for manifest in indexer.list_files(
|
||||
block[PATH_COLUMN_NAME],
|
||||
filesystem=filesystem,
|
||||
pruners=pruners,
|
||||
preserve_order=preserve_order,
|
||||
):
|
||||
if len(manifest) > 0:
|
||||
yield manifest.as_block()
|
||||
|
||||
|
||||
def shuffle_files(
|
||||
blocks: Iterable[Block],
|
||||
_: TaskContext,
|
||||
*,
|
||||
shuffle_config: "FileShuffleConfig",
|
||||
execution_idx: int,
|
||||
) -> Iterable[Block]:
|
||||
"""Concatenate manifest blocks and shuffle rows with the seeded RNG.
|
||||
|
||||
Runs in a single task (`plan_list_files_op` sets `should_parallelize=False`
|
||||
when shuffle is requested) so we have the full manifest before
|
||||
shuffling. Emits one merged manifest block. Determinism comes from
|
||||
``FileManifest.shuffle`` which sorts by path before applying the
|
||||
permutation — this protects against non-deterministic upstream
|
||||
indexer yield order.
|
||||
"""
|
||||
builder = DelegatingBlockBuilder()
|
||||
for block in blocks:
|
||||
if len(block) > 0:
|
||||
builder.add_block(block)
|
||||
|
||||
combined = builder.build()
|
||||
if len(combined) == 0:
|
||||
return
|
||||
|
||||
seed = shuffle_config.get_seed(execution_idx)
|
||||
yield FileManifest(combined).shuffle(seed).as_block()
|
||||
|
||||
|
||||
def sample_files(
|
||||
indexer: "FileIndexer",
|
||||
paths: List[str],
|
||||
filesystem: "FileSystem",
|
||||
pruners: Optional[List[FilePruner]] = None,
|
||||
max_files: int = 16,
|
||||
) -> FileManifest:
|
||||
"""Drive the indexer until up to ``max_files`` files arrive; return them.
|
||||
|
||||
Used for driver-side schema inference in ``_read_datasource_v2``.
|
||||
Sampling more than one file lets callers unify schemas (e.g., if the
|
||||
first file has an all-null column, later files' non-null types can
|
||||
promote it). No caching — the returned manifest is discarded after
|
||||
schema inference, and the ``ListFiles`` op lists the same paths
|
||||
again on workers at execution time.
|
||||
"""
|
||||
assert max_files >= 1
|
||||
paths_column = pa.array(paths, type=pa.string())
|
||||
collected: List[FileManifest] = []
|
||||
collected_rows = 0
|
||||
for manifest in indexer.list_files(
|
||||
paths_column,
|
||||
filesystem=filesystem,
|
||||
pruners=pruners or [],
|
||||
preserve_order=True,
|
||||
):
|
||||
if len(manifest) == 0:
|
||||
continue
|
||||
remaining = max_files - collected_rows
|
||||
if len(manifest) <= remaining:
|
||||
collected.append(manifest)
|
||||
collected_rows += len(manifest)
|
||||
else:
|
||||
collected.append(
|
||||
FileManifest(
|
||||
BlockAccessor.for_block(manifest.as_block()).slice(0, remaining)
|
||||
)
|
||||
)
|
||||
collected_rows = max_files
|
||||
if collected_rows >= max_files:
|
||||
break
|
||||
if not collected:
|
||||
return FileManifest.construct_manifest(paths=[], sizes=[], chunk_metadatas=[])
|
||||
return FileManifest.concat(collected)
|
||||
@@ -0,0 +1,122 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||
|
||||
from ray.data.expressions import Expr
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class SupportsFilterPushdown(ABC):
|
||||
"""Mixin for scanners that support filter/predicate pushdown.
|
||||
|
||||
Filter pushdown allows predicates to be evaluated at the data source level,
|
||||
reducing the amount of data that needs to be read and transferred.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push_filters(self, predicate: "Expr") -> Tuple["Scanner", Optional["Expr"]]:
|
||||
"""Push a filter predicate down to the scanner.
|
||||
|
||||
Args:
|
||||
predicate: Expression representing the filter condition.
|
||||
|
||||
Returns:
|
||||
Tuple of (new_scanner, residual_predicate) where:
|
||||
- new_scanner: New Scanner instance with the filter applied
|
||||
- residual_predicate: Any part of the predicate that couldn't be
|
||||
pushed down and must be applied post-scan. None if fully pushed.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class SupportsColumnPruning(ABC):
|
||||
"""Mixin for scanners that support column pruning/projection pushdown.
|
||||
|
||||
Column pruning allows reading only the columns needed by the query,
|
||||
which is especially beneficial for columnar formats like Parquet.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def prune_columns(self, columns: List[str]) -> "Scanner":
|
||||
"""Prune the scanner to only read the specified columns.
|
||||
|
||||
Args:
|
||||
columns: List of column names to read.
|
||||
|
||||
Returns:
|
||||
New Scanner instance configured to read only the specified columns.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def pruned_column_names(self) -> Optional[Tuple[str, ...]]:
|
||||
"""Physical column names selected after pruning, if any.
|
||||
|
||||
Returns:
|
||||
``None`` when no pruning has been applied (read all columns).
|
||||
A tuple (possibly empty) after :meth:`prune_columns` has been
|
||||
applied, listing on-disk / reader column names in read order.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class SupportsLimitPushdown(ABC):
|
||||
"""Mixin for scanners that support limit pushdown.
|
||||
|
||||
Limit pushdown allows the scanner to stop early once the required number
|
||||
of rows has been read.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push_limit(self, limit: int) -> "Scanner":
|
||||
"""Push a row limit down to the scanner.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of rows to read.
|
||||
|
||||
Returns:
|
||||
New Scanner instance with the limit applied.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class SupportsPartitionPruning(ABC):
|
||||
"""Mixin for scanners that support partition pruning.
|
||||
|
||||
Partition pruning allows skipping entire files/partitions based on
|
||||
predicates that reference partition columns.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def partition_columns(self) -> Set[str]:
|
||||
"""Names of columns that are partition keys.
|
||||
|
||||
Callers (e.g. the predicate-pushdown rule) use this to decide
|
||||
whether a predicate should be routed through :meth:`push_filters`
|
||||
(data columns) or :meth:`prune_partitions` (partition columns).
|
||||
Must be fully populated by schema inference at planning time.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def prune_partitions(self, predicate: "Expr") -> "Scanner":
|
||||
"""Prune partitions based on a predicate.
|
||||
|
||||
The scanner determines its partition columns from its
|
||||
``Partitioning`` configuration, which is fully populated
|
||||
by schema inference at planning time.
|
||||
|
||||
Args:
|
||||
predicate: Expression to evaluate against partition values.
|
||||
|
||||
Returns:
|
||||
New Scanner instance with partition pruning applied.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Concrete ``DataSourceV2`` for Parquet files.
|
||||
|
||||
Wires the V2 listing (`NonSamplingFileIndexer`, driven by the upstream
|
||||
`ListFiles` op), scanning (`ParquetScanner`), and reading
|
||||
(`ParquetFileReader`) components against a user-supplied path set.
|
||||
Constructed from `read_api.read_parquet` when
|
||||
`DataContext.use_datasource_v2` is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, List, Literal, Optional, Union
|
||||
|
||||
import pyarrow as pa
|
||||
from typing_extensions import override
|
||||
|
||||
from ray.data._internal.datasource.parquet_datasource import (
|
||||
ParquetDatasource,
|
||||
check_for_legacy_tensor_type,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
|
||||
FileChunker,
|
||||
ParquetFileChunker,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.datasource_v2 import (
|
||||
DatasourceCategory,
|
||||
DataSourceV2,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.file_indexer import (
|
||||
FileIndexer,
|
||||
NonSamplingFileIndexer,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.readers.file_reader import (
|
||||
INCLUDE_PATHS_COLUMN_NAME,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
|
||||
ParquetInMemorySizeEstimator,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.scanners.parquet_scanner import ParquetScanner
|
||||
from ray.data._internal.util import _is_local_scheme
|
||||
from ray.data.context import DataContext
|
||||
from ray.data.datasource.partitioning import (
|
||||
Partitioning,
|
||||
PartitionStyle,
|
||||
PathPartitionParser,
|
||||
_partition_field_types_to_pa_schema,
|
||||
)
|
||||
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyarrow.fs import FileSystem
|
||||
|
||||
from ray.data.datasource.file_based_datasource import FileShuffleConfig
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ParquetDatasourceV2(DataSourceV2[FileManifest]):
|
||||
"""V2 Parquet datasource.
|
||||
|
||||
Listing is delegated to :class:`NonSamplingFileIndexer` driven by the
|
||||
``ListFiles`` logical op; scanning and reading are delegated to
|
||||
:class:`ParquetScanner` and :class:`ParquetFileReader`. Schema
|
||||
inference reads the first file's footer only and augments with
|
||||
partition/path columns as configured.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
paths: List[str],
|
||||
*,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
partitioning: Optional[Partitioning] = Partitioning(PartitionStyle.HIVE),
|
||||
file_extensions: Optional[List[str]] = None,
|
||||
ignore_missing_paths: bool = False,
|
||||
include_paths: bool = False,
|
||||
include_row_hash: bool = False,
|
||||
shuffle: Optional[Union[Literal["files"], "FileShuffleConfig"]] = None,
|
||||
arrow_parquet_args: Optional[dict] = None,
|
||||
schema: Optional[pa.Schema] = None,
|
||||
parquet_format_kwargs: Optional[dict] = None,
|
||||
file_chunker: Optional[FileChunker] = None,
|
||||
):
|
||||
super().__init__(name="ParquetV2", category=DatasourceCategory.FILE_BASED)
|
||||
# Capture the ``local://`` check against the *original* paths;
|
||||
# ``_resolve_paths_and_filesystem`` below strips the scheme, so
|
||||
# introspecting ``self._paths`` after construction can't tell a
|
||||
# plain local path from a ``local://`` one. ``_supports_distributed_reads``
|
||||
# is exposed by the base-class ``supports_distributed_reads``
|
||||
# property and consumed by ``_read_datasource_v2``.
|
||||
self._supports_distributed_reads = not _is_local_scheme(paths)
|
||||
resolved_paths, resolved_filesystem = _resolve_paths_and_filesystem(
|
||||
paths, filesystem
|
||||
)
|
||||
self._paths: List[str] = resolved_paths
|
||||
self._filesystem = resolved_filesystem
|
||||
self._partitioning = partitioning
|
||||
self._file_extensions = file_extensions or ParquetDatasource._FILE_EXTENSIONS
|
||||
self._ignore_missing_paths = ignore_missing_paths
|
||||
self._include_paths = include_paths
|
||||
self._include_row_hash = include_row_hash
|
||||
self._shuffle = shuffle
|
||||
self._arrow_parquet_args = arrow_parquet_args or {}
|
||||
# ``pds.ParquetFileFormat`` kwargs forwarded from the deprecated
|
||||
# ``read_parquet(dataset_kwargs=...)`` arg. Spread into the format
|
||||
# built by ``ParquetFileReader._make_format``.
|
||||
self._parquet_format_kwargs = parquet_format_kwargs or {}
|
||||
# User-supplied schema override. When set, ``infer_schema`` returns
|
||||
# it verbatim (plus partition/path augmentation) rather than reading
|
||||
# footers, and the scanner pins it on the pyarrow dataset so files
|
||||
# are cast to these types at scan time.
|
||||
self._user_schema = schema
|
||||
# Chunker that splits each listed Parquet file into one or more
|
||||
# row-group-aligned read units. Defaults to ``ParquetFileChunker``
|
||||
# (1 GiB target chunk size, or whatever ``DataContext`` configures).
|
||||
# Callers can inject an alternative for tests or shuffle-aware
|
||||
# planning code that wants whole-file reads.
|
||||
self._file_chunker: FileChunker = (
|
||||
file_chunker if file_chunker is not None else ParquetFileChunker()
|
||||
)
|
||||
|
||||
@property
|
||||
def paths(self) -> List[str]:
|
||||
return self._paths
|
||||
|
||||
@property
|
||||
def filesystem(self) -> Optional["FileSystem"]:
|
||||
return self._filesystem
|
||||
|
||||
@property
|
||||
def partitioning(self) -> Optional[Partitioning]:
|
||||
return self._partitioning
|
||||
|
||||
@property
|
||||
def file_extensions(self) -> List[str]:
|
||||
return self._file_extensions
|
||||
|
||||
@property
|
||||
def ignore_missing_paths(self) -> bool:
|
||||
return self._ignore_missing_paths
|
||||
|
||||
@property
|
||||
def include_paths(self) -> bool:
|
||||
return self._include_paths
|
||||
|
||||
@property
|
||||
def shuffle(self) -> Optional[Union[Literal["files"], "FileShuffleConfig"]]:
|
||||
return self._shuffle
|
||||
|
||||
def _get_file_indexer(self) -> FileIndexer:
|
||||
return NonSamplingFileIndexer(
|
||||
ignore_missing_paths=self._ignore_missing_paths,
|
||||
file_chunker=self._file_chunker,
|
||||
)
|
||||
|
||||
def get_size_estimator(self) -> ParquetInMemorySizeEstimator:
|
||||
return ParquetInMemorySizeEstimator()
|
||||
|
||||
@override
|
||||
def resolve_partitioning(self, sample: FileManifest) -> Optional[Partitioning]:
|
||||
"""Return ``self._partitioning`` with path-discovered field names.
|
||||
|
||||
Hive partitioning ships with ``field_names=None`` by default and
|
||||
discovers keys from the file path at plan time. Directory
|
||||
partitioning already carries ``field_names`` at construction and
|
||||
needs no discovery. Returns a fresh ``Partitioning`` rather than
|
||||
mutating ``self`` so schema inference stays side-effect-free.
|
||||
"""
|
||||
import copy
|
||||
|
||||
if self._partitioning is None or len(sample) == 0:
|
||||
return copy.deepcopy(self._partitioning)
|
||||
if self._partitioning.field_names:
|
||||
return copy.deepcopy(self._partitioning)
|
||||
|
||||
first_path = sample.paths.tolist()[0]
|
||||
parser = PathPartitionParser(self._partitioning)
|
||||
partition_kv = parser(first_path)
|
||||
if not partition_kv:
|
||||
return copy.deepcopy(self._partitioning)
|
||||
return Partitioning(
|
||||
style=self._partitioning.style,
|
||||
base_dir=self._partitioning.base_dir,
|
||||
field_names=list(partition_kv.keys()),
|
||||
field_types=self._partitioning.field_types,
|
||||
filesystem=self._partitioning.filesystem,
|
||||
)
|
||||
|
||||
def infer_schema(self, sample: FileManifest) -> pa.Schema:
|
||||
"""Read Parquet footers from the sample manifest; unify and augment.
|
||||
|
||||
When the sample has multiple files, their schemas are unified via
|
||||
``unify_schemas_with_validation`` so a first file with all-null
|
||||
columns doesn't lock in ``null`` types that can't be cast to the
|
||||
actual types in later files.
|
||||
|
||||
Pure: does not mutate ``self``. Partitioning field-name discovery
|
||||
is delegated to :meth:`resolve_partitioning` so the discovered
|
||||
``Partitioning`` can flow through ``_read_datasource_v2`` into
|
||||
:meth:`create_scanner` without side effects.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from ray.data._internal.util import unify_schemas_with_validation
|
||||
|
||||
# Empty sample — typically means the user pointed ``read_parquet``
|
||||
# at an empty directory. Return an empty schema so the rest of
|
||||
# the plan stays valid; downstream ops produce zero blocks and
|
||||
# the executor runs through without error (matches V1).
|
||||
if len(sample) == 0:
|
||||
return self._user_schema if self._user_schema is not None else pa.schema([])
|
||||
|
||||
sample_paths: List[str] = sample.paths.tolist()
|
||||
# Parquet footer reads against high-latency object stores
|
||||
# (S3, GCS) are ~50-100 ms each. Reading the sample's footers in
|
||||
# parallel keeps driver-side schema inference bounded by the
|
||||
# slowest single read rather than the sum. ``executor.map``
|
||||
# preserves input order, which matters because the unified
|
||||
# schema's field order follows the first schema's and
|
||||
# ``sample_paths[0]`` drives partition discovery below.
|
||||
#
|
||||
# NOTE: ``pq.read_schema`` only accepts a ``filesystem=`` kwarg in
|
||||
# recent pyarrow releases; older wheels in CI don't have it. Open
|
||||
# the file through the configured filesystem and hand the file
|
||||
# handle to ``read_schema`` for cross-version compatibility.
|
||||
filesystem = self._filesystem
|
||||
|
||||
if self._user_schema is not None:
|
||||
# Caller pinned the schema — skip footer reads. Partition/path
|
||||
# augmentation below still applies so downstream ops see the
|
||||
# synthesized columns.
|
||||
schema = self._user_schema
|
||||
else:
|
||||
|
||||
def _read_schema(path: str):
|
||||
if filesystem is None:
|
||||
return pq.read_schema(path)
|
||||
with filesystem.open_input_file(path) as handle:
|
||||
return pq.read_schema(handle)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(len(sample_paths), 16)) as executor:
|
||||
per_file_schemas = list(executor.map(_read_schema, sample_paths))
|
||||
schema = (
|
||||
unify_schemas_with_validation(per_file_schemas) or per_file_schemas[0]
|
||||
)
|
||||
assert isinstance(schema, pa.Schema)
|
||||
|
||||
resolved_partitioning = self.resolve_partitioning(sample)
|
||||
if resolved_partitioning is not None:
|
||||
first_path = sample_paths[0]
|
||||
parser = PathPartitionParser(resolved_partitioning)
|
||||
partition_kv = parser(first_path)
|
||||
# For hive partitioning the parser discovers key names from the
|
||||
# path itself; for directory partitioning it uses ``field_names``.
|
||||
# In both cases ``partition_kv`` is the authoritative list of
|
||||
# partition columns for the first sample file.
|
||||
partition_pa_schema = _partition_field_types_to_pa_schema(
|
||||
field_names=list(partition_kv.keys()),
|
||||
field_types=resolved_partitioning.field_types or {},
|
||||
)
|
||||
for field_name in partition_kv.keys():
|
||||
if schema.get_field_index(field_name) == -1:
|
||||
pa_type = partition_pa_schema.field(field_name).type
|
||||
schema = schema.append(pa.field(field_name, pa_type))
|
||||
|
||||
if (
|
||||
self._include_paths
|
||||
and schema.get_field_index(INCLUDE_PATHS_COLUMN_NAME) == -1
|
||||
):
|
||||
schema = schema.append(pa.field(INCLUDE_PATHS_COLUMN_NAME, pa.string()))
|
||||
|
||||
if self._include_row_hash:
|
||||
# ``row_hash`` is synthesized post-read as ``uint64``. Replace
|
||||
# the field type when the file already has a ``row_hash``
|
||||
# column (matches V1 ``_derive_schema``); otherwise append.
|
||||
idx = schema.get_field_index("row_hash")
|
||||
if idx == -1:
|
||||
schema = schema.append(pa.field("row_hash", pa.uint64()))
|
||||
elif schema.field(idx).type != pa.uint64():
|
||||
schema = schema.set(idx, pa.field("row_hash", pa.uint64()))
|
||||
|
||||
check_for_legacy_tensor_type(schema)
|
||||
return schema
|
||||
|
||||
def create_scanner(
|
||||
self,
|
||||
schema: pa.Schema,
|
||||
filesystem: Optional["FileSystem"] = None,
|
||||
**options: Any,
|
||||
) -> ParquetScanner:
|
||||
# Callers (``_read_datasource_v2``) supply the sample-resolved
|
||||
# ``Partitioning`` via ``options["partitioning"]`` so the
|
||||
# datasource itself stays immutable — fall back to the
|
||||
# constructor-provided one for direct users of this API.
|
||||
partitioning = options.get("partitioning", self._partitioning)
|
||||
return ParquetScanner(
|
||||
schema=schema,
|
||||
filesystem=filesystem or self._filesystem,
|
||||
partitioning=partitioning,
|
||||
include_paths=self._include_paths,
|
||||
include_row_hash=self._include_row_hash,
|
||||
shuffle=self._shuffle,
|
||||
ignore_prefixes=options.get("ignore_prefixes"),
|
||||
target_block_size=DataContext.get_current().target_max_block_size,
|
||||
parquet_format_kwargs=dict(self._parquet_format_kwargs),
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
|
||||
|
||||
class FilePartitioner(ABC):
|
||||
"""Abstract base class for partitioning file manifests.
|
||||
|
||||
A ``FilePartitioner`` groups file paths and their associated metadata into new
|
||||
file manifests based on a specific partitioning strategy.
|
||||
|
||||
Implementations must be deterministic to ensure consistent partitioning across
|
||||
retries.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def add_input(self, input_manifest: FileManifest):
|
||||
"""Add a file manifest to be partitioned.
|
||||
|
||||
Args:
|
||||
input_manifest: A ``FileManifest`` containing paths and metadata to partition.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def has_partition(self) -> bool:
|
||||
"""Check if there are any partitions available.
|
||||
|
||||
Returns:
|
||||
``True`` if there are partitions ready to be retrieved via
|
||||
``next_partition()``, ``False`` otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def next_partition(self) -> FileManifest:
|
||||
"""Get the next available partition.
|
||||
|
||||
Returns:
|
||||
A ``FileManifest`` containing the paths and metadata for the next partition.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def finalize(self):
|
||||
"""Process any remaining files and complete the partitioning.
|
||||
|
||||
This method is called after all inputs have been added via ``add_input()`` to
|
||||
ensure any buffered files are properly partitioned.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.partitioners.file_partitioner import (
|
||||
FilePartitioner,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
|
||||
InMemorySizeEstimator,
|
||||
)
|
||||
from ray.data._internal.weighted_round_robin import WeightedRoundRobinPartitioner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RoundRobinPartitioner(FilePartitioner):
|
||||
"""Partitions input paths into blocks based on the in-memory size of files.
|
||||
|
||||
This partitioning ensures read tasks effectively utilize the cluster and
|
||||
produce appropriately-sized blocks
|
||||
|
||||
**Steps:**
|
||||
1. Initialize empty buckets.
|
||||
2. Iterate through input blocks and add paths to buckets. For each path:
|
||||
- If the current bucket falls below `min_bucket_size`, add the path and don't move
|
||||
to the next bucket.
|
||||
- If the current bucket exceeds `min_bucket_size` but not `max_bucket_size`,
|
||||
add the path and move to the next bucket.
|
||||
- If the current bucket exceeds `max_bucket_size`, yield the paths as a block, clear
|
||||
the bucket, and move to the next bucket.
|
||||
3. Yield any remaining paths in the buckets as blocks.
|
||||
|
||||
This algorithm ensures that each block contains [min_bucket_size, max_bucket_size]
|
||||
worth of files. It's a deterministic algorithm, but it doesn't maintain the order
|
||||
of the input paths.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
in_memory_size_estimator: InMemorySizeEstimator,
|
||||
*,
|
||||
min_bucket_size: int,
|
||||
max_bucket_size: int,
|
||||
num_buckets: int,
|
||||
):
|
||||
self._in_memory_size_estimator = in_memory_size_estimator
|
||||
self._partitioner = WeightedRoundRobinPartitioner(
|
||||
min_bucket_size=min_bucket_size,
|
||||
max_bucket_size=max_bucket_size,
|
||||
num_buckets=num_buckets,
|
||||
)
|
||||
|
||||
def add_input(self, input_manifest: FileManifest):
|
||||
in_memory_size_estimates = (
|
||||
self._in_memory_size_estimator.estimate_in_memory_sizes(input_manifest)
|
||||
)
|
||||
for (
|
||||
file_path,
|
||||
file_size,
|
||||
file_chunk_metadata,
|
||||
in_memory_size_estimate,
|
||||
) in zip(
|
||||
input_manifest.paths,
|
||||
input_manifest.file_sizes,
|
||||
input_manifest.file_chunk_metadatas,
|
||||
in_memory_size_estimates,
|
||||
):
|
||||
self._partitioner.add_item(
|
||||
(file_path, file_size, file_chunk_metadata),
|
||||
in_memory_size_estimate,
|
||||
)
|
||||
|
||||
def has_partition(self) -> bool:
|
||||
return self._partitioner.has_partition()
|
||||
|
||||
@property
|
||||
def num_buckets(self) -> int:
|
||||
return self._partitioner.num_buckets
|
||||
|
||||
def next_partition(self) -> FileManifest:
|
||||
partition = self._partitioner.next_partition()
|
||||
paths, file_sizes, file_chunk_metadatas = zip(*partition)
|
||||
return FileManifest.construct_manifest(
|
||||
list(paths),
|
||||
list(file_sizes),
|
||||
list(file_chunk_metadatas),
|
||||
)
|
||||
|
||||
def finalize(self):
|
||||
self._partitioner.finalize()
|
||||
@@ -0,0 +1,34 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generic, Iterator
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.datasource_v2 import InputSplit
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class Reader(ABC, Generic[InputSplit]):
|
||||
"""Abstract base class for reading data from input buckets.
|
||||
|
||||
Readers execute on workers to actually read data. They receive an InputSplit
|
||||
(e.g., FileManifest for file-based sources) and yield Arrow tables.
|
||||
|
||||
The Reader is created by Scanner.create_reader() and is configured with all
|
||||
pushdown optimizations (columns, predicates, limits) that were applied.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def read(self, input_split: InputSplit) -> Iterator[pa.Table]:
|
||||
"""Read data from the input bucket and yield Arrow tables.
|
||||
|
||||
This method is called on workers to perform the actual read operation.
|
||||
It should respect all pushdowns configured on this reader.
|
||||
|
||||
Args:
|
||||
input_split: Work unit describing what data to read.
|
||||
|
||||
Returns:
|
||||
Iterator[pa.Table]: Iterator of PyArrow Tables containing the read data.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,513 @@
|
||||
from enum import Enum
|
||||
from functools import cached_property, partial
|
||||
from typing import Any, Iterator, List, Optional, Set, Tuple
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.dataset as pds
|
||||
from pyarrow.fs import FileSystem, LocalFileSystem
|
||||
|
||||
from ray._common.utils import env_integer
|
||||
from ray.data._internal.arrow_block import _BATCH_SIZE_PRESERVING_STUB_COL_NAME
|
||||
from ray.data._internal.datasource.parquet_datasource import _compute_row_hashes
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.readers.base_reader import Reader
|
||||
from ray.data._internal.util import iterate_with_retry, make_async_gen
|
||||
from ray.data.context import DataContext
|
||||
from ray.data.datasource.partitioning import Partitioning, PathPartitionParser
|
||||
from ray.data.expressions import Expr
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
# Synthetic column name produced when ``include_paths=True``. Shared with
|
||||
# the V2 datasource and scanner layers so all references are spelled the
|
||||
# same way.
|
||||
INCLUDE_PATHS_COLUMN_NAME = "path"
|
||||
|
||||
# https://arrow.apache.org/docs/python/generated/pyarrow.dataset.Scanner.html#pyarrow.dataset.Scanner.from_batches
|
||||
# Default is specified by PyArrow.
|
||||
_ARROW_DEFAULT_BATCH_SIZE = 131_072
|
||||
|
||||
# Number of batches read ahead per scanner. PyArrow's default is 16,
|
||||
# which can retain a multi-GB working set when scanning jumbo tensor
|
||||
# columns. 8 keeps I/O pipelined on remote filesystems for typical
|
||||
# Parquet workloads without doubling memory peak. Drop to 1 via the
|
||||
# env var when reading wide tensor columns.
|
||||
_ARROW_SCANNER_BATCH_READAHEAD = env_integer(
|
||||
"RAY_DATA_ARROW_SCANNER_BATCH_READAHEAD", 8
|
||||
)
|
||||
|
||||
# Number of worker threads used to read fragments concurrently per task.
|
||||
# Defaults to 4 to overlap remote-filesystem I/O latency across multiple
|
||||
# fragments. ``_read_fragment_batches`` caps this to ``len(fragments)``
|
||||
# at runtime so single-fragment tasks don't spin up extra workers, and
|
||||
# falls back to the sequential path entirely when
|
||||
# ``DataContext.execution_options.preserve_order`` is set.
|
||||
_DEFAULT_NUM_THREADS = env_integer("RAY_DATA_READ_FILES_NUM_THREADS", 4)
|
||||
|
||||
ROW_HASH_COLUMN_NAME = "row_hash"
|
||||
|
||||
|
||||
class FileFormat(str, Enum):
|
||||
PARQUET = "parquet"
|
||||
CSV = "csv"
|
||||
FEATHER = "feather"
|
||||
JSON = "json"
|
||||
ARROW = "arrow"
|
||||
IPC = "ipc"
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class FileReader(Reader[FileManifest]):
|
||||
"""Reader for file-based sources.
|
||||
|
||||
This reader uses PyArrow's Dataset API which automatically handles:
|
||||
- Column pruning
|
||||
- Filter pushdown (row group pruning)
|
||||
- Batch-level filtering
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
format: FileFormat,
|
||||
batch_size: int = _ARROW_DEFAULT_BATCH_SIZE,
|
||||
columns: Optional[List[str]] = None,
|
||||
predicate: Optional[Expr] = None,
|
||||
limit: Optional[int] = None,
|
||||
filesystem: Optional[FileSystem] = None,
|
||||
partitioning: Optional[Partitioning] = None,
|
||||
ignore_prefixes: Optional[List[str]] = None,
|
||||
include_paths: bool = False,
|
||||
include_row_hash: bool = False,
|
||||
schema: Optional[pa.Schema] = None,
|
||||
):
|
||||
"""Initialize the reader.
|
||||
Refer to https://arrow.apache.org/docs/python/generated/pyarrow.dataset.dataset.html for more details.
|
||||
|
||||
Args:
|
||||
format: Format of the files to read.
|
||||
batch_size: Number of rows per batch.
|
||||
columns: Columns to read. None means all columns.
|
||||
predicate: Ray Data expression for filtering. Converted to a
|
||||
PyArrow expression at the scanner-kwargs boundary.
|
||||
limit: Maximum number of rows to read.
|
||||
filesystem: Filesystem for reading files.
|
||||
partitioning: Ray ``Partitioning`` object. Partition columns are
|
||||
synthesized per-path via ``PathPartitionParser`` after each
|
||||
batch is read, producing string-typed columns (V1 parity).
|
||||
ignore_prefixes: Prefixes to ignore when reading files. Default is ['.', '_'] set by PyArrow.
|
||||
include_paths: If True, include the source file path in a
|
||||
``'path'`` column for each row.
|
||||
include_row_hash: If True, include a deterministic uint64 hash
|
||||
per row in a ``'row_hash'`` column. The hash is derived from
|
||||
the source file path and the row's post-filter output
|
||||
position within the fragment, matching V1 semantics. If a
|
||||
``'row_hash'`` column already exists in the file, it is
|
||||
overwritten.
|
||||
schema: Caller-supplied unified schema used both to override
|
||||
pyarrow's per-fragment inference (so a file whose column
|
||||
is all-null doesn't pin the type to ``null``) and to cast
|
||||
path-derived partition values to their target types when
|
||||
``Partitioning(field_types=...)`` is set.
|
||||
|
||||
"""
|
||||
self._format = format
|
||||
self._columns = columns
|
||||
self._predicate = predicate
|
||||
self._batch_size = batch_size
|
||||
self._limit = limit
|
||||
self._filesystem = filesystem
|
||||
self._partition_parser: Optional[PathPartitionParser] = (
|
||||
PathPartitionParser(partitioning) if partitioning is not None else None
|
||||
)
|
||||
self._ignore_prefixes = ignore_prefixes
|
||||
self._include_paths = include_paths
|
||||
self._include_row_hash = include_row_hash
|
||||
self._schema = schema
|
||||
|
||||
@cached_property
|
||||
def _file_dataset_schema(self) -> Optional[pa.Schema]:
|
||||
"""Schema passed to ``pds.dataset`` — partition keys and ``path``
|
||||
stripped out since those are synthesized post-read.
|
||||
|
||||
Pinning the caller-supplied schema at the pyarrow layer is how
|
||||
we cover the "first file has an all-null column, later files
|
||||
have the real type" case (e.g.
|
||||
``test_read_null_data_in_first_file``): without the pin,
|
||||
pyarrow locks column X to ``null`` across the fragment group
|
||||
and the later string-typed file fails the cast.
|
||||
|
||||
But pyarrow refuses extension-to-extension casts (e.g.
|
||||
``ArrowTensorTypeV2(shape=X)`` → ``ArrowVariableShapedTensor``),
|
||||
and files with different per-file tensor shapes only unify
|
||||
through ``ArrowVariableShapedTensor``. When the caller schema
|
||||
contains *any* extension column we skip the pin entirely and
|
||||
let pyarrow infer per-file — downstream concat handles the
|
||||
heterogeneous blocks. Losing the all-null promotion in this
|
||||
narrow case is acceptable; the combination of an all-null
|
||||
first file *and* an extension column is uncommon, whereas
|
||||
reading multiple files with variable-shape tensors is a
|
||||
supported V1 feature.
|
||||
"""
|
||||
if self._schema is None:
|
||||
return None
|
||||
if any(isinstance(f.type, pa.ExtensionType) for f in self._schema):
|
||||
return None
|
||||
partition_keys = (
|
||||
set(self._partition_parser._scheme.field_names or [])
|
||||
if self._partition_parser is not None
|
||||
else set()
|
||||
)
|
||||
synthesized = {INCLUDE_PATHS_COLUMN_NAME}
|
||||
if self._include_row_hash:
|
||||
# ``row_hash`` is synthesized post-read, and the schema's type
|
||||
# (``uint64``) may not match the on-disk column's type when a
|
||||
# file already carries a ``row_hash`` column. Strip it from the
|
||||
# dataset schema so pyarrow doesn't try to cast.
|
||||
synthesized.add(ROW_HASH_COLUMN_NAME)
|
||||
fields = [
|
||||
f
|
||||
for f in self._schema
|
||||
if f.name not in partition_keys and f.name not in synthesized
|
||||
]
|
||||
return pa.schema(fields) if fields else None
|
||||
|
||||
def _broadcast_partition_value(
|
||||
self, name: str, value: Any, num_rows: int
|
||||
) -> pa.Array:
|
||||
"""Broadcast a single path-derived partition value to ``num_rows``,
|
||||
casting to the caller-supplied schema's field type if set.
|
||||
|
||||
Values are stringified first (``PathPartitionParser`` in
|
||||
``explicit`` mode can return arrow-scalar-like non-strings) and
|
||||
then cast to the target type, so ``Partitioning(field_types=
|
||||
{"year": int})`` still promotes them correctly.
|
||||
"""
|
||||
str_val = None if value is None else str(value)
|
||||
arr = pa.repeat(pa.scalar(str_val, type=pa.string()), num_rows)
|
||||
if self._schema is not None:
|
||||
idx = self._schema.get_field_index(name)
|
||||
if idx != -1 and self._schema.field(idx).type != pa.string():
|
||||
arr = arr.cast(self._schema.field(idx).type)
|
||||
return arr
|
||||
|
||||
def read(self, input_split: FileManifest) -> Iterator[pa.Table]:
|
||||
"""Read data from the input bucket and yield Arrow tables.
|
||||
|
||||
This method is called on workers to perform the actual read operation.
|
||||
It should respect all pushdowns configured on this reader.
|
||||
|
||||
Args:
|
||||
input_split: Work unit describing what data to read.
|
||||
|
||||
Yields:
|
||||
pa.Table: PyArrow Tables containing the read data.
|
||||
"""
|
||||
if len(input_split) == 0:
|
||||
return
|
||||
|
||||
# Dedupe paths before handing them to pyarrow. When chunking is on,
|
||||
# a manifest can carry multiple rows per file (each describing a
|
||||
# different row-group slice); pyarrow only needs one fragment per
|
||||
# file, and ``_get_fragments_to_read`` then fans out chunk-level
|
||||
# sub-fragments using the per-row chunk metadata.
|
||||
paths = list(dict.fromkeys(list(input_split.paths)))
|
||||
filesystem = self._filesystem or LocalFileSystem()
|
||||
# Build a ``pds.Dataset`` over *all* manifest paths so pyarrow's
|
||||
# listing + column metadata is shared, but then iterate its
|
||||
# fragments one at a time. ``dataset.scanner(fragments=...)``
|
||||
# at the aggregate level would force a cross-fragment cast —
|
||||
# which breaks variable-shape tensor extensions where each
|
||||
# file has its own ``ArrowTensorTypeV2(shape=...)``. Per-
|
||||
# fragment scanners let pyarrow use the native per-file type,
|
||||
# and downstream concat handles unification.
|
||||
dataset = pds.dataset(
|
||||
source=paths,
|
||||
format=self._make_format(),
|
||||
filesystem=filesystem,
|
||||
schema=self._file_dataset_schema,
|
||||
ignore_prefixes=self._ignore_prefixes,
|
||||
)
|
||||
|
||||
# Split the requested columns into ones the on-disk file has
|
||||
# (pyarrow reads these) and ones we need to synthesize post-read
|
||||
# (hive partition keys, "path"). ``self._columns is None`` means
|
||||
# "no projection" — read every file column and synthesize every
|
||||
# available partition/path column.
|
||||
on_disk_column_names = set(dataset.schema.names)
|
||||
if self._columns is None:
|
||||
columns_to_read_from_file: Optional[List[str]] = None
|
||||
columns_to_synthesize: Optional[Set[str]] = None
|
||||
else:
|
||||
columns_to_read_from_file = [
|
||||
c for c in self._columns if c in on_disk_column_names
|
||||
]
|
||||
columns_to_synthesize = set(self._columns) - on_disk_column_names
|
||||
|
||||
scanner_kwargs = {
|
||||
"columns": columns_to_read_from_file,
|
||||
"filter": (
|
||||
self._predicate.to_pyarrow() if self._predicate is not None else None
|
||||
),
|
||||
"batch_size": self._resolve_batch_size(dataset),
|
||||
"batch_readahead": _ARROW_SCANNER_BATCH_READAHEAD,
|
||||
}
|
||||
scanner_kwargs.update(self._arrow_scanner_kwargs())
|
||||
|
||||
rows_read = 0
|
||||
for table, fragment_path, fragment_row_offset in self._read_fragment_batches(
|
||||
dataset, scanner_kwargs, input_split
|
||||
):
|
||||
if self._limit is not None:
|
||||
if rows_read >= self._limit:
|
||||
break
|
||||
if len(table) > self._limit - rows_read:
|
||||
table = table.slice(0, self._limit - rows_read)
|
||||
|
||||
# Build the list of (name, value) pairs to synthesize from
|
||||
# the fragment path: hive partitions + optional ``path``.
|
||||
derived_items: List[Tuple[str, Any]] = []
|
||||
if self._partition_parser is not None:
|
||||
derived_items.extend(self._partition_parser(fragment_path).items())
|
||||
if self._include_paths:
|
||||
derived_items.append((INCLUDE_PATHS_COLUMN_NAME, fragment_path))
|
||||
|
||||
for name, value in derived_items:
|
||||
if (
|
||||
columns_to_synthesize is not None
|
||||
and name not in columns_to_synthesize
|
||||
):
|
||||
continue
|
||||
if name in table.column_names:
|
||||
# When the caller schema names a partition key, pyarrow
|
||||
# expects it in every file and fills it with nulls when
|
||||
# absent (the hive-typical case). Drop that placeholder
|
||||
# so the path-derived value below replaces it.
|
||||
table = table.drop([name])
|
||||
table = table.append_column(
|
||||
name,
|
||||
self._broadcast_partition_value(name, value, table.num_rows),
|
||||
)
|
||||
|
||||
# Skip when projection pushdown has narrowed ``columns`` to
|
||||
# exclude ``row_hash`` — the projection below would just drop it.
|
||||
if self._include_row_hash and (
|
||||
columns_to_synthesize is None
|
||||
or ROW_HASH_COLUMN_NAME in columns_to_synthesize
|
||||
):
|
||||
hashes = _compute_row_hashes(
|
||||
fragment_path, fragment_row_offset, table.num_rows
|
||||
)
|
||||
if ROW_HASH_COLUMN_NAME in table.column_names:
|
||||
table = table.drop([ROW_HASH_COLUMN_NAME])
|
||||
table = table.append_column(
|
||||
ROW_HASH_COLUMN_NAME, pa.array(hashes, type=pa.uint64())
|
||||
)
|
||||
|
||||
if self._columns is not None:
|
||||
# Project/reorder to the caller's requested column order;
|
||||
# drop any that weren't produced (matches V1's lenient
|
||||
# behavior). Always select — an empty projection must
|
||||
# narrow the table to zero columns so the stub-column
|
||||
# guard below handles row preservation.
|
||||
produced = set(table.column_names)
|
||||
projected = [c for c in self._columns if c in produced]
|
||||
table = table.select(projected)
|
||||
|
||||
if table.num_columns == 0 and table.num_rows > 0:
|
||||
# Guards against ``pa.concat_tables`` collapsing rows
|
||||
# when a batch has zero columns (e.g., empty projection
|
||||
# for a count query). The stub column is dropped by
|
||||
# downstream projections.
|
||||
table = table.append_column(
|
||||
_BATCH_SIZE_PRESERVING_STUB_COL_NAME,
|
||||
pa.nulls(table.num_rows),
|
||||
)
|
||||
|
||||
self._on_batch_read(table)
|
||||
rows_read += len(table)
|
||||
yield table
|
||||
|
||||
def _resolve_batch_size(self, dataset: pds.Dataset) -> int:
|
||||
"""Return the batch size to use for scanning.
|
||||
|
||||
Subclasses can override this to implement adaptive batch sizing.
|
||||
"""
|
||||
return self._batch_size
|
||||
|
||||
def _on_batch_read(self, table: pa.Table) -> None:
|
||||
"""Hook called after each batch is read.
|
||||
|
||||
Subclasses can override this to update internal state (e.g., refine
|
||||
batch size estimates from actual data).
|
||||
"""
|
||||
pass
|
||||
|
||||
def _arrow_scanner_kwargs(self) -> dict:
|
||||
"""Additional keyword arguments passed to ``pds.Dataset.scanner()``.
|
||||
|
||||
Subclasses override this to inject format-specific options.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def _make_format(self) -> Any:
|
||||
"""Format passed to ``pds.dataset(format=...)``.
|
||||
|
||||
Defaults to the format string (e.g. ``"parquet"``); subclasses
|
||||
override to return a configured ``pds.FileFormat`` instance when
|
||||
format-specific options (read options, fragment scan options) need
|
||||
to be threaded through.
|
||||
"""
|
||||
return self._format.value
|
||||
|
||||
def _get_fragments_to_read(
|
||||
self,
|
||||
dataset: pds.Dataset,
|
||||
manifest: FileManifest,
|
||||
) -> List[Tuple[pds.Fragment, int]]:
|
||||
"""Return ``(fragment, file_row_offset)`` pairs to scan for this
|
||||
manifest.
|
||||
|
||||
``file_row_offset`` is the cumulative pre-filter row count of all
|
||||
rows in the underlying file that precede this fragment. It seeds
|
||||
the per-fragment hashing offset so chunked sub-fragments of the
|
||||
same file produce unique ``_compute_row_hashes`` keys instead of
|
||||
colliding on ``(path, 0, n)``.
|
||||
|
||||
Default impl returns one ``(fragment, 0)`` per file in the dataset
|
||||
(paths are deduped in :meth:`read` before the dataset is built).
|
||||
Subclasses that support per-row chunk metadata
|
||||
(e.g. :class:`ParquetFileReader`) override this to fan a single
|
||||
file fragment out into N sub-fragments — one per row-group slice —
|
||||
based on :attr:`FileManifest.file_chunk_metadatas`, each paired
|
||||
with its starting row offset in the file.
|
||||
"""
|
||||
return [(fragment, 0) for fragment in dataset.get_fragments()]
|
||||
|
||||
def _read_fragment_batches(
|
||||
self,
|
||||
dataset: pds.Dataset,
|
||||
scanner_kwargs: dict,
|
||||
manifest: FileManifest,
|
||||
) -> Iterator[Tuple[pa.Table, str, int]]:
|
||||
"""Yield non-empty (table, fragment_path, fragment_row_offset) triples.
|
||||
|
||||
``fragment_row_offset`` is the post-filter row position of the first
|
||||
row of ``table`` within its fragment. ``iterate_with_retry`` skips
|
||||
already-yielded items on retry, so ``offset`` reflects only the
|
||||
rows that actually surface to the caller — matching V1 row-hash
|
||||
semantics even when a fragment fails partway through.
|
||||
|
||||
Retry is scoped per-fragment: if a fragment fails mid-read, only
|
||||
that fragment is re-read (skipping batches already yielded).
|
||||
Wrapping the whole manifest in a single retry would re-iterate
|
||||
fragments that already succeeded and double-emit their batches.
|
||||
|
||||
Each fragment gets its own scanner so pyarrow uses the native
|
||||
per-file schema. A cross-fragment scanner would force a unified
|
||||
schema cast, which refuses extension-to-extension conversion
|
||||
(e.g. variable-shape tensors). V1 ``ParquetDatasource`` follows
|
||||
the same per-fragment pattern via ``fragment.to_batches``.
|
||||
|
||||
When ``RAY_DATA_READ_FILES_NUM_THREADS > 1`` and
|
||||
``execution_options.preserve_order`` is False, fragments are
|
||||
read concurrently via :func:`make_async_gen`. We still pass
|
||||
``preserve_ordering=True`` so concurrent reads emit blocks in
|
||||
fragment order; otherwise Ray Data task retries (block
|
||||
reconstruction) could produce a different block sequence.
|
||||
|
||||
``make_async_gen`` consumes the whole input iterator up front
|
||||
when preserving order. That is acceptable here because the input
|
||||
is the finite fragment manifest from ``_get_fragments_to_read``,
|
||||
which we materialize below anyway. File data is still read lazily
|
||||
by the worker threads.
|
||||
"""
|
||||
ctx = DataContext.get_current()
|
||||
|
||||
# ``preserve_ordering=True`` would drain the input iterator
|
||||
# eagerly anyway, so materialize once here to (a) cap
|
||||
# ``num_workers`` at the actual fragment count and (b) avoid
|
||||
# an early-fallback when the manifest has a single fragment.
|
||||
# Subclasses (e.g. ``ParquetFileReader``) override
|
||||
# ``_get_fragments_to_read`` to fan out chunk-level
|
||||
# sub-fragments from the manifest's chunk metadata.
|
||||
fragments_with_offsets = self._get_fragments_to_read(dataset, manifest)
|
||||
if not fragments_with_offsets:
|
||||
return
|
||||
|
||||
num_workers = min(_DEFAULT_NUM_THREADS, len(fragments_with_offsets))
|
||||
if num_workers <= 1 or ctx.execution_options.preserve_order:
|
||||
yield from self._read_fragments_sequential(
|
||||
iter(fragments_with_offsets), scanner_kwargs
|
||||
)
|
||||
return
|
||||
|
||||
# Set `preserve_ordering=True` to ensure deterministic output ordering.
|
||||
# This is required so that Ray Data task retries (block reconstruction)
|
||||
yield from make_async_gen(
|
||||
base_iterator=iter(fragments_with_offsets),
|
||||
fn=partial(self._read_fragments_sequential, scanner_kwargs=scanner_kwargs),
|
||||
preserve_ordering=True,
|
||||
num_workers=num_workers,
|
||||
)
|
||||
|
||||
def _read_fragments_sequential(
|
||||
self,
|
||||
fragments_with_offsets: Iterator[Tuple[pds.Fragment, int]],
|
||||
scanner_kwargs: dict,
|
||||
) -> Iterator[Tuple[pa.Table, str, int]]:
|
||||
"""Read each fragment in ``fragments_with_offsets`` in order, yielding
|
||||
``(table, fragment_path, fragment_row_offset)`` triples.
|
||||
|
||||
Each input pair is ``(fragment, file_row_offset)``. The yielded
|
||||
``fragment_row_offset`` starts at ``file_row_offset`` (the row
|
||||
position of the fragment's first row within its underlying file)
|
||||
and accumulates per yielded batch, so the per-fragment row-hash
|
||||
math in :meth:`read` keys off the right window even when chunking
|
||||
fans one file into multiple sub-fragments sharing ``fragment.path``.
|
||||
|
||||
``iterate_with_retry`` is scoped to a single fragment so a
|
||||
transient I/O failure only re-reads the failing file (skipping
|
||||
batches already yielded), not the whole input.
|
||||
|
||||
This is the per-worker body for the threaded path in
|
||||
:meth:`_read_fragment_batches` (one thread per call, each
|
||||
consuming a disjoint slice of fragments via ``make_async_gen``)
|
||||
and is also the entire read loop for the sequential path.
|
||||
"""
|
||||
ctx = DataContext.get_current()
|
||||
for fragment, file_row_offset in fragments_with_offsets:
|
||||
offset = file_row_offset
|
||||
for table in iterate_with_retry(
|
||||
partial(self._iter_fragment_tables, fragment, scanner_kwargs),
|
||||
f"read fragment {fragment.path}",
|
||||
match=ctx.retried_io_errors,
|
||||
):
|
||||
if table.num_rows > 0:
|
||||
yield table, fragment.path, offset
|
||||
offset += table.num_rows
|
||||
|
||||
def _iter_fragment_tables(
|
||||
self,
|
||||
fragment: pds.Fragment,
|
||||
scanner_kwargs: dict,
|
||||
) -> Iterator[pa.Table]:
|
||||
"""Yield Arrow tables for a single fragment.
|
||||
|
||||
Subclasses override this to swap in a format-specific reader for
|
||||
fragments that don't fit the default scanner-based path (e.g.
|
||||
Parquet's ARROW-5030 nested-type fallback).
|
||||
|
||||
When a non-extension caller schema is available we pin it at the
|
||||
scanner so pyarrow null-fills any column the unified schema names
|
||||
but the fragment lacks (V1 parity — ``ParquetDatasource`` passes
|
||||
``read_schema`` to ``fragment.to_batches``). Falling back to the
|
||||
per-fragment ``physical_schema`` preserves the variable-shape
|
||||
tensor escape hatch already encoded in ``_file_dataset_schema``.
|
||||
"""
|
||||
fragment_schema = (
|
||||
self._file_dataset_schema
|
||||
if self._file_dataset_schema is not None
|
||||
else fragment.physical_schema
|
||||
)
|
||||
scanner = fragment.scanner(**scanner_kwargs, schema=fragment_schema)
|
||||
for tagged in scanner.scan_batches():
|
||||
yield pa.Table.from_batches(batches=[tagged.record_batch])
|
||||
@@ -0,0 +1,146 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.readers.file_reader import FileReader
|
||||
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
|
||||
from ray.data.block import BlockAccessor
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class InMemorySizeEstimator(ABC):
|
||||
@abstractmethod
|
||||
def estimate_in_memory_sizes(self, manifest: FileManifest) -> np.ndarray:
|
||||
"""Estimate the in-memory sizes of the paths in the given manifest.
|
||||
|
||||
Some `FilePartitioner` implementations use this method to ensure that each
|
||||
read task receives an appropriate amount of data. To ensure that file listing
|
||||
is efficient, this method must be cheap to call, on average.
|
||||
|
||||
Args:
|
||||
manifest: A manifest containing the paths and on-disk sizes of the files.
|
||||
|
||||
Returns:
|
||||
The estimated in-memory sizes of the data in bytes.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class SamplingInMemorySizeEstimator(InMemorySizeEstimator):
|
||||
"""Estimates in-memory sizes by reading files.
|
||||
|
||||
This class estimates the in-memory size of files by multiplying the on-disk
|
||||
size by an estimated encoding ratio. If an instance hasn't estimated an encoding
|
||||
ratio yet, it'll read a file to estimate it. Otherwise, it'll use the previously
|
||||
estimated encoding ratio.
|
||||
|
||||
TODO: This approach doesn't work well for formats that produce multiple batches
|
||||
(because we assume a 1:1 encoding ratio) or for formats that vary in encoding
|
||||
ratios (e.g. videos).
|
||||
"""
|
||||
|
||||
def __init__(self, reader: "FileReader"):
|
||||
self._reader = reader
|
||||
|
||||
self._encoding_ratio = None
|
||||
|
||||
def estimate_in_memory_sizes(self, manifest: FileManifest) -> np.ndarray:
|
||||
assert np.all(manifest.file_sizes >= 0)
|
||||
|
||||
for path, file_size in zip(manifest.paths, manifest.file_sizes):
|
||||
if self._encoding_ratio is None:
|
||||
# Estimating the encoding ratio can be expensive since it requires
|
||||
# reading the file. So, we only estimate the encoding ratio if we don't
|
||||
# already have one.
|
||||
self._encoding_ratio = self._estimate_encoding_ratio(path, file_size)
|
||||
break
|
||||
|
||||
if self._encoding_ratio is None:
|
||||
# If we couldn't estimate the encoding ratio, assume a 1:1 encoding ratio.
|
||||
return manifest.file_sizes
|
||||
else:
|
||||
return manifest.file_sizes * self._encoding_ratio
|
||||
|
||||
def _estimate_encoding_ratio(
|
||||
self,
|
||||
path: str,
|
||||
file_size: int,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Estimate the encoding ratio (in-memory size / on-disk size) for a file.
|
||||
|
||||
Args:
|
||||
path: The path to the file.
|
||||
file_size: The on-disk size of the file/chunk in bytes.
|
||||
|
||||
Returns:
|
||||
The estimated encoding ratio of the file, or `None` if the ratio can't
|
||||
be estimated.
|
||||
"""
|
||||
# If the file is empty, we can't estimate the encoding ratio.
|
||||
if not file_size:
|
||||
return None
|
||||
|
||||
# Use ``None`` chunk metadata: the size estimator reads the file whole
|
||||
# to estimate the encoding ratio; chunk-level splitting is irrelevant here.
|
||||
manifest = FileManifest.construct_manifest(
|
||||
[path],
|
||||
[file_size],
|
||||
[None],
|
||||
)
|
||||
batches = self._reader.read(manifest)
|
||||
|
||||
try:
|
||||
first_batch = next(batches)
|
||||
except StopIteration:
|
||||
# If there's no data, we can't estimate the encoding ratio.
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try to read a second batch. If it succeeds, it means the file contains
|
||||
# multiple batches.
|
||||
next(batches)
|
||||
except StopIteration:
|
||||
# Each file contains exactly one batch.
|
||||
builder = DelegatingBlockBuilder()
|
||||
builder.add_batch(first_batch)
|
||||
block = builder.build()
|
||||
|
||||
in_memory_size = BlockAccessor.for_block(block).size_bytes()
|
||||
else:
|
||||
# Each file contains multiple batches.
|
||||
#
|
||||
# NOTE: To avoid reading the entire file to estimate the encoding ratio,
|
||||
# we assume the file is 1:1 encoded. We can't return `None` because if
|
||||
# all files contain multiple batches, then we'd try to re-estimate the
|
||||
# encoding ratio for every file, and that'd be very expensive.
|
||||
in_memory_size = file_size
|
||||
|
||||
return in_memory_size / file_size
|
||||
|
||||
|
||||
# Default Parquet encoding ratio: in-memory is ~5x on-disk size.
|
||||
# Parquet uses columnar compression and encoding, so Arrow in-memory
|
||||
# representation is significantly larger than the on-disk format.
|
||||
PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT = 5
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ParquetInMemorySizeEstimator(InMemorySizeEstimator):
|
||||
"""Estimates in-memory sizes for Parquet files using a fixed encoding ratio.
|
||||
|
||||
Parquet files are typically much smaller on disk than in memory due to
|
||||
columnar compression and encoding. This estimator applies a constant
|
||||
ratio (default 5x) to avoid the overhead of reading file metadata or
|
||||
sampling data, which can be slow for Parquet files and hurt startup time.
|
||||
"""
|
||||
|
||||
def __init__(self, encoding_ratio: float = PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT):
|
||||
self._encoding_ratio = encoding_ratio
|
||||
|
||||
def estimate_in_memory_sizes(self, manifest: FileManifest) -> np.ndarray:
|
||||
return self._encoding_ratio * manifest.file_sizes
|
||||
@@ -0,0 +1,516 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.dataset as pds
|
||||
import pyarrow.parquet as pq
|
||||
from pyarrow.fs import FileSystem
|
||||
from typing_extensions import override
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data.datasource.partitioning import Partitioning
|
||||
|
||||
from ray._common.utils import env_bool, env_integer
|
||||
from ray.data._internal.datasource.parquet_datasource import (
|
||||
AUTOLOAD_PICKLE_OBJECT_SCALAR_ENV_VAR,
|
||||
_check_for_pickle_object_columns,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.chunkers.parquet_file_chunking_utils import (
|
||||
_fragments_from_chunk_metadata,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.readers.file_reader import (
|
||||
_ARROW_DEFAULT_BATCH_SIZE,
|
||||
FileFormat,
|
||||
FileReader,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
|
||||
PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT,
|
||||
)
|
||||
from ray.data._internal.util import MiB
|
||||
from ray.data.expressions import Expr
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
from ray.util.debug import log_once
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
# Per-stream read-ahead buffer for ``use_buffered_stream=True``. PyArrow's
|
||||
# default (~8 KiB) produces many tiny range requests on S3; 8 MiB
|
||||
# amortizes per-request latency across meaningful payload sizes.
|
||||
_PARQUET_FRAGMENT_BUFFER_SIZE = env_integer(
|
||||
"RAY_DATA_PARQUET_FRAGMENT_BUFFER_SIZE", 8 * MiB
|
||||
)
|
||||
|
||||
|
||||
def _estimate_batch_size_from_metadata(
|
||||
fragment: pds.ParquetFileFragment,
|
||||
columns: Optional[List[str]],
|
||||
target_block_size: int,
|
||||
) -> Optional[int]:
|
||||
"""Estimate batch size from Parquet row group metadata without reading data.
|
||||
|
||||
Uses uncompressed column sizes from row group metadata and the encoding
|
||||
ratio to estimate in-memory row size, then computes how many rows fit
|
||||
in ``target_block_size``.
|
||||
|
||||
Args:
|
||||
fragment: A PyArrow Parquet fragment with accessible metadata.
|
||||
columns: Columns being read, or None for all columns.
|
||||
target_block_size: Target in-memory size per batch in bytes.
|
||||
|
||||
Returns:
|
||||
Estimated batch size in rows, or None if metadata is unavailable.
|
||||
"""
|
||||
try:
|
||||
# Accessing `metadata` triggers I/O via `EnsureCompleteMetadata()` to
|
||||
# read the Parquet footer. `check_status` maps C++ Status codes to
|
||||
# Python exceptions: IOError (OSError) for I/O failures,
|
||||
# ArrowInvalid for corrupt footers.
|
||||
# https://github.com/apache/arrow/blob/apache-arrow-23.0.0/python/pyarrow/error.pxi#L110-L126
|
||||
metadata: pq.FileMetaData = fragment.metadata
|
||||
except (OSError, pa.ArrowInvalid) as e:
|
||||
logger.debug("Failed to read Parquet metadata for batch size estimation: %s", e)
|
||||
return None
|
||||
|
||||
if metadata is None or metadata.num_row_groups == 0:
|
||||
return None
|
||||
|
||||
row_group_idx: int = (
|
||||
fragment.row_groups[0].id if fragment.row_groups is not None else 0
|
||||
)
|
||||
row_group_meta: pq.RowGroupMetaData = metadata.row_group(row_group_idx)
|
||||
row_group_num_rows: int = row_group_meta.num_rows
|
||||
|
||||
if row_group_num_rows == 0:
|
||||
return None
|
||||
|
||||
if columns is not None:
|
||||
projected_columns = tuple(columns)
|
||||
target_column_indices = []
|
||||
for col_idx in range(row_group_meta.num_columns):
|
||||
leaf_path = row_group_meta.column(col_idx).path_in_schema
|
||||
# Account for nested columns
|
||||
if any(
|
||||
leaf_path == col_name or leaf_path.startswith(f"{col_name}.")
|
||||
for col_name in projected_columns
|
||||
):
|
||||
target_column_indices.append(col_idx)
|
||||
row_group_uncompressed_size = sum(
|
||||
row_group_meta.column(col_idx).total_uncompressed_size
|
||||
for col_idx in target_column_indices
|
||||
)
|
||||
else:
|
||||
# Sum per-column uncompressed sizes instead of using
|
||||
# row_group_meta.total_byte_size, which can return the *compressed* size
|
||||
# for some files (apache/arrow#48138).
|
||||
row_group_uncompressed_size = sum(
|
||||
row_group_meta.column(col_idx).total_uncompressed_size
|
||||
for col_idx in range(row_group_meta.num_columns)
|
||||
)
|
||||
|
||||
# Estimate the in-memory size of the row group
|
||||
estimated_in_mem_row_group_size = (
|
||||
row_group_uncompressed_size * PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT
|
||||
)
|
||||
|
||||
estimated_in_mem_row_size = estimated_in_mem_row_group_size / row_group_num_rows
|
||||
if estimated_in_mem_row_size == 0:
|
||||
return None
|
||||
|
||||
# Never request more rows than the row group actually contains.
|
||||
target_batch_size = min(
|
||||
math.ceil(target_block_size / estimated_in_mem_row_size),
|
||||
row_group_num_rows,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Estimated target batch size to be: {target_batch_size} (with "
|
||||
f"{target_block_size=} bytes and {estimated_in_mem_row_size=} bytes)"
|
||||
)
|
||||
|
||||
return target_batch_size
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ParquetFileReader(FileReader):
|
||||
"""Parquet-specific file reader with adaptive batch sizing.
|
||||
|
||||
Extends :class:`FileReader` with:
|
||||
|
||||
- **Metadata-based batch size estimation**: Uses Parquet row group metadata
|
||||
(uncompressed column sizes) to estimate an optimal batch size before
|
||||
reading any data.
|
||||
- **Adaptive refinement**: After reading each batch, refines the batch size
|
||||
estimate from actual in-memory sizes for subsequent reads.
|
||||
|
||||
For non-Parquet formats, use :class:`FileReader` directly.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
batch_size: Optional[int] = None,
|
||||
columns: Optional[List[str]] = None,
|
||||
predicate: Optional[Expr] = None,
|
||||
limit: Optional[int] = None,
|
||||
filesystem: Optional[FileSystem] = None,
|
||||
partitioning: "Optional[Partitioning]" = None,
|
||||
ignore_prefixes: Optional[List[str]] = None,
|
||||
target_block_size: Optional[int] = None,
|
||||
include_paths: bool = False,
|
||||
include_row_hash: bool = False,
|
||||
schema: Optional[pa.Schema] = None,
|
||||
parquet_format_kwargs: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Initialize the Parquet reader.
|
||||
|
||||
Args:
|
||||
batch_size: Explicit batch size override. If provided, disables
|
||||
adaptive batch sizing.
|
||||
columns: Columns to read. None means all columns.
|
||||
predicate: Ray Data expression for filtering.
|
||||
limit: Maximum number of rows to read.
|
||||
filesystem: Filesystem for reading files.
|
||||
partitioning: Ray ``Partitioning`` for synthesizing partition
|
||||
columns from file paths.
|
||||
ignore_prefixes: Prefixes to ignore when reading files.
|
||||
target_block_size: Target in-memory size per batch in bytes.
|
||||
Used for adaptive batch sizing when ``batch_size`` is not set.
|
||||
include_paths: If True, include the source file path in a
|
||||
``'path'`` column for each row.
|
||||
include_row_hash: If True, include a deterministic uint64 hash
|
||||
per row in a ``'row_hash'`` column.
|
||||
schema: Caller-supplied unified schema forwarded to the base
|
||||
:class:`FileReader` for per-fragment inference override
|
||||
and partition-column type casting.
|
||||
parquet_format_kwargs: Extra kwargs spread into
|
||||
:class:`pyarrow.dataset.ParquetFileFormat` (e.g.
|
||||
``coerce_int96_timestamp_unit``, ``pre_buffer``,
|
||||
``dictionary_columns``). Used to forward the deprecated
|
||||
``dataset_kwargs`` arg on the V2 path.
|
||||
"""
|
||||
super().__init__(
|
||||
format=FileFormat.PARQUET,
|
||||
batch_size=batch_size or _ARROW_DEFAULT_BATCH_SIZE,
|
||||
columns=columns,
|
||||
predicate=predicate,
|
||||
limit=limit,
|
||||
filesystem=filesystem,
|
||||
partitioning=partitioning,
|
||||
ignore_prefixes=ignore_prefixes,
|
||||
include_paths=include_paths,
|
||||
include_row_hash=include_row_hash,
|
||||
schema=schema,
|
||||
)
|
||||
self._explicit_batch_size = batch_size
|
||||
self._allow_pickle_object_columns = env_bool(
|
||||
AUTOLOAD_PICKLE_OBJECT_SCALAR_ENV_VAR, False
|
||||
)
|
||||
self._target_block_size = target_block_size
|
||||
self._parquet_format_kwargs: Dict[str, Any] = parquet_format_kwargs or {}
|
||||
self._sampled_batch_size: int | object = (
|
||||
_UNSET # pyrefly: ignore[bad-assignment]
|
||||
)
|
||||
|
||||
@override
|
||||
def _make_format(self) -> pds.ParquetFileFormat:
|
||||
return pds.ParquetFileFormat(**self._parquet_format_kwargs)
|
||||
|
||||
@override
|
||||
def _resolve_batch_size(self, dataset: pds.Dataset) -> int:
|
||||
"""Determine batch size from explicit setting, metadata, or default.
|
||||
|
||||
Priority: explicit batch_size > sampled estimate > metadata estimate > default.
|
||||
|
||||
On the first call ``_sampled_batch_size`` is ``_UNSET``, so we fall
|
||||
through to the metadata estimate and seed ``_sampled_batch_size`` with
|
||||
the result. ``_on_batch_read`` later refines it from actual data, and
|
||||
subsequent ``read()`` calls on the same instance use the refined value.
|
||||
"""
|
||||
if self._explicit_batch_size is not None:
|
||||
return self._explicit_batch_size
|
||||
|
||||
if self._sampled_batch_size is not _UNSET:
|
||||
return self._sampled_batch_size # pyrefly: ignore[bad-return]
|
||||
|
||||
batch_size = _ARROW_DEFAULT_BATCH_SIZE
|
||||
if self._target_block_size is not None:
|
||||
first_fragment = next(dataset.get_fragments(), None)
|
||||
if first_fragment is not None:
|
||||
estimated = _estimate_batch_size_from_metadata(
|
||||
first_fragment, self._columns, self._target_block_size
|
||||
)
|
||||
if estimated is not None:
|
||||
logger.debug(
|
||||
"Estimated Parquet batch size: %d rows (target_block_size=%d)",
|
||||
estimated,
|
||||
self._target_block_size,
|
||||
)
|
||||
batch_size = estimated
|
||||
|
||||
self._sampled_batch_size = batch_size
|
||||
return batch_size
|
||||
|
||||
@override
|
||||
def _on_batch_read(self, table: pa.Table) -> None:
|
||||
"""Refine batch size estimate from actual in-memory data."""
|
||||
if self._target_block_size is None or table.nbytes == 0 or table.num_rows == 0:
|
||||
return
|
||||
row_size = table.nbytes / table.num_rows
|
||||
self._sampled_batch_size = max(math.ceil(self._target_block_size / row_size), 1)
|
||||
|
||||
@override
|
||||
def _get_fragments_to_read(
|
||||
self,
|
||||
dataset: pds.Dataset,
|
||||
manifest: FileManifest,
|
||||
) -> List[Tuple[pds.Fragment, int]]:
|
||||
"""Fan file fragments into chunk-level sub-fragments per manifest row.
|
||||
|
||||
For each manifest row, looks up the file's fragment by path and:
|
||||
|
||||
- If ``chunk_metadata`` is ``None`` (whole-file case), the file
|
||||
fragment is yielded as-is with a row offset of 0. This matches
|
||||
``ParquetFileChunker``'s behavior for files at or below
|
||||
``target_chunk_size`` and the default ``WholeFileChunker`` for
|
||||
non-chunking callers.
|
||||
- Otherwise the row carries a :class:`ParquetFileChunkMetadata`;
|
||||
we slice the fragment via
|
||||
:func:`~ray.data._internal.datasource_v2.chunkers.parquet_file_chunking_utils._fragments_from_chunk_metadata`
|
||||
which returns one sub-fragment per row group in the chunk's
|
||||
row-group range, paired with the cumulative pre-filter row
|
||||
offset of that row group within the file. The downstream
|
||||
``_compute_row_hashes`` call uses this offset so row hashes
|
||||
remain unique across sub-fragments that share ``fragment.path``.
|
||||
|
||||
Paths are deduped by :meth:`FileReader.read` before the dataset is
|
||||
built, so the dataset has exactly one fragment per file. The
|
||||
per-row chunk metadata drives the fan-out here, not the dataset
|
||||
itself — multiple manifest rows can share a single path with
|
||||
different chunk indices.
|
||||
"""
|
||||
path_to_fragment = {
|
||||
fragment.path: fragment for fragment in dataset.get_fragments()
|
||||
}
|
||||
fragments: List[Tuple[pds.Fragment, int]] = []
|
||||
for path, chunk_metadata in zip(manifest.paths, manifest.file_chunk_metadatas):
|
||||
fragment = path_to_fragment[path]
|
||||
if chunk_metadata is None:
|
||||
fragments.append((fragment, 0))
|
||||
else:
|
||||
fragments.extend(
|
||||
_fragments_from_chunk_metadata(fragment, chunk_metadata)
|
||||
)
|
||||
return fragments
|
||||
|
||||
@override
|
||||
def _iter_fragment_tables(
|
||||
self,
|
||||
fragment: pds.Fragment,
|
||||
scanner_kwargs: dict,
|
||||
) -> "Iterator[pa.Table]":
|
||||
for table in self._iter_fragment_tables_without_pickle_check(
|
||||
fragment, scanner_kwargs
|
||||
):
|
||||
if not self._allow_pickle_object_columns:
|
||||
_check_for_pickle_object_columns(table)
|
||||
yield table
|
||||
|
||||
def _iter_fragment_tables_without_pickle_check(
|
||||
self,
|
||||
fragment: pds.Fragment,
|
||||
scanner_kwargs: dict,
|
||||
) -> "Iterator[pa.Table]":
|
||||
"""Use V1's nested-type fallback path when the fragment has nested
|
||||
columns whose row-group size exceeds Arrow's ~2GB chunking limit
|
||||
(ARROW-5030).
|
||||
"""
|
||||
import pyarrow.compute as pc
|
||||
|
||||
from ray.data._internal.arrow_ops.transform_pyarrow import (
|
||||
_align_struct_fields,
|
||||
)
|
||||
from ray.data._internal.datasource.parquet_datasource import (
|
||||
_get_safe_batch_size_for_nested_types,
|
||||
_needs_nested_type_fallback,
|
||||
_resolve_leaf_column_indices,
|
||||
_resolve_read_columns,
|
||||
)
|
||||
from ray.data._internal.planner.plan_expression.expression_visitors import (
|
||||
get_column_references,
|
||||
)
|
||||
|
||||
columns = scanner_kwargs.get("columns")
|
||||
filter_expr: pc.Expression = scanner_kwargs.get("filter")
|
||||
# Include filter-referenced columns in the fallback check: a filter
|
||||
# that touches a large nested column outside the projection still
|
||||
# forces row-level decoding of that column, which would otherwise
|
||||
# hit ARROW-5030 in the normal scanner path.
|
||||
filter_columns = (
|
||||
get_column_references(self._predicate)
|
||||
if self._predicate is not None
|
||||
else None
|
||||
)
|
||||
read_columns = _resolve_read_columns(columns, filter_expr, filter_columns)
|
||||
if not _needs_nested_type_fallback(fragment, read_columns):
|
||||
yield from super()._iter_fragment_tables(fragment, scanner_kwargs)
|
||||
return
|
||||
|
||||
if log_once(f"parquet_nested_fallback_v2:{fragment.path}"):
|
||||
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,
|
||||
)
|
||||
|
||||
batch_size = scanner_kwargs.get("batch_size")
|
||||
|
||||
pf = pq.ParquetFile(
|
||||
fragment.path,
|
||||
filesystem=fragment.filesystem, # pyrefly: ignore[unexpected-keyword]
|
||||
)
|
||||
|
||||
# Scope the safe batch-size calculation to the columns actually being
|
||||
# decoded so we don't shrink batches based on columns we won't read.
|
||||
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_batch_size = _get_safe_batch_size_for_nested_types(pf, leaf_indices)
|
||||
fallback_batch_size = (
|
||||
min(batch_size, safe_batch_size) if batch_size else safe_batch_size
|
||||
)
|
||||
|
||||
# Apply row-group-level predicate pushdown via fragment.subset; the
|
||||
# row-level filter is applied per-batch below since iter_batches
|
||||
# doesn't accept a filter expression. Under schema evolution the
|
||||
# filter may reference a column absent from this fragment's
|
||||
# physical schema — fragment.subset uses that schema (not the
|
||||
# unified one) and raises ArrowInvalid, so skip row-group pruning
|
||||
# in that case and let the per-batch filter (post null-fill) do
|
||||
# all the row-dropping.
|
||||
fragment_physical_columns = set(fragment.physical_schema.names)
|
||||
filter_touches_missing_column = filter_columns is not None and any(
|
||||
c not in fragment_physical_columns for c in filter_columns
|
||||
)
|
||||
if filter_expr is not None and not filter_touches_missing_column:
|
||||
subset = fragment.subset(filter=filter_expr)
|
||||
else:
|
||||
subset = fragment
|
||||
row_groups = (
|
||||
[rg.id for rg in subset.row_groups]
|
||||
if subset.row_groups is not None
|
||||
else None
|
||||
)
|
||||
if row_groups is not None and len(row_groups) == 0:
|
||||
return
|
||||
|
||||
# ``pq.ParquetFile.iter_batches`` returns batches with the fragment's
|
||||
# physical schema, so the fallback path would otherwise emit tables
|
||||
# that differ from the scanner path (which pins
|
||||
# ``_file_dataset_schema``) in struct field order, integer width,
|
||||
# or missing columns. Align + cast to the same unified schema so
|
||||
# fallback and non-fallback fragments concat cleanly downstream.
|
||||
# Scoped to ``columns`` (not ``read_columns``) since filter-only
|
||||
# columns are projected away before alignment.
|
||||
file_dataset_schema = self._file_dataset_schema
|
||||
if file_dataset_schema is not None and columns is not None:
|
||||
align_schema = pa.schema(
|
||||
[
|
||||
file_dataset_schema.field(c)
|
||||
for c in columns
|
||||
if file_dataset_schema.get_field_index(c) != -1
|
||||
]
|
||||
)
|
||||
else:
|
||||
align_schema = file_dataset_schema
|
||||
|
||||
# Under schema evolution a filter-referenced column may live in
|
||||
# the unified dataset schema but be absent from this fragment.
|
||||
# The scanner path null-fills such columns via dataset-level
|
||||
# schema pinning; ``pq.ParquetFile.iter_batches`` silently drops
|
||||
# them and then ``table.filter(filter_expr)`` raises
|
||||
# ``ArrowInvalid: No match for FieldRef.Name``. Mirror the
|
||||
# scanner: append a null column of the unified type before the
|
||||
# filter evaluates, so ``null > 15`` resolves to false and the
|
||||
# fragment contributes 0 rows.
|
||||
columns_to_null_fill: List[str] = (
|
||||
[c for c in read_columns if c not in fragment_physical_columns]
|
||||
if read_columns is not None
|
||||
else []
|
||||
)
|
||||
null_fill_type_by_column = {
|
||||
column_name: (
|
||||
file_dataset_schema.field(column_name).type
|
||||
if file_dataset_schema is not None
|
||||
and file_dataset_schema.get_field_index(column_name) != -1
|
||||
else pa.null()
|
||||
)
|
||||
for column_name in columns_to_null_fill
|
||||
}
|
||||
|
||||
for batch in pf.iter_batches(
|
||||
batch_size=fallback_batch_size,
|
||||
columns=read_columns,
|
||||
use_threads=False,
|
||||
row_groups=row_groups,
|
||||
):
|
||||
table = pa.Table.from_batches([batch])
|
||||
for column_name in columns_to_null_fill:
|
||||
if column_name not in table.column_names:
|
||||
table = table.append_column(
|
||||
column_name,
|
||||
pa.nulls(
|
||||
table.num_rows,
|
||||
type=null_fill_type_by_column[column_name],
|
||||
),
|
||||
)
|
||||
if filter_expr is not None:
|
||||
table = table.filter(filter_expr)
|
||||
# Skip downstream select/align/cast on fully-filtered
|
||||
# batches — the caller discards empty tables anyway.
|
||||
if table.num_rows == 0:
|
||||
continue
|
||||
if columns is not None:
|
||||
table = table.select([c for c in columns if c in table.column_names])
|
||||
if align_schema is not None:
|
||||
table = _align_struct_fields([table], align_schema)[0].cast(
|
||||
align_schema
|
||||
)
|
||||
yield table
|
||||
|
||||
@override
|
||||
def _arrow_scanner_kwargs(self) -> dict:
|
||||
# ``pre_buffer`` is left at pyarrow's default (``True``). With
|
||||
# ``pre_buffer=True`` pyarrow plans a single coalesced range
|
||||
# request covering all needed column chunks for a fragment and
|
||||
# issues it in one I/O burst, then decodes from memory. With
|
||||
# ``pre_buffer=False`` pyarrow opens a per-column buffered
|
||||
# stream and fetches lazily — fine on narrow schemas (few large
|
||||
# columns) but catastrophic on wide schemas (thousands of small
|
||||
# columns become thousands of range requests). V1
|
||||
# ``ParquetDatasource`` also relies on the default. The
|
||||
# cross-fragment memory accumulation that originally motivated
|
||||
# disabling ``pre_buffer`` (apache/arrow#39808) is already
|
||||
# addressed by V2's per-fragment scanners.
|
||||
#
|
||||
# ``buffer_size`` controls the per-stream read-ahead buffer
|
||||
# pyarrow issues against the filesystem when ``use_buffered_stream``
|
||||
# is on. The default is small (8 KiB), which produces many tiny
|
||||
# range requests on S3. 8 MiB amortizes S3 latency across
|
||||
# meaningful bytes per round-trip. Tunable via env var for
|
||||
# workloads that need a different point on the latency/memory-
|
||||
# peak curve.
|
||||
kwargs: dict = {
|
||||
"fragment_scan_options": pds.ParquetFragmentScanOptions(
|
||||
use_buffered_stream=True,
|
||||
buffer_size=_PARQUET_FRAGMENT_BUFFER_SIZE,
|
||||
),
|
||||
"fragment_readahead": 1,
|
||||
}
|
||||
return kwargs
|
||||
@@ -0,0 +1,201 @@
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import List, Optional, Set, Tuple
|
||||
|
||||
import pyarrow as pa
|
||||
from pyarrow.fs import FileSystem
|
||||
from typing_extensions import override
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.logical_optimizers import (
|
||||
SupportsColumnPruning,
|
||||
SupportsFilterPushdown,
|
||||
SupportsLimitPushdown,
|
||||
SupportsPartitionPruning,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.scanners.file_scanner import FileScanner
|
||||
from ray.data.datasource.partitioning import Partitioning, PathPartitionParser
|
||||
from ray.data.expressions import Expr
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass(frozen=True)
|
||||
class ArrowFileScanner(
|
||||
FileScanner,
|
||||
SupportsFilterPushdown,
|
||||
SupportsColumnPruning,
|
||||
SupportsLimitPushdown,
|
||||
SupportsPartitionPruning,
|
||||
):
|
||||
"""Base scanner for file-based datasources that use PyArrow's Dataset API.
|
||||
|
||||
Holds shared Arrow types and options (schema, projection, filesystem,
|
||||
partitioning, etc.). Subclasses set the file format in :meth:`create_reader`.
|
||||
|
||||
Provides default implementations of filter pushdown, column pruning,
|
||||
limit pushdown, and partition pruning that work for all Arrow-backed
|
||||
formats.
|
||||
|
||||
Non-Arrow file formats should subclass :class:`FileScanner` directly.
|
||||
"""
|
||||
|
||||
schema: pa.Schema
|
||||
batch_size: Optional[int] = None
|
||||
columns: Optional[Tuple[str, ...]] = None
|
||||
predicate: Optional[Expr] = None
|
||||
partition_predicate: Optional[Expr] = None
|
||||
limit: Optional[int] = None
|
||||
filesystem: Optional[FileSystem] = None
|
||||
partitioning: Optional[Partitioning] = None
|
||||
ignore_prefixes: Optional[List[str]] = None
|
||||
|
||||
@property
|
||||
def partition_columns(self) -> Set[str]:
|
||||
"""Return the set of partition column names, or empty if unpartitioned."""
|
||||
if self.partitioning is None:
|
||||
return set()
|
||||
return set(self.partitioning.field_names or [])
|
||||
|
||||
def read_schema(self) -> pa.Schema:
|
||||
"""Return the logical schema after column pruning.
|
||||
|
||||
``columns is None`` → no projection applied, return the full schema.
|
||||
``columns = ()`` → empty projection (``ds.select_columns([])``),
|
||||
return an empty schema.
|
||||
|
||||
The physical read may still inject a stub column (see
|
||||
``_BATCH_SIZE_PRESERVING_STUB_COL_NAME``) so that row counts
|
||||
survive a zero-column scan; that stub is an execution-layer detail
|
||||
and is deliberately not reflected in this logical schema.
|
||||
"""
|
||||
if self.columns is None:
|
||||
return self.schema
|
||||
fields = []
|
||||
for name in self.columns:
|
||||
idx = self.schema.get_field_index(name)
|
||||
assert idx >= 0, f"Column {name} not found in schema"
|
||||
fields.append(self.schema.field(idx))
|
||||
return pa.schema(fields)
|
||||
|
||||
@override
|
||||
def push_filters(
|
||||
self, predicate: "Expr"
|
||||
) -> Tuple["ArrowFileScanner", Optional["Expr"]]:
|
||||
"""Push filter predicate down to the scanner.
|
||||
|
||||
ANDs the predicate with any existing predicate. The Ray ``Expr`` is
|
||||
retained as the source of truth so the reader can introspect filter
|
||||
columns; conversion to a PyArrow expression happens at the
|
||||
scanner-kwargs boundary in :class:`FileReader`.
|
||||
|
||||
This method handles data-column predicates only. Partition predicates
|
||||
should be pushed via :meth:`prune_partitions` instead; the optimizer
|
||||
is responsible for splitting them before calling either method.
|
||||
|
||||
Args:
|
||||
predicate: Ray Data expression to push down.
|
||||
|
||||
Returns:
|
||||
A pair ``(scanner, residual)`` where ``scanner`` has the predicate
|
||||
merged into its PyArrow filter. ``residual`` is ``None`` because
|
||||
PyArrow handles the full filter at scan time.
|
||||
"""
|
||||
if self.predicate is not None:
|
||||
combined = self.predicate & predicate
|
||||
else:
|
||||
combined = predicate
|
||||
|
||||
return replace(self, predicate=combined), None
|
||||
|
||||
@override
|
||||
def prune_columns(self, columns: List[str]) -> "ArrowFileScanner":
|
||||
"""Prune to only the specified columns.
|
||||
|
||||
Args:
|
||||
columns: List of column names to keep.
|
||||
|
||||
Returns:
|
||||
New scanner with column pruning applied.
|
||||
"""
|
||||
if self.columns:
|
||||
existing = set(self.columns)
|
||||
columns = [c for c in columns if c in existing]
|
||||
|
||||
return replace(self, columns=tuple(columns))
|
||||
|
||||
@override
|
||||
def pruned_column_names(self) -> Optional[Tuple[str, ...]]:
|
||||
return self.columns
|
||||
|
||||
@override
|
||||
def push_limit(self, limit: int) -> "ArrowFileScanner":
|
||||
"""Push row limit down to the scanner.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of rows to read.
|
||||
|
||||
Returns:
|
||||
New scanner with limit applied.
|
||||
"""
|
||||
current = self.limit
|
||||
new_limit = min(current, limit) if current is not None else limit
|
||||
return replace(self, limit=new_limit)
|
||||
|
||||
@override
|
||||
def prune_partitions(self, predicate: "Expr") -> "ArrowFileScanner":
|
||||
"""Store a partition predicate for file-level pruning during plan().
|
||||
|
||||
The predicate is ANDed with any existing partition predicate. Actual
|
||||
file pruning happens in :meth:`plan` when the manifest is available,
|
||||
using :class:`PathPartitionParser` to evaluate partition values from
|
||||
file paths.
|
||||
|
||||
Args:
|
||||
predicate: Expression referencing only partition columns.
|
||||
|
||||
Returns:
|
||||
New scanner with partition predicate stored.
|
||||
"""
|
||||
if self.partition_predicate is not None:
|
||||
combined = self.partition_predicate & predicate
|
||||
else:
|
||||
combined = predicate
|
||||
|
||||
return replace(self, partition_predicate=combined)
|
||||
|
||||
@override
|
||||
def prune_manifest(self, manifest: FileManifest) -> FileManifest:
|
||||
"""Filter manifest to only files matching ``self.partition_predicate``.
|
||||
|
||||
Called by :func:`plan_read_files_op.do_read` for every incoming
|
||||
manifest block. No-op when either the predicate or the
|
||||
partitioning spec is absent. Uses
|
||||
:class:`PathPartitionParser` to parse partition values from
|
||||
each file path and evaluate the predicate.
|
||||
"""
|
||||
if self.partition_predicate is None or self.partitioning is None:
|
||||
return manifest
|
||||
|
||||
parser = PathPartitionParser(self.partitioning)
|
||||
keep_indices = []
|
||||
|
||||
for i, path in enumerate(manifest.paths):
|
||||
if parser.evaluate_predicate_on_partition(path, self.partition_predicate):
|
||||
keep_indices.append(i)
|
||||
|
||||
if len(keep_indices) == len(manifest):
|
||||
return manifest
|
||||
|
||||
pruned_count = len(manifest) - len(keep_indices)
|
||||
logger.debug(
|
||||
"Partition pruning removed %d of %d files",
|
||||
pruned_count,
|
||||
len(manifest),
|
||||
)
|
||||
|
||||
block = manifest.as_block()
|
||||
pruned_block = block.take(keep_indices)
|
||||
return FileManifest(pruned_block)
|
||||
@@ -0,0 +1,46 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Union
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.scanners.scanner import Scanner
|
||||
from ray.data.datasource.file_based_datasource import (
|
||||
FileShuffleConfig,
|
||||
_validate_shuffle_arg,
|
||||
)
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass(frozen=True)
|
||||
class FileScanner(Scanner[FileManifest]):
|
||||
"""Base scanner for file-based datasources.
|
||||
|
||||
Subclasses implement format-specific ``read_schema()`` and
|
||||
``create_reader()``. Shuffling and parallel bucketing are handled
|
||||
upstream in the ``ListFiles`` transform chain (``shuffle_files`` +
|
||||
``RoundRobinPartitioner`` via ``plan_list_files_op``), not here.
|
||||
|
||||
PyArrow Dataset-based scanners should subclass ``ArrowFileScanner``;
|
||||
use ``FileScanner`` directly for non-Arrow file formats.
|
||||
"""
|
||||
|
||||
# kw_only so subclass dataclasses can declare their own required fields
|
||||
# (like ``ArrowFileScanner.schema``) without running into the "non-default
|
||||
# argument follows default argument" dataclass inheritance rule.
|
||||
shuffle: Union[Literal["files"], FileShuffleConfig, None] = field(
|
||||
default=None, kw_only=True
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_shuffle_arg(self.shuffle)
|
||||
|
||||
def prune_manifest(self, manifest: FileManifest) -> FileManifest:
|
||||
"""Return a filtered view of ``manifest``.
|
||||
|
||||
Default: identity. Subclasses that support file-level predicate
|
||||
pruning (e.g. :class:`ArrowFileScanner`'s ``partition_predicate``)
|
||||
override this to drop rows whose partition values fail the
|
||||
predicate. Invoked per-block from
|
||||
:func:`plan_read_files_op.do_read`.
|
||||
"""
|
||||
return manifest
|
||||
@@ -0,0 +1,88 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.datasource.parquet_datasource import (
|
||||
check_for_legacy_tensor_type,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.file_reader import (
|
||||
INCLUDE_PATHS_COLUMN_NAME,
|
||||
ROW_HASH_COLUMN_NAME,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.parquet_file_reader import (
|
||||
ParquetFileReader,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.scanners.arrow_file_scanner import (
|
||||
ArrowFileScanner,
|
||||
)
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass(frozen=True)
|
||||
class ParquetScanner(ArrowFileScanner):
|
||||
"""Parquet-specific scanner implementation.
|
||||
|
||||
Inherits filter pushdown, column pruning, limit pushdown, partition
|
||||
pruning, and file shuffle from ArrowFileScanner. Adds Parquet-specific
|
||||
reader creation with adaptive batch sizing and the Parquet-only
|
||||
legacy-tensor-type schema check.
|
||||
"""
|
||||
|
||||
target_block_size: Optional[int] = None
|
||||
include_paths: bool = False
|
||||
include_row_hash: bool = False
|
||||
# Extra kwargs forwarded to ``pds.ParquetFileFormat(**kwargs)`` inside
|
||||
# the per-task ``ParquetFileReader`` (e.g. ``coerce_int96_timestamp_unit``,
|
||||
# ``pre_buffer``, ``dictionary_columns``). Carries the deprecated
|
||||
# ``dataset_kwargs`` payload from ``read_parquet`` to the worker.
|
||||
parquet_format_kwargs: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def read_schema(self) -> pa.Schema:
|
||||
"""Return schema after column pruning and tensor check.
|
||||
|
||||
``path`` and ``row_hash`` are synthesized post-read by the file
|
||||
reader, but only for columns listed in ``self.columns`` (see
|
||||
``file_reader.read``'s ``columns_to_synthesize`` filter). When a
|
||||
projection has pruned a synthesized column away, advertising it
|
||||
here would put the schema out of sync with the actual blocks — so
|
||||
only append when no projection is active or when it survives.
|
||||
"""
|
||||
schema = super().read_schema()
|
||||
synthesized = (
|
||||
(self.include_paths, INCLUDE_PATHS_COLUMN_NAME, pa.string()),
|
||||
(self.include_row_hash, ROW_HASH_COLUMN_NAME, pa.uint64()),
|
||||
)
|
||||
for enabled, name, dtype in synthesized:
|
||||
if not enabled:
|
||||
continue
|
||||
if self.columns is not None and name not in self.columns:
|
||||
continue
|
||||
if schema.get_field_index(name) != -1:
|
||||
continue
|
||||
schema = schema.append(pa.field(name, dtype))
|
||||
|
||||
check_for_legacy_tensor_type(schema)
|
||||
return schema
|
||||
|
||||
def create_reader(self) -> ParquetFileReader:
|
||||
"""Create a ParquetFileReader configured for this scanner.
|
||||
|
||||
Returns:
|
||||
ParquetFileReader with all pushdowns and adaptive batch sizing.
|
||||
"""
|
||||
return ParquetFileReader(
|
||||
batch_size=self.batch_size,
|
||||
columns=list(self.columns) if self.columns is not None else None,
|
||||
predicate=self.predicate,
|
||||
limit=self.limit,
|
||||
filesystem=self.filesystem,
|
||||
partitioning=self.partitioning,
|
||||
ignore_prefixes=self.ignore_prefixes,
|
||||
target_block_size=self.target_block_size,
|
||||
include_paths=self.include_paths,
|
||||
include_row_hash=self.include_row_hash,
|
||||
schema=self.schema,
|
||||
parquet_format_kwargs=dict(self.parquet_format_kwargs),
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generic
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.datasource_v2 import InputSplit
|
||||
from ray.data._internal.datasource_v2.readers.base_reader import Reader
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class Scanner(ABC, Generic[InputSplit]):
|
||||
"""Abstract base class for configured scanners.
|
||||
|
||||
A Scanner represents the logical result of reading data, including applied
|
||||
filters, projections, limits, and other pushdown operations. It is an
|
||||
immutable abstraction: each push operation returns a new Scanner instance
|
||||
via cloning rather than mutation.
|
||||
|
||||
The Scanner is responsible for:
|
||||
1. Determining the output schema after all projections
|
||||
2. Creating Reader instances configured with all pushdowns
|
||||
|
||||
Splitting the input into parallel work units used to live here as a
|
||||
``plan()`` method. That responsibility now belongs to the listing-side
|
||||
pipeline (``ListFiles`` + ``FilePartitioner``); scanners only
|
||||
need to answer "what schema?" and "give me a reader."
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def read_schema(self) -> pa.Schema:
|
||||
"""Return the schema that will be produced by this scanner.
|
||||
|
||||
This reflects the schema after all column pruning has been applied.
|
||||
|
||||
Returns:
|
||||
PyArrow Schema describing the output data.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def create_reader(self) -> Reader[InputSplit]:
|
||||
"""Create a Reader configured for this scanner.
|
||||
|
||||
The returned Reader will have all pushdowns (columns, predicates, limits)
|
||||
applied and is ready to execute on workers.
|
||||
|
||||
Returns:
|
||||
Configured Reader instance.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,144 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import (
|
||||
FILE_CHUNK_METADATA_COLUMN_NAME,
|
||||
FILE_SIZE_COLUMN_NAME,
|
||||
PATH_COLUMN_NAME,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.listing_utils import partition_files
|
||||
from ray.data._internal.datasource_v2.partitioners.round_robin_partitioner import (
|
||||
RoundRobinPartitioner,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
|
||||
InMemorySizeEstimator,
|
||||
)
|
||||
from ray.data._internal.weighted_round_robin import WeightedRoundRobinPartitioner
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_paths, expected_partitions",
|
||||
(
|
||||
# These diagrams represent the state before leftover paths are yielded. Each
|
||||
# column represent a bucket, the height represent the max bucket size, and
|
||||
# numbers represent paths. There are two buckets, the min bucket size is 1, and
|
||||
# the max bucket size is 3.
|
||||
#
|
||||
# | | | | Yeilds [1].
|
||||
# | | | |
|
||||
# |1| | |
|
||||
[1, [["1"]]],
|
||||
# | | | | Move to the second bucket because ther first one exceeds the min
|
||||
# | | | | bucket size (1).
|
||||
# |1| |2|
|
||||
[2, [["1"], ["2"]]],
|
||||
# |5| | | | | | | Continue spreading paths because all buckets contain the
|
||||
# |3| |4| -> | | |4| min bucket size. Once the first bucket is full, yield the
|
||||
# |1| |2| | | |2| paths, clear the bucket, and move to the second bucket.
|
||||
[5, [["1", "3", "5"], ["2", "4"]]],
|
||||
# | | |6| | | | | The second bucket is full, so we yield the paths, clear
|
||||
# | | |4| -> | | | | the bucket, and move back to the first.
|
||||
# | | |2| | | | |
|
||||
[6, [["1", "3", "5"], ["2", "4", "6"]]],
|
||||
# | | | | Repeat.
|
||||
# | | | |
|
||||
# |7| | |
|
||||
[7, [["1", "3", "5"], ["2", "4", "6"], ["7"]]],
|
||||
),
|
||||
)
|
||||
def test_round_robin_partitioner_produces_correct_partitions(
|
||||
num_paths, expected_partitions
|
||||
):
|
||||
input_table = pa.Table.from_pydict(
|
||||
{
|
||||
PATH_COLUMN_NAME: [str(i) for i in range(1, num_paths + 1)],
|
||||
FILE_SIZE_COLUMN_NAME: [1] * num_paths,
|
||||
FILE_CHUNK_METADATA_COLUMN_NAME: [None] * num_paths,
|
||||
}
|
||||
)
|
||||
|
||||
class StubInMemorySizeEstimator(InMemorySizeEstimator):
|
||||
def estimate_in_memory_sizes(
|
||||
self,
|
||||
manifest,
|
||||
) -> np.ndarray:
|
||||
return np.ones(len(manifest))
|
||||
|
||||
outputs = partition_files(
|
||||
iter([input_table]),
|
||||
MagicMock(),
|
||||
partitioner=RoundRobinPartitioner(
|
||||
in_memory_size_estimator=StubInMemorySizeEstimator(),
|
||||
num_buckets=2,
|
||||
min_bucket_size=1,
|
||||
max_bucket_size=3,
|
||||
),
|
||||
)
|
||||
|
||||
partitions = [output[PATH_COLUMN_NAME].to_pylist() for output in outputs]
|
||||
assert partitions == expected_partitions
|
||||
|
||||
|
||||
def test_round_robin_partitioner_with_no_size_estimates():
|
||||
# This tests the case where we don't have size estimates. This can happen if you use
|
||||
# HTTPFileSystem.
|
||||
input_table = pa.Table.from_pydict(
|
||||
{
|
||||
PATH_COLUMN_NAME: ["path0", "path1", "path2"],
|
||||
FILE_SIZE_COLUMN_NAME: [None, None, None],
|
||||
FILE_CHUNK_METADATA_COLUMN_NAME: [None, None, None],
|
||||
}
|
||||
)
|
||||
|
||||
class StubInMemorySizeEstimator(InMemorySizeEstimator):
|
||||
def estimate_in_memory_sizes(
|
||||
self,
|
||||
manifest,
|
||||
) -> np.ndarray:
|
||||
return manifest.file_sizes
|
||||
|
||||
outputs = partition_files(
|
||||
iter([input_table]),
|
||||
MagicMock(),
|
||||
partitioner=RoundRobinPartitioner(
|
||||
in_memory_size_estimator=StubInMemorySizeEstimator(),
|
||||
num_buckets=2,
|
||||
min_bucket_size=1,
|
||||
max_bucket_size=1,
|
||||
),
|
||||
)
|
||||
partitions = [output[PATH_COLUMN_NAME].to_pylist() for output in outputs]
|
||||
|
||||
# If in-memory size estimates aren't available, the partitioner should round-robin
|
||||
# the paths across the buckets, disregarding the bucket size limits.
|
||||
assert len(partitions) == 2
|
||||
assert partitions[0] == ["path0", "path2"]
|
||||
assert partitions[1] == ["path1"]
|
||||
|
||||
|
||||
def test_weighted_round_robin_partitioner_can_emit_before_overflow():
|
||||
partitioner = WeightedRoundRobinPartitioner(
|
||||
num_buckets=1,
|
||||
min_bucket_size=1,
|
||||
max_bucket_size=3,
|
||||
emit_before_overflow=True,
|
||||
)
|
||||
|
||||
partitioner.add_item("a", 2)
|
||||
partitioner.add_item("b", 2)
|
||||
|
||||
assert partitioner.has_partition()
|
||||
assert partitioner.next_partition() == ["a"]
|
||||
|
||||
partitioner.finalize()
|
||||
assert partitioner.has_partition()
|
||||
assert partitioner.next_partition() == ["b"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Unit tests for :class:`ParquetDatasourceV2`.
|
||||
|
||||
These tests exercise schema inference, scanner/estimator creation, and
|
||||
include-paths schema augmentation against a local tmpdir — they do not
|
||||
spin up Ray.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from ray.data._internal.datasource_v2.chunkers.file_chunker import (
|
||||
ParquetFileChunker,
|
||||
ParquetFileChunkMetadata,
|
||||
WholeFileChunker,
|
||||
create_chunk_metadata,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.chunkers.parquet_file_chunking_utils import (
|
||||
_fragments_from_chunk_metadata,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.listing.file_manifest import FileManifest
|
||||
from ray.data._internal.datasource_v2.parquet_datasource_v2 import (
|
||||
ParquetDatasourceV2,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.in_memory_size_estimator import (
|
||||
ParquetInMemorySizeEstimator,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.readers.parquet_file_reader import (
|
||||
ParquetFileReader,
|
||||
)
|
||||
from ray.data._internal.datasource_v2.scanners.parquet_scanner import (
|
||||
ParquetScanner,
|
||||
)
|
||||
from ray.data.datasource.partitioning import Partitioning, PartitionStyle
|
||||
|
||||
|
||||
def _write_parquet(path: str, table: pa.Table) -> None:
|
||||
pq.write_table(table, path)
|
||||
|
||||
|
||||
def _manifest_of(paths):
|
||||
sizes = [os.path.getsize(p) for p in paths]
|
||||
return FileManifest.construct_manifest(paths, sizes, [None] * len(paths))
|
||||
|
||||
|
||||
def test_infer_schema_unpartitioned(tmp_path):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1, 2, 3], "b": ["x", "y", "z"]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)])
|
||||
schema = datasource.infer_schema(_manifest_of([str(file_path)]))
|
||||
|
||||
assert schema.names == ["a", "b"]
|
||||
assert schema.field("a").type == pa.int64()
|
||||
assert schema.field("b").type == pa.string()
|
||||
|
||||
|
||||
def test_infer_schema_hive_partitioned(tmp_path):
|
||||
for part in ["a", "b"]:
|
||||
d = tmp_path / f"color={part}"
|
||||
d.mkdir()
|
||||
_write_parquet(str(d / "data.parquet"), pa.table({"x": [1, 2]}))
|
||||
|
||||
first_file = str(tmp_path / "color=a" / "data.parquet")
|
||||
datasource = ParquetDatasourceV2(
|
||||
[str(tmp_path)], partitioning=Partitioning(PartitionStyle.HIVE)
|
||||
)
|
||||
schema = datasource.infer_schema(_manifest_of([first_file]))
|
||||
|
||||
assert "x" in schema.names
|
||||
assert "color" in schema.names
|
||||
assert schema.field("color").type == pa.string()
|
||||
|
||||
|
||||
def test_infer_schema_with_include_paths(tmp_path):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1, 2]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)], include_paths=True)
|
||||
schema = datasource.infer_schema(_manifest_of([str(file_path)]))
|
||||
|
||||
assert "path" in schema.names
|
||||
assert schema.field("path").type == pa.string()
|
||||
|
||||
|
||||
def test_infer_schema_returns_empty_schema_on_empty_manifest(tmp_path):
|
||||
datasource = ParquetDatasourceV2([str(tmp_path)])
|
||||
empty = FileManifest.construct_manifest([], [], [])
|
||||
schema = datasource.infer_schema(empty)
|
||||
assert schema.names == []
|
||||
|
||||
|
||||
def test_create_scanner_returns_parquet_scanner(tmp_path):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)])
|
||||
schema = datasource.infer_schema(_manifest_of([str(file_path)]))
|
||||
scanner = datasource.create_scanner(schema)
|
||||
|
||||
assert isinstance(scanner, ParquetScanner)
|
||||
assert scanner.schema == schema
|
||||
|
||||
|
||||
def test_get_size_estimator_returns_parquet_estimator(tmp_path):
|
||||
datasource = ParquetDatasourceV2([str(tmp_path)])
|
||||
assert isinstance(datasource.get_size_estimator(), ParquetInMemorySizeEstimator)
|
||||
|
||||
|
||||
def test_paths_and_filesystem_resolved(tmp_path):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)])
|
||||
# _resolve_paths_and_filesystem produces a concrete filesystem even when
|
||||
# the caller passed None.
|
||||
assert datasource.filesystem is not None
|
||||
assert len(datasource.paths) == 1
|
||||
|
||||
|
||||
def test_infer_schema_with_include_row_hash(tmp_path):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1, 2]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)], include_row_hash=True)
|
||||
schema = datasource.infer_schema(_manifest_of([str(file_path)]))
|
||||
|
||||
assert "row_hash" in schema.names
|
||||
assert schema.field("row_hash").type == pa.uint64()
|
||||
|
||||
|
||||
def test_infer_schema_with_include_row_hash_existing_column_promoted_to_uint64(
|
||||
tmp_path,
|
||||
):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"val": [1, 2], "row_hash": [10, 20]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)], include_row_hash=True)
|
||||
schema = datasource.infer_schema(_manifest_of([str(file_path)]))
|
||||
|
||||
assert schema.field("row_hash").type == pa.uint64()
|
||||
|
||||
|
||||
def test_create_scanner_propagates_include_row_hash(tmp_path):
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)], include_row_hash=True)
|
||||
schema = datasource.infer_schema(_manifest_of([str(file_path)]))
|
||||
scanner = datasource.create_scanner(schema)
|
||||
|
||||
assert scanner.include_row_hash is True
|
||||
|
||||
|
||||
def test_nested_fallback_handles_schema_evolution(tmp_path, monkeypatch):
|
||||
"""Regression: when the nested-type fallback fires on a fragment that
|
||||
lacks a filter-referenced column, the V2 reader must null-fill the
|
||||
missing column instead of letting pyarrow raise. Matches the
|
||||
scanner path, which null-fills via dataset-level schema pinning.
|
||||
"""
|
||||
import pyarrow.dataset as pds
|
||||
|
||||
from ray.data._internal.datasource import parquet_datasource
|
||||
from ray.data._internal.datasource_v2.readers.parquet_file_reader import (
|
||||
ParquetFileReader,
|
||||
)
|
||||
from ray.data.expressions import col
|
||||
|
||||
_write_parquet(
|
||||
str(tmp_path / "with_b.parquet"),
|
||||
pa.table({"a": [1, 2, 3], "b": [10, 20, 30]}),
|
||||
)
|
||||
_write_parquet(
|
||||
str(tmp_path / "without_b.parquet"),
|
||||
pa.table({"a": [4, 5, 6]}),
|
||||
)
|
||||
|
||||
unified_schema = pa.schema([("a", pa.int64()), ("b", pa.int64())])
|
||||
predicate = col("b") > 15
|
||||
|
||||
# Force the fallback path; the source-module attribute is what V2's
|
||||
# function-local import resolves to on each call.
|
||||
monkeypatch.setattr(
|
||||
parquet_datasource, "_needs_nested_type_fallback", lambda *a, **kw: True
|
||||
)
|
||||
|
||||
reader = ParquetFileReader(
|
||||
columns=["a"], predicate=predicate, schema=unified_schema
|
||||
)
|
||||
dataset = pds.dataset(str(tmp_path), format="parquet", schema=unified_schema)
|
||||
scanner_kwargs = {
|
||||
"columns": ["a"],
|
||||
"filter": predicate.to_pyarrow(),
|
||||
"batch_size": None,
|
||||
}
|
||||
|
||||
rows_by_fragment = {}
|
||||
for fragment in dataset.get_fragments():
|
||||
tables = list(reader._iter_fragment_tables(fragment, scanner_kwargs))
|
||||
rows_by_fragment[os.path.basename(fragment.path)] = sum(
|
||||
t.num_rows for t in tables
|
||||
)
|
||||
|
||||
# with_b: rows where b > 15 → 2 rows (b=20, b=30)
|
||||
# without_b: b is null-filled → null > 15 is null → 0 rows
|
||||
assert rows_by_fragment == {"with_b.parquet": 2, "without_b.parquet": 0}
|
||||
|
||||
|
||||
def test_datasource_defaults_to_parquet_file_chunker(tmp_path):
|
||||
"""``ParquetDatasourceV2`` plugs ``ParquetFileChunker`` into its indexer."""
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1, 2, 3]}))
|
||||
|
||||
datasource = ParquetDatasourceV2([str(file_path)])
|
||||
indexer = datasource._get_file_indexer()
|
||||
assert isinstance(indexer.file_chunker, ParquetFileChunker)
|
||||
|
||||
|
||||
def test_datasource_accepts_custom_chunker(tmp_path):
|
||||
"""An explicit ``file_chunker`` override propagates to the indexer."""
|
||||
file_path = tmp_path / "data.parquet"
|
||||
_write_parquet(str(file_path), pa.table({"a": [1, 2, 3]}))
|
||||
|
||||
custom = WholeFileChunker()
|
||||
datasource = ParquetDatasourceV2([str(file_path)], file_chunker=custom)
|
||||
indexer = datasource._get_file_indexer()
|
||||
assert indexer.file_chunker is custom
|
||||
|
||||
|
||||
def _write_multi_row_group_parquet(path, num_rows: int, row_group_size: int):
|
||||
table = pa.table({"id": list(range(num_rows))})
|
||||
pq.write_table(table, path, row_group_size=row_group_size)
|
||||
return table
|
||||
|
||||
|
||||
def test_fragments_from_chunk_metadata_subsets_by_row_group(tmp_path):
|
||||
"""``_fragments_from_chunk_metadata`` slices a fragment to the explicit range."""
|
||||
import pyarrow.dataset as pds
|
||||
|
||||
file_path = str(tmp_path / "multi.parquet")
|
||||
# 1000 rows, 100 row groups (row_group_size=10).
|
||||
_write_multi_row_group_parquet(file_path, num_rows=1000, row_group_size=10)
|
||||
|
||||
dataset = pds.dataset(file_path, format="parquet")
|
||||
(fragment,) = dataset.get_fragments()
|
||||
assert fragment.metadata.num_row_groups == 100
|
||||
|
||||
# Explicit range [25, 50) → 25 row groups, starting row offset 250.
|
||||
chunk_md = create_chunk_metadata(
|
||||
ParquetFileChunkMetadata, row_group_start=25, row_group_end=50
|
||||
)
|
||||
sub_fragments = _fragments_from_chunk_metadata(fragment, chunk_md)
|
||||
assert len(sub_fragments) == 25
|
||||
expected_offset = 250 # 25 row groups × 10 rows each precede the range.
|
||||
for sub, offset in sub_fragments:
|
||||
assert len(sub.row_groups) == 1
|
||||
assert offset == expected_offset
|
||||
expected_offset += sub.metadata.row_group(sub.row_groups[0].id).num_rows
|
||||
|
||||
|
||||
def test_fragments_from_chunk_metadata_clamps_range_beyond_row_groups(tmp_path):
|
||||
"""A range beyond the file's actual row-group count is clamped (no crash)."""
|
||||
import pyarrow.dataset as pds
|
||||
|
||||
file_path = str(tmp_path / "single.parquet")
|
||||
# 5 rows, single row group.
|
||||
_write_multi_row_group_parquet(file_path, num_rows=5, row_group_size=5)
|
||||
|
||||
dataset = pds.dataset(file_path, format="parquet")
|
||||
(fragment,) = dataset.get_fragments()
|
||||
assert fragment.metadata.num_row_groups == 1
|
||||
|
||||
# Fully out-of-range [5, 6) → clamped to [1, 1) → no sub-fragments.
|
||||
chunk_md = create_chunk_metadata(
|
||||
ParquetFileChunkMetadata, row_group_start=5, row_group_end=6
|
||||
)
|
||||
assert _fragments_from_chunk_metadata(fragment, chunk_md) == []
|
||||
|
||||
# Partially out-of-range [0, 9) → clamped to [0, 1) → the one real row group.
|
||||
chunk_md = create_chunk_metadata(
|
||||
ParquetFileChunkMetadata, row_group_start=0, row_group_end=9
|
||||
)
|
||||
sub_fragments = _fragments_from_chunk_metadata(fragment, chunk_md)
|
||||
assert len(sub_fragments) == 1
|
||||
assert sub_fragments[0][1] == 0 # row offset
|
||||
|
||||
|
||||
def _read_via_reader(reader, manifest):
|
||||
return list(reader.read(manifest))
|
||||
|
||||
|
||||
def test_parquet_file_reader_reads_chunked_manifest(tmp_path):
|
||||
"""End-to-end: a manifest with per-chunk rows is read into the same rows
|
||||
as a single whole-file manifest."""
|
||||
file_path = str(tmp_path / "data.parquet")
|
||||
expected_rows = 200
|
||||
_write_multi_row_group_parquet(file_path, num_rows=expected_rows, row_group_size=20)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
reader_whole = ParquetFileReader()
|
||||
whole_manifest = FileManifest.construct_manifest([file_path], [file_size], [None])
|
||||
whole_tables = _read_via_reader(reader_whole, whole_manifest)
|
||||
whole_rows = pa.concat_tables(whole_tables).column("id").to_pylist()
|
||||
|
||||
# target_chunk_size=1 forces one chunk per row group.
|
||||
chunker = ParquetFileChunker(target_chunk_size=1)
|
||||
chunks = list(chunker.generate_chunk_metadatas(file_path, file_size))
|
||||
assert len(chunks) > 1, "test setup expects ParquetFileChunker to chunk"
|
||||
|
||||
paths = [file_path] * len(chunks)
|
||||
chunk_metadatas = [md for md, _ in chunks]
|
||||
chunk_sizes = [sz for _, sz in chunks]
|
||||
chunked_manifest = FileManifest.construct_manifest(
|
||||
paths, chunk_sizes, chunk_metadatas
|
||||
)
|
||||
|
||||
reader_chunked = ParquetFileReader()
|
||||
chunked_tables = _read_via_reader(reader_chunked, chunked_manifest)
|
||||
chunked_rows = pa.concat_tables(chunked_tables).column("id").to_pylist()
|
||||
|
||||
assert sorted(chunked_rows) == sorted(whole_rows) == list(range(expected_rows))
|
||||
|
||||
|
||||
def test_parquet_file_reader_chunked_row_hashes_are_unique(tmp_path):
|
||||
"""Row hashes must remain unique across chunked sub-fragments of the
|
||||
same file.
|
||||
|
||||
Regression: ``_read_fragments_sequential`` previously reseeded
|
||||
``offset=0`` for every fragment. Since chunked sub-fragments share
|
||||
``fragment.path``, ``_compute_row_hashes(path, 0, n)`` collided across
|
||||
row groups of the same file.
|
||||
"""
|
||||
file_path = str(tmp_path / "data.parquet")
|
||||
expected_rows = 200
|
||||
_write_multi_row_group_parquet(file_path, num_rows=expected_rows, row_group_size=20)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
chunker = ParquetFileChunker(target_chunk_size=1)
|
||||
chunks = list(chunker.generate_chunk_metadatas(file_path, file_size))
|
||||
assert len(chunks) > 1, "test setup expects ParquetFileChunker to chunk"
|
||||
|
||||
paths = [file_path] * len(chunks)
|
||||
chunk_metadatas = [md for md, _ in chunks]
|
||||
chunk_sizes = [sz for _, sz in chunks]
|
||||
chunked_manifest = FileManifest.construct_manifest(
|
||||
paths, chunk_sizes, chunk_metadatas
|
||||
)
|
||||
|
||||
reader = ParquetFileReader(include_row_hash=True)
|
||||
chunked_tables = list(reader.read(chunked_manifest))
|
||||
hashes = pa.concat_tables(chunked_tables).column("row_hash").to_pylist()
|
||||
assert len(hashes) == expected_rows
|
||||
assert (
|
||||
len(set(hashes)) == expected_rows
|
||||
), "row_hash must be unique across chunked sub-fragments of one file"
|
||||
|
||||
|
||||
def test_parquet_file_reader_handles_out_of_range_chunks(tmp_path):
|
||||
"""Defensively clamped out-of-range chunk metadata yields no rows, no crash.
|
||||
|
||||
The chunker never emits out-of-range ranges (they're computed from the
|
||||
same footer the reader sees), but a hand-constructed range beyond the
|
||||
file's row groups must be handled gracefully.
|
||||
"""
|
||||
file_path = str(tmp_path / "tiny.parquet")
|
||||
# 5 rows, single row group.
|
||||
_write_multi_row_group_parquet(file_path, num_rows=5, row_group_size=5)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Explicit range entirely beyond the file's one row group.
|
||||
out_of_range = create_chunk_metadata(
|
||||
ParquetFileChunkMetadata, row_group_start=3, row_group_end=4
|
||||
)
|
||||
manifest = FileManifest.construct_manifest([file_path], [file_size], [out_of_range])
|
||||
|
||||
reader = ParquetFileReader()
|
||||
tables = list(reader.read(manifest))
|
||||
assert sum(t.num_rows for t in tables) == 0
|
||||
Reference in New Issue
Block a user