chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ray.data._internal.pandas_block import PandasBlockAccessor
|
||||
from ray.data._internal.tensor_extensions.arrow import pyarrow_table_from_pydict
|
||||
from ray.data.context import DataContext
|
||||
from ray.data.datasource.file_based_datasource import FileBasedDatasource
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pyarrow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JSON_FILE_EXTENSIONS = [
|
||||
"json",
|
||||
"jsonl",
|
||||
# gzip-compressed files
|
||||
"json.gz",
|
||||
"jsonl.gz",
|
||||
# Brotli-compressed fi;es
|
||||
"json.br",
|
||||
"jsonl.br",
|
||||
# Zstandard-compressed files
|
||||
"json.zst",
|
||||
"jsonl.zst",
|
||||
# lz4-compressed files
|
||||
"json.lz4",
|
||||
"jsonl.lz4",
|
||||
]
|
||||
|
||||
|
||||
class ArrowJSONDatasource(FileBasedDatasource):
|
||||
"""JSON datasource, for reading and writing JSON and JSONL files."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
paths: Union[str, List[str]],
|
||||
*,
|
||||
arrow_json_args: Optional[Dict[str, Any]] = None,
|
||||
**file_based_datasource_kwargs,
|
||||
):
|
||||
from pyarrow import json
|
||||
|
||||
super().__init__(paths, **file_based_datasource_kwargs)
|
||||
|
||||
if arrow_json_args is None:
|
||||
arrow_json_args = {}
|
||||
|
||||
self.read_options = arrow_json_args.pop(
|
||||
"read_options", json.ReadOptions(use_threads=False)
|
||||
)
|
||||
self.arrow_json_args = arrow_json_args
|
||||
|
||||
def _read_with_pyarrow_read_json(self, buffer: "pyarrow.lib.Buffer"):
|
||||
"""Read with PyArrow JSON reader, trying to auto-increase the
|
||||
read block size in the case of the read object
|
||||
straddling block boundaries."""
|
||||
import pyarrow as pa
|
||||
import pyarrow.json as pajson
|
||||
|
||||
# When reading large files, the default block size configured in PyArrow can be
|
||||
# too small, resulting in the following error: `pyarrow.lib.ArrowInvalid:
|
||||
# straddling object straddles two block boundaries (try to increase block
|
||||
# size?)`. More information on this issue can be found here:
|
||||
# https://github.com/apache/arrow/issues/25674
|
||||
# The read will be retried with geometrically increasing block size
|
||||
# until the size reaches `DataContext.get_current().target_max_block_size`.
|
||||
# The initial block size will start at the PyArrow default block size
|
||||
# or it can be manually set through the `read_options` parameter as follows.
|
||||
# >>> import pyarrow.json as pajson
|
||||
# >>> block_size = 10 << 20 # Set block size to 10MB
|
||||
# >>> ray.data.read_json( # doctest: +SKIP
|
||||
# ... "s3://anonymous@ray-example-data/log.json",
|
||||
# ... read_options=pajson.ReadOptions(block_size=block_size)
|
||||
# ... )
|
||||
|
||||
init_block_size = self.read_options.block_size
|
||||
max_block_size = DataContext.get_current().target_max_block_size
|
||||
while True:
|
||||
try:
|
||||
yield pajson.read_json(
|
||||
io.BytesIO(buffer),
|
||||
read_options=self.read_options,
|
||||
**self.arrow_json_args,
|
||||
)
|
||||
self.read_options.block_size = init_block_size
|
||||
break
|
||||
except pa.ArrowInvalid as e:
|
||||
if "straddling object straddles two block boundaries" in str(e):
|
||||
if (
|
||||
max_block_size is None
|
||||
or self.read_options.block_size < max_block_size
|
||||
):
|
||||
# Increase the block size in case it was too small.
|
||||
logger.debug(
|
||||
f"JSONDatasource read failed with "
|
||||
f"block_size={self.read_options.block_size}. Retrying with "
|
||||
f"block_size={self.read_options.block_size * 2}."
|
||||
)
|
||||
self.read_options.block_size *= 2
|
||||
else:
|
||||
raise pa.ArrowInvalid(
|
||||
f"{e} - Auto-increasing block size to "
|
||||
f"{self.read_options.block_size} bytes failed. "
|
||||
f"Please try manually increasing the block size through "
|
||||
f"the `read_options` parameter to a larger size. "
|
||||
f"For example: `read_json(..., read_options="
|
||||
f"pyarrow.json.ReadOptions(block_size=10 << 25))`"
|
||||
f"More information on this issue can be found here: "
|
||||
f"https://github.com/apache/arrow/issues/25674"
|
||||
)
|
||||
else:
|
||||
# unrelated error, simply reraise
|
||||
raise e
|
||||
|
||||
def _read_with_python_json(self, buffer: "pyarrow.lib.Buffer"):
|
||||
"""Fallback method to read JSON files with Python's native json.load(),
|
||||
in case the default pyarrow json reader fails."""
|
||||
import json
|
||||
|
||||
import pyarrow as pa
|
||||
|
||||
# Check if the buffer is empty
|
||||
if buffer.size == 0:
|
||||
return
|
||||
|
||||
parsed_json = json.load(io.BytesIO(buffer))
|
||||
try:
|
||||
yield pa.Table.from_pylist(parsed_json)
|
||||
except AttributeError as e:
|
||||
# For PyArrow < 7.0.0, `pa.Table.from_pylist()` is not available.
|
||||
# Construct a dict from the list and call
|
||||
# `pa.Table.from_pydict()` instead.
|
||||
assert "no attribute 'from_pylist'" in str(e), str(e)
|
||||
from collections import defaultdict
|
||||
|
||||
dct = defaultdict(list)
|
||||
for row in parsed_json:
|
||||
for k, v in row.items():
|
||||
dct[k].append(v)
|
||||
yield pyarrow_table_from_pydict(dct)
|
||||
|
||||
# TODO(ekl) The PyArrow JSON reader doesn't support streaming reads.
|
||||
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
|
||||
import pyarrow as pa
|
||||
|
||||
buffer: pa.lib.Buffer = f.read_buffer()
|
||||
|
||||
try:
|
||||
yield from self._read_with_pyarrow_read_json(buffer)
|
||||
except pa.ArrowInvalid as e:
|
||||
# If read with PyArrow fails, try falling back to native json.load().
|
||||
logger.warning(
|
||||
f"Error reading with pyarrow.json.read_json(). "
|
||||
f"Falling back to native json.load(), which may be slower. "
|
||||
f"PyArrow error was:\n{e}"
|
||||
)
|
||||
yield from self._read_with_python_json(buffer)
|
||||
|
||||
|
||||
class PandasJSONDatasource(FileBasedDatasource):
|
||||
|
||||
# Buffer size in bytes for reading files. Default is 1MB.
|
||||
#
|
||||
# pandas reads data in small chunks (~8 KiB), which leads to many costly
|
||||
# small read requests when accessing cloud storage. To reduce overhead and
|
||||
# improve performance, we wrap the file in a larger buffered reader that
|
||||
# reads bigger blocks at once.
|
||||
_BUFFER_SIZE = 1024**2
|
||||
|
||||
# In the case of zipped json files, we cannot infer the chunk_size.
|
||||
_DEFAULT_CHUNK_SIZE = 10000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
paths: Union[str, List[str]],
|
||||
target_output_size_bytes: int,
|
||||
**file_based_datasource_kwargs,
|
||||
):
|
||||
super().__init__(paths, **file_based_datasource_kwargs)
|
||||
|
||||
self._target_output_size_bytes = target_output_size_bytes
|
||||
|
||||
def _read_stream(self, f: "pyarrow.NativeFile", path: str):
|
||||
chunksize = self._estimate_chunksize(f)
|
||||
|
||||
with StrictBufferedReader(f, buffer_size=self._BUFFER_SIZE) as stream:
|
||||
if chunksize is None:
|
||||
# When chunksize=None, pandas returns DataFrame directly
|
||||
# (no context manager).
|
||||
df = pd.read_json(stream, chunksize=chunksize, lines=True)
|
||||
yield _cast_range_index_to_string(df)
|
||||
else:
|
||||
# When chunksize is a number, pandas returns JsonReader
|
||||
# (supports context manager).
|
||||
with pd.read_json(stream, chunksize=chunksize, lines=True) as reader:
|
||||
for df in reader:
|
||||
yield _cast_range_index_to_string(df)
|
||||
|
||||
def _estimate_chunksize(self, f: "pyarrow.NativeFile") -> Optional[int]:
|
||||
"""Estimate the chunksize by sampling the first row.
|
||||
|
||||
This is necessary to avoid OOMs while reading the file.
|
||||
"""
|
||||
|
||||
if not f.seekable():
|
||||
return self._DEFAULT_CHUNK_SIZE
|
||||
|
||||
# ``_read_stream`` can be recreated on the same file handle when
|
||||
# ``FileBasedDatasource`` retries a transient read error.
|
||||
f.seek(0)
|
||||
|
||||
if self._target_output_size_bytes is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with StrictBufferedReader(f, buffer_size=self._BUFFER_SIZE) as stream:
|
||||
with pd.read_json(stream, chunksize=1, lines=True) as reader:
|
||||
try:
|
||||
df = _cast_range_index_to_string(next(reader))
|
||||
except StopIteration:
|
||||
return 1
|
||||
|
||||
block_accessor = PandasBlockAccessor.for_block(df)
|
||||
if block_accessor.num_rows() == 0:
|
||||
chunksize = 1
|
||||
else:
|
||||
bytes_per_row = block_accessor.size_bytes() / block_accessor.num_rows()
|
||||
chunksize = max(
|
||||
round(self._target_output_size_bytes / bytes_per_row), 1
|
||||
)
|
||||
|
||||
return chunksize
|
||||
finally:
|
||||
# Reset file pointer to the beginning for the actual read and for any
|
||||
# subsequent retry that reuses the same file handle.
|
||||
f.seek(0)
|
||||
|
||||
def _open_input_source(
|
||||
self,
|
||||
filesystem: "pyarrow.fs.FileSystem",
|
||||
path: str,
|
||||
**open_args,
|
||||
) -> "pyarrow.NativeFile":
|
||||
|
||||
compression = self.resolve_compression(path, open_args)
|
||||
|
||||
if compression is None:
|
||||
# We use a seekable file to estimate chunksize.
|
||||
return filesystem.open_input_file(path)
|
||||
|
||||
return super()._open_input_source(filesystem, path, **open_args)
|
||||
|
||||
|
||||
def _cast_range_index_to_string(df: pd.DataFrame):
|
||||
# NOTE: PandasBlockAccessor doesn't support RangeIndex, so we need to convert
|
||||
# to string.
|
||||
if isinstance(df.columns, pd.RangeIndex):
|
||||
df.columns = df.columns.astype(str)
|
||||
return df
|
||||
|
||||
|
||||
class StrictBufferedReader(io.RawIOBase):
|
||||
"""Wrapper that prevents premature file closure and ensures full-buffered reads.
|
||||
|
||||
This is necessary for two reasons:
|
||||
1. The datasource reads the file twice -- first to sample and determine the chunk size,
|
||||
and again to load the actual data. Since pandas assumes ownership of the file and
|
||||
may close it, we prevent that by explicitly detaching the underlying file before
|
||||
closing the buffer.
|
||||
|
||||
2. pandas wraps the file in a TextIOWrapper to decode bytes into text. TextIOWrapper
|
||||
prefers calling read1(), which doesn't prefetch for random-access files
|
||||
(e.g., from PyArrow). This wrapper forces all reads through the full buffer to
|
||||
avoid inefficient small-range S3 GETs.
|
||||
"""
|
||||
|
||||
def __init__(self, file: io.RawIOBase, buffer_size: int):
|
||||
self._file = io.BufferedReader(file, buffer_size=buffer_size)
|
||||
|
||||
def read(self, size=-1, /):
|
||||
return self._file.read(size)
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if not self.closed:
|
||||
self._file.detach()
|
||||
super().close()
|
||||
Reference in New Issue
Block a user