3355 lines
116 KiB
Python
3355 lines
116 KiB
Python
import contextlib
|
||
import os
|
||
import pathlib
|
||
import pickle
|
||
import shutil
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Optional, Union
|
||
from unittest.mock import MagicMock
|
||
|
||
import fsspec
|
||
import numpy as np
|
||
import pandas as pd
|
||
import pyarrow as pa
|
||
import pyarrow.dataset as pds
|
||
import pyarrow.parquet as pq
|
||
import pytest
|
||
from packaging.version import parse as parse_version
|
||
from pyarrow.fs import FSSpecHandler, PyFileSystem
|
||
from pytest_lazy_fixtures import lf as lazy_fixture
|
||
|
||
import ray
|
||
from ray.data import FileShuffleConfig, Schema
|
||
from ray.data._internal.datasource.parquet_datasource import (
|
||
_MAX_PYARROW_TO_BATCHES_BATCH_SIZE,
|
||
ParquetDatasource,
|
||
_coerce_pyarrow_fragment_batch_size,
|
||
_read_batches_from,
|
||
)
|
||
from ray.data._internal.execution.interfaces.ref_bundle import (
|
||
_ref_bundles_iterator_to_block_refs_list,
|
||
)
|
||
from ray.data._internal.object_extensions.arrow import ArrowPythonObjectType
|
||
from ray.data._internal.tensor_extensions.arrow import (
|
||
get_arrow_extension_fixed_shape_tensor_types,
|
||
)
|
||
from ray.data._internal.util import explain_plan, rows_same
|
||
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
|
||
from ray.data.block import BlockAccessor
|
||
from ray.data.context import DataContext
|
||
from ray.data.datasource.partitioning import Partitioning, PathPartitionFilter
|
||
from ray.data.datasource.path_util import _unwrap_protocol
|
||
from ray.data.tests.conftest import * # noqa
|
||
from ray.data.tests.mock_http_server import * # noqa
|
||
from ray.data.tests.test_util import ConcurrencyCounter # noqa
|
||
from ray.tests.conftest import * # noqa
|
||
|
||
|
||
@pytest.fixture(params=[False, True], ids=["v1", "v2"])
|
||
def use_datasource_v2(request, restore_data_context):
|
||
restore_data_context.use_datasource_v2 = request.param
|
||
|
||
|
||
def test_read_parquet_allows_pickle_object_columns_with_env_var(
|
||
tmp_path, shutdown_only, use_datasource_v2, monkeypatch
|
||
):
|
||
# Set the environment variable on both the driver and the worker processes.
|
||
monkeypatch.setenv("RAY_DATA_AUTOLOAD_PICKLE_OBJECT_SCALAR", "1")
|
||
ray.init(runtime_env={"env_vars": {"RAY_DATA_AUTOLOAD_PICKLE_OBJECT_SCALAR": "1"}})
|
||
|
||
ext_type = ArrowPythonObjectType()
|
||
storage = pa.array([pickle.dumps({"key": "value"})], type=ext_type.storage_type)
|
||
table = pa.table({"col": pa.ExtensionArray.from_storage(ext_type, storage)})
|
||
pq.write_table(table, str(tmp_path / "data.parquet"))
|
||
|
||
ds = ray.data.read_parquet(str(tmp_path))
|
||
rows = ds.take_all()
|
||
|
||
assert len(rows) == 1
|
||
assert rows[0]["col"] == {"key": "value"}
|
||
|
||
|
||
def test_read_parquet_rejects_pickle_object_columns(
|
||
tmp_path, ray_start_regular_shared, use_datasource_v2
|
||
):
|
||
marker = tmp_path / "exploit_marker"
|
||
|
||
class Exploit:
|
||
def __reduce__(self):
|
||
import os
|
||
|
||
return (os.system, (f"touch {marker}",))
|
||
|
||
ext_type = ArrowPythonObjectType()
|
||
storage = pa.array([pickle.dumps(Exploit())], type=ext_type.storage_type)
|
||
table = pa.table({"col": pa.ExtensionArray.from_storage(ext_type, storage)})
|
||
pq.write_table(table, str(tmp_path / "data.parquet"))
|
||
|
||
ds = ray.data.read_parquet(str(tmp_path))
|
||
with pytest.raises(Exception, match="arrow_pickled_object"):
|
||
ds.take_all()
|
||
|
||
assert not marker.exists(), "pickle.load executed attacker code"
|
||
|
||
|
||
def test_write_parquet_handles_per_block_column_reorder(
|
||
ray_start_regular_shared, tmp_path
|
||
):
|
||
# When the Write task receives multiple blocks whose schemas share the same
|
||
# field names in a different order, `pa.unify_schemas` fixes the column
|
||
# order from the first block. Previously the per-block `Table.cast` was
|
||
# positional and rejected the second block; ParquetDatasink now reorders
|
||
# columns by name before casting.
|
||
from ray.data._internal.datasource.parquet_datasink import (
|
||
WRITE_UUID_KWARG_NAME,
|
||
ParquetDatasink,
|
||
)
|
||
from ray.data._internal.execution.interfaces import TaskContext
|
||
|
||
t1 = pa.table({"x": [1], "y": [2]})
|
||
t2 = pa.table({"y": [3], "x": [4]})
|
||
sink = ParquetDatasink(path=str(tmp_path))
|
||
ctx = TaskContext(task_idx=0, op_name="Write")
|
||
ctx.kwargs = {WRITE_UUID_KWARG_NAME: "wuid"}
|
||
|
||
sink.write([t1, t2], ctx)
|
||
|
||
out = pq.read_table(str(tmp_path))
|
||
assert sorted(out.column_names) == ["x", "y"]
|
||
assert out.num_rows == 2
|
||
# Pair each row's (x, y) regardless of the unified output order.
|
||
assert sorted(zip(out.column("x").to_pylist(), out.column("y").to_pylist())) == [
|
||
(1, 2),
|
||
(4, 3),
|
||
]
|
||
|
||
|
||
def test_widen_offset_overflowing_columns(monkeypatch):
|
||
# Unit test for the schema-promotion helper. Only `string`/`binary` columns
|
||
# whose combined size across the blocks exceeds the int32 offset limit
|
||
# (2 GiB) should be promoted to their `large_*` variant; everything else is
|
||
# left untouched. The real limit is impractical to allocate, so patch the
|
||
# threshold low and check the decision boundary directly.
|
||
from ray.data._internal.datasource import parquet_datasink
|
||
from ray.data._internal.datasource.parquet_datasink import (
|
||
_widen_offset_overflowing_columns,
|
||
)
|
||
|
||
monkeypatch.setattr(parquet_datasink, "INT32_MAX", 1024)
|
||
|
||
big, tiny = "z" * 600, "x"
|
||
t1 = pa.table(
|
||
{
|
||
"id": pa.array([0, 1]), # not variable-width
|
||
"big_str": pa.array([big, big]), # combined > 1024 -> promote
|
||
"big_bin": pa.array([big.encode(), big.encode()]), # -> large_binary
|
||
"small_str": pa.array([tiny, tiny]), # combined < 1024 -> untouched
|
||
}
|
||
)
|
||
t2 = pa.table(
|
||
{
|
||
"id": pa.array([2, 3]),
|
||
"big_str": pa.array([big, big]),
|
||
"big_bin": pa.array([big.encode(), big.encode()]),
|
||
"small_str": pa.array([tiny, tiny]),
|
||
}
|
||
)
|
||
schema = pa.unify_schemas([t1.schema, t2.schema])
|
||
|
||
widened = _widen_offset_overflowing_columns([t1, t2], schema)
|
||
|
||
assert widened.field("big_str").type == pa.large_string()
|
||
assert widened.field("big_bin").type == pa.large_binary()
|
||
assert widened.field("small_str").type == pa.string()
|
||
assert widened.field("id").type == pa.int64()
|
||
|
||
# When nothing overflows, the original schema is returned unchanged.
|
||
monkeypatch.setattr(parquet_datasink, "INT32_MAX", 1 << 40)
|
||
assert _widen_offset_overflowing_columns([t1, t2], schema) is schema
|
||
|
||
|
||
def test_write_parquet_string_column_over_2gib_e2e(ray_start_regular_shared, tmp_path):
|
||
# End-to-end through the ray.data API: a dataset whose `payload` column, once
|
||
# the writer coalesces blocks into a single row group (forced by
|
||
# `min_rows_per_file`), exceeds Arrow's 2 GiB int32-offset `string` limit.
|
||
# Each individual value stays small, so only the *cumulative* size overflows.
|
||
#
|
||
# Pre-fix the write task died with an offset-overflow / column-length
|
||
# mismatch; ParquetDatasink now promotes the column to `large_string`.
|
||
num_rows, row_bytes = 1100, 2_000_000 # ~2.05 GiB combined, just over 2 GiB
|
||
|
||
def add_payload(batch):
|
||
# Reusing one string keeps driver-side memory ~row_bytes; Ray
|
||
# materializes per-row copies into the object store.
|
||
batch["payload"] = ["z" * row_bytes] * len(batch["id"])
|
||
return batch
|
||
|
||
ds = ray.data.range(num_rows).map_batches(add_payload, batch_format="numpy")
|
||
# min_rows_per_file makes the write coalesce all blocks into one row group.
|
||
ds.write_parquet(str(tmp_path), min_rows_per_file=num_rows)
|
||
|
||
out = pq.read_table(str(tmp_path), columns=["id"])
|
||
assert out.num_rows == num_rows
|
||
# The oversized variable-width column was promoted to dodge int32 offsets.
|
||
full_schema = pq.read_schema(str(next(pathlib.Path(tmp_path).glob("*.parquet"))))
|
||
assert full_schema.field("payload").type == pa.large_string()
|
||
|
||
|
||
def test_write_parquet_supports_gzip(ray_start_regular_shared, tmp_path):
|
||
ray.data.range(1).write_parquet(tmp_path, compression="gzip")
|
||
|
||
# Test that all written files are gzip compressed.
|
||
for filename in os.listdir(tmp_path):
|
||
file_metadata = pq.ParquetFile(tmp_path / filename).metadata
|
||
compression = file_metadata.row_group(0).column(0).compression
|
||
assert compression == "GZIP", compression
|
||
|
||
# Test that you can read the written files.
|
||
assert pq.read_table(tmp_path).to_pydict() == {"id": [0]}
|
||
|
||
|
||
def test_write_parquet_partition_cols(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
num_partitions = 10
|
||
rows_per_partition = 10
|
||
num_rows = num_partitions * rows_per_partition
|
||
|
||
df = pd.DataFrame(
|
||
{
|
||
"a": list(range(num_partitions)) * rows_per_partition,
|
||
"b": list(range(num_partitions)) * rows_per_partition,
|
||
"c": list(range(num_rows)),
|
||
"d": list(range(num_rows)),
|
||
# Make sure algorithm does not fail for tensor types.
|
||
"e": list(np.random.random((num_rows, 128))),
|
||
}
|
||
)
|
||
|
||
ds = ray.data.from_pandas(df)
|
||
ds.write_parquet(tmp_path, partition_cols=["a", "b"])
|
||
|
||
# Test that files are written in partition style
|
||
for i in range(num_partitions):
|
||
partition = os.path.join(tmp_path, f"a={i}", f"b={i}")
|
||
ds_partition = ray.data.read_parquet(partition)
|
||
dsf_partition = ds_partition.to_pandas()
|
||
c_expected = [k * i for k in range(rows_per_partition)].sort()
|
||
d_expected = [k * i for k in range(rows_per_partition)].sort()
|
||
assert c_expected == dsf_partition["c"].tolist().sort()
|
||
assert d_expected == dsf_partition["d"].tolist().sort()
|
||
assert dsf_partition["e"].shape == (rows_per_partition,)
|
||
|
||
# Test that partition are read back properly into original dataset schema
|
||
ds1 = ray.data.read_parquet(tmp_path)
|
||
assert set(ds.schema().names) == set(ds1.schema().names)
|
||
assert ds.count() == ds1.count()
|
||
|
||
df = df.sort_values(by=["a", "b", "c", "d"])
|
||
df1 = ds1.to_pandas().sort_values(by=["a", "b", "c", "d"])
|
||
for (index1, row1), (index2, row2) in zip(df.iterrows(), df1.iterrows()):
|
||
row1_dict = row1.to_dict()
|
||
row2_dict = row2.to_dict()
|
||
assert row1_dict["c"] == row2_dict["c"]
|
||
assert row1_dict["d"] == row2_dict["d"]
|
||
|
||
|
||
def test_include_paths(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"animals": ["cat", "dog"]})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path, include_paths=True)
|
||
|
||
# Verify that the path column is present in the schema
|
||
schema_names = ds.schema().names
|
||
assert "path" in schema_names, f"'path' column not found in schema: {schema_names}"
|
||
|
||
paths = [row["path"] for row in ds.take_all()]
|
||
assert paths == [path, path]
|
||
|
||
|
||
def test_include_paths_with_column_projection(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
target_max_block_size_infinite_or_default,
|
||
use_datasource_v2,
|
||
):
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"animals": ["cat", "dog"], "id": [1, 2]})
|
||
pq.write_table(table, path)
|
||
|
||
# Exercises the deprecated ``columns=`` arg: V1 retained ``"path"``
|
||
# implicitly under ``include_paths=True``, and read_api preserves that
|
||
# by appending it to the projection on the caller's behalf. The
|
||
# deprecation warning is emitted only on the V2 path.
|
||
if ray.data.DataContext.get_current().use_datasource_v2:
|
||
warn_ctx = pytest.warns(
|
||
DeprecationWarning, match="`columns=` on `read_parquet`"
|
||
)
|
||
else:
|
||
warn_ctx = contextlib.nullcontext()
|
||
with warn_ctx:
|
||
ds = ray.data.read_parquet(path, columns=["id"], include_paths=True)
|
||
|
||
schema_names = ds.schema().names
|
||
assert "id" in schema_names, f"'id' column not found in schema: {schema_names}"
|
||
assert "path" in schema_names, f"'path' column not found in schema: {schema_names}"
|
||
|
||
# Verify that the path column contains the expected paths
|
||
rows = ds.take_all()
|
||
for row in rows:
|
||
assert "path" in row
|
||
assert row["path"] == path
|
||
|
||
|
||
def test_include_row_hash(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"animals": ["cat", "dog", "bird"]})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path, include_row_hash=True)
|
||
|
||
schema_names = ds.schema().names
|
||
assert "row_hash" in schema_names
|
||
|
||
rows = ds.take_all()
|
||
hashes = [row["row_hash"] for row in rows]
|
||
assert len(hashes) == 3
|
||
assert len(set(hashes)) == 3, "Hashes must be unique"
|
||
assert all(isinstance(h, int) for h in hashes)
|
||
|
||
|
||
def test_include_row_hash_reproducible(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"val": list(range(10))})
|
||
pq.write_table(table, path)
|
||
|
||
hashes1 = [
|
||
row["row_hash"]
|
||
for row in ray.data.read_parquet(path, include_row_hash=True).take_all()
|
||
]
|
||
hashes2 = [
|
||
row["row_hash"]
|
||
for row in ray.data.read_parquet(path, include_row_hash=True).take_all()
|
||
]
|
||
assert hashes1 == hashes2, "Hashes must be reproducible across reads"
|
||
|
||
|
||
def test_include_row_hash_unique_across_files(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
for i in range(3):
|
||
path = os.path.join(tmp_path, f"file{i}.parquet")
|
||
table = pa.Table.from_pydict({"val": [i * 10, i * 10 + 1]})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(str(tmp_path), include_row_hash=True)
|
||
rows = ds.take_all()
|
||
hashes = [row["row_hash"] for row in rows]
|
||
assert len(hashes) == 6
|
||
assert len(set(hashes)) == 6, "Hashes must be unique across files"
|
||
|
||
|
||
def test_include_row_hash_same_data_different_files(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
"""Files with identical content must produce different hashes because
|
||
the hash is derived from the file path, not the data."""
|
||
table = pa.Table.from_pydict({"val": [1, 2, 3]})
|
||
for name in ("a.parquet", "b.parquet", "c.parquet"):
|
||
pq.write_table(table, os.path.join(tmp_path, name))
|
||
|
||
ds = ray.data.read_parquet(str(tmp_path), include_row_hash=True)
|
||
rows = ds.take_all()
|
||
hashes = [row["row_hash"] for row in rows]
|
||
assert len(hashes) == 9
|
||
assert (
|
||
len(set(hashes)) == 9
|
||
), "Identical data in different files must produce distinct hashes"
|
||
|
||
|
||
def test_include_row_hash_with_column_projection(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
target_max_block_size_infinite_or_default,
|
||
use_datasource_v2,
|
||
):
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"a": [1, 2], "b": [3, 4]})
|
||
pq.write_table(table, path)
|
||
|
||
# Exercises the deprecated ``columns=`` arg: V1 retained ``"row_hash"``
|
||
# implicitly under ``include_row_hash=True``, and read_api preserves
|
||
# that by appending it to the projection on the caller's behalf. The
|
||
# deprecation warning is emitted only on the V2 path.
|
||
if ray.data.DataContext.get_current().use_datasource_v2:
|
||
warn_ctx = pytest.warns(
|
||
DeprecationWarning, match="`columns=` on `read_parquet`"
|
||
)
|
||
else:
|
||
warn_ctx = contextlib.nullcontext()
|
||
with warn_ctx:
|
||
ds = ray.data.read_parquet(path, columns=["a"], include_row_hash=True)
|
||
schema_names = ds.schema().names
|
||
assert "a" in schema_names
|
||
assert "b" not in schema_names
|
||
assert "row_hash" in schema_names
|
||
|
||
rows = ds.take_all()
|
||
assert len(rows) == 2
|
||
assert all("row_hash" in row and "a" in row and "b" not in row for row in rows)
|
||
|
||
|
||
def test_include_row_hash_with_include_paths(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"val": [1, 2]})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path, include_paths=True, include_row_hash=True)
|
||
schema_names = ds.schema().names
|
||
assert "path" in schema_names
|
||
assert "row_hash" in schema_names
|
||
|
||
df = ds.to_pandas()
|
||
assert "path" in df.columns
|
||
assert len(set(df["row_hash"])) == 2
|
||
|
||
|
||
def test_include_row_hash_existing_column(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
"""When the file already has a 'row_hash' column, it should be
|
||
overwritten by the generated one without crashing."""
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"val": [1, 2, 3], "row_hash": [100, 200, 300]})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path, include_row_hash=True)
|
||
rows = ds.take_all()
|
||
hashes = [row["row_hash"] for row in rows]
|
||
assert len(hashes) == 3
|
||
assert len(set(hashes)) == 3, "Hashes must be unique"
|
||
assert all(
|
||
h not in (100, 200, 300) for h in hashes
|
||
), "Generated hashes must overwrite the original column values"
|
||
|
||
|
||
def test_include_row_hash_existing_column_with_projection(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
"""Column projection + pre-existing row_hash column should work."""
|
||
path = os.path.join(tmp_path, "test.parquet")
|
||
table = pa.Table.from_pydict({"val": [1, 2], "row_hash": [10, 20]})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path, include_row_hash=True).select_columns(
|
||
["val", "row_hash"]
|
||
)
|
||
schema_names = ds.schema().names
|
||
assert "val" in schema_names
|
||
assert "row_hash" in schema_names
|
||
rows = ds.take_all()
|
||
assert all(row["row_hash"] not in (10, 20) for row in rows)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
(
|
||
lazy_fixture("s3_fs_with_space"),
|
||
lazy_fixture("s3_path_with_space"),
|
||
), # Path contains space.
|
||
(
|
||
lazy_fixture("s3_fs_with_anonymous_crendential"),
|
||
lazy_fixture("s3_path_with_anonymous_crendential"),
|
||
),
|
||
],
|
||
)
|
||
def test_parquet_read_basic(
|
||
ray_start_regular_shared, fs, data_path, target_max_block_size_infinite_or_default
|
||
):
|
||
df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]})
|
||
table = pa.Table.from_pandas(df1)
|
||
setup_data_path = _unwrap_protocol(data_path)
|
||
path1 = os.path.join(setup_data_path, "test1.parquet")
|
||
pq.write_table(table, path1, filesystem=fs)
|
||
df2 = pd.DataFrame({"one": [4, 5, 6], "two": ["e", "f", "g"]})
|
||
table = pa.Table.from_pandas(df2)
|
||
path2 = os.path.join(setup_data_path, "test2.parquet")
|
||
pq.write_table(table, path2, filesystem=fs)
|
||
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs)
|
||
|
||
# Schema is available pre-execution via driver-side first-file sampling.
|
||
assert ds.schema() == Schema(pa.schema({"one": pa.int64(), "two": pa.string()}))
|
||
|
||
# Forces a data read.
|
||
values = [[s["one"], s["two"]] for s in ds.take_all()]
|
||
assert sorted(values) == [
|
||
[1, "a"],
|
||
[2, "b"],
|
||
[3, "c"],
|
||
[4, "e"],
|
||
[5, "f"],
|
||
[6, "g"],
|
||
]
|
||
|
||
# Post-materialization count / size checks. ``input_files()`` is a
|
||
# V1 construction-time-only capability that doesn't carry through
|
||
# V2's ``ListFiles → ReadFiles`` split (or through V1 materialization),
|
||
# so we don't assert on it here.
|
||
materialized = ds.materialize()
|
||
assert materialized.count() == 6
|
||
assert materialized.size_bytes() > 0
|
||
|
||
# Test column selection.
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs).select_columns(["one"])
|
||
values = [s["one"] for s in ds.take()]
|
||
assert sorted(values) == [1, 2, 3, 4, 5, 6]
|
||
assert ds.schema().names == ["one"]
|
||
|
||
# Test concurrency.
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs, concurrency=1)
|
||
values = [s["one"] for s in ds.take()]
|
||
assert sorted(values) == [1, 2, 3, 4, 5, 6]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
],
|
||
)
|
||
def test_parquet_read_with_success_file(ray_start_regular_shared, fs, data_path):
|
||
df = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]})
|
||
table = pa.Table.from_pandas(df)
|
||
setup_data_path = _unwrap_protocol(data_path)
|
||
path = os.path.join(setup_data_path, "test.parquet")
|
||
pq.write_table(table, path, filesystem=fs)
|
||
|
||
# Add _SUCCESS file to ensure it's ignored
|
||
success_path = os.path.join(setup_data_path, "_SUCCESS")
|
||
if fs is None:
|
||
open(success_path, "wb").close()
|
||
else:
|
||
with fs.open_output_stream(success_path):
|
||
pass
|
||
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs)
|
||
assert ds.count() == 3
|
||
values = [s["one"] for s in ds.take_all()]
|
||
assert sorted(values) == [1, 2, 3]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
(
|
||
lazy_fixture("s3_fs_with_space"),
|
||
lazy_fixture("s3_path_with_space"),
|
||
), # Path contains space.
|
||
(
|
||
lazy_fixture("s3_fs_with_anonymous_crendential"),
|
||
lazy_fixture("s3_path_with_anonymous_crendential"),
|
||
),
|
||
],
|
||
)
|
||
def test_parquet_read_random_shuffle(
|
||
ray_start_regular_shared,
|
||
restore_data_context,
|
||
fs,
|
||
data_path,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
# NOTE: set preserve_order to True to allow consistent output behavior.
|
||
context = ray.data.DataContext.get_current()
|
||
context.execution_options.preserve_order = True
|
||
|
||
num_files = 10
|
||
input_list = list(range(num_files))
|
||
setup_data_path = _unwrap_protocol(data_path)
|
||
for i in range(num_files):
|
||
table = pa.Table.from_pydict({"id": [i]})
|
||
path = os.path.join(setup_data_path, f"test_{i}.parquet")
|
||
pq.write_table(table, path, filesystem=fs)
|
||
|
||
shuffle = FileShuffleConfig(seed=0)
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs, shuffle=shuffle)
|
||
|
||
first = [row["id"] for row in ds.take_all()]
|
||
second = [row["id"] for row in ds.take_all()]
|
||
|
||
assert sorted(first) == input_list
|
||
assert sorted(second) == input_list
|
||
assert first != second
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
(
|
||
lazy_fixture("s3_fs_with_anonymous_crendential"),
|
||
lazy_fixture("s3_path_with_anonymous_crendential"),
|
||
),
|
||
],
|
||
)
|
||
def test_parquet_read_partitioned(
|
||
ray_start_regular_shared, fs, data_path, target_max_block_size_infinite_or_default
|
||
):
|
||
df = pd.DataFrame(
|
||
{"one": [1, 1, 1, 3, 3, 3], "two": ["a", "b", "c", "e", "f", "g"]}
|
||
)
|
||
table = pa.Table.from_pandas(df)
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=_unwrap_protocol(data_path),
|
||
partition_cols=["one"],
|
||
filesystem=fs,
|
||
)
|
||
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs)
|
||
|
||
# Schema is available pre-execution via driver-side first-file sampling.
|
||
assert ds.schema() == Schema(pa.schema({"two": pa.string(), "one": pa.string()}))
|
||
|
||
# Forces a data read.
|
||
values = [[s["one"], s["two"]] for s in ds.take()]
|
||
assert sorted(values) == [
|
||
["1", "a"],
|
||
["1", "b"],
|
||
["1", "c"],
|
||
["3", "e"],
|
||
["3", "f"],
|
||
["3", "g"],
|
||
]
|
||
|
||
# Post-materialization count / size checks (no input_files — see note
|
||
# in ``test_parquet_read_basic``).
|
||
materialized = ds.materialize()
|
||
assert materialized.count() == 6
|
||
assert materialized.size_bytes() > 0
|
||
|
||
# Test column selection.
|
||
ds = ray.data.read_parquet(data_path, filesystem=fs).select_columns(["one"])
|
||
values = [s["one"] for s in ds.take()]
|
||
assert sorted(values) == ["1", "1", "1", "3", "3", "3"]
|
||
|
||
|
||
def test_parquet_read_partitioned_with_filter(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
from ray.data.expressions import col, lit
|
||
|
||
df = pd.DataFrame(
|
||
{"one": [1, 1, 1, 3, 3, 3], "two": ["a", "a", "b", "b", "c", "c"]}
|
||
)
|
||
table = pa.Table.from_pandas(df)
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=str(tmp_path),
|
||
partition_cols=["one"],
|
||
)
|
||
|
||
# 2 partitions, 1 empty partition, 1 block/read task
|
||
|
||
ds = ray.data.read_parquet(str(tmp_path), override_num_blocks=1).filter(
|
||
expr=col("two") == lit("a")
|
||
)
|
||
|
||
values = [[s["one"], s["two"]] for s in ds.take()]
|
||
assert sorted(values) == [["1", "a"], ["1", "a"]]
|
||
assert ds.count() == 2
|
||
|
||
# 2 partitions, 1 empty partition, 2 block/read tasks, 1 empty block
|
||
|
||
ds = ray.data.read_parquet(str(tmp_path), override_num_blocks=2).filter(
|
||
expr=col("two") == lit("a")
|
||
)
|
||
|
||
values = [[s["one"], s["two"]] for s in ds.take()]
|
||
assert sorted(values) == [["1", "a"], ["1", "a"]]
|
||
assert ds.count() == 2
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
(
|
||
lazy_fixture("s3_fs_with_anonymous_crendential"),
|
||
lazy_fixture("s3_path_with_anonymous_crendential"),
|
||
),
|
||
],
|
||
)
|
||
def test_parquet_read_partitioned_with_columns(
|
||
ray_start_regular_shared, fs, data_path, target_max_block_size_infinite_or_default
|
||
):
|
||
data = {
|
||
"x": [0, 0, 1, 1, 2, 2],
|
||
"y": ["a", "b", "a", "b", "a", "b"],
|
||
"z": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
|
||
}
|
||
table = pa.Table.from_pydict(data)
|
||
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=_unwrap_protocol(data_path),
|
||
filesystem=fs,
|
||
partition_cols=["x", "y"],
|
||
)
|
||
|
||
ds = ray.data.read_parquet(
|
||
_unwrap_protocol(data_path),
|
||
filesystem=fs,
|
||
).select_columns(["y", "z"])
|
||
assert set(ds.columns()) == {"y", "z"}
|
||
values = [[s["y"], s["z"]] for s in ds.take()]
|
||
assert sorted(values) == [
|
||
["a", 0.1],
|
||
["a", 0.3],
|
||
["a", 0.5],
|
||
["b", 0.2],
|
||
["b", 0.4],
|
||
["b", 0.6],
|
||
]
|
||
|
||
|
||
def test_parquet_read_partitioned_excludes_unrequested_partition_columns(
|
||
ray_start_regular_shared, tmp_path
|
||
):
|
||
"""Test that partition columns are excluded when not explicitly requested.
|
||
|
||
This is a regression test to ensure that when a user uses select_columns()
|
||
with only data columns, partition columns are NOT automatically included.
|
||
"""
|
||
table = pa.table(
|
||
{
|
||
"partition_col0": [1, 1, 2, 2],
|
||
"partition_col1": ["a", "a", "b", "b"],
|
||
"data_col0": [10.5, 20.3, 30.2, 25.8],
|
||
"data_col1": [100, 200, 300, 400],
|
||
}
|
||
)
|
||
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=tmp_path,
|
||
partition_cols=["partition_col0", "partition_col1"],
|
||
)
|
||
|
||
# Request only data columns excluding partition columns
|
||
ds = ray.data.read_parquet(
|
||
tmp_path,
|
||
partitioning=Partitioning("hive"),
|
||
).select_columns(["data_col0"])
|
||
|
||
# Verify only the requested column is present
|
||
assert ds.columns() == ["data_col0"]
|
||
|
||
# Verify the data is correct
|
||
result_df = ds.to_pandas()
|
||
expected_df = pd.DataFrame({"data_col0": [10.5, 20.3, 25.8, 30.2]})
|
||
assert rows_same(result_df, expected_df)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
(
|
||
lazy_fixture("s3_fs_with_anonymous_crendential"),
|
||
lazy_fixture("s3_path_with_anonymous_crendential"),
|
||
),
|
||
],
|
||
)
|
||
def test_parquet_read_partitioned_with_partition_filter(
|
||
ray_start_regular_shared, fs, data_path, target_max_block_size_infinite_or_default
|
||
):
|
||
# This test is to make sure when only one file remains
|
||
# after partition filtering, Ray data can still parse the
|
||
# partitions correctly.
|
||
data = {
|
||
"x": [0, 0, 1, 1, 2, 2],
|
||
"y": ["a", "b", "a", "b", "a", "b"],
|
||
"z": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
|
||
}
|
||
table = pa.Table.from_pydict(data)
|
||
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=_unwrap_protocol(data_path),
|
||
filesystem=fs,
|
||
partition_cols=["x", "y"],
|
||
)
|
||
|
||
ds = ray.data.read_parquet(
|
||
_unwrap_protocol(data_path),
|
||
filesystem=fs,
|
||
partition_filter=ray.data.datasource.partitioning.PathPartitionFilter.of(
|
||
filter_fn=lambda x: (x["x"] == "0") and (x["y"] == "a"), style="hive"
|
||
),
|
||
).select_columns(["x", "y", "z"])
|
||
|
||
# Where we insert partition columns is an implementation detail, so we don't check
|
||
# the order of the columns.
|
||
assert sorted(zip(ds.schema().names, ds.schema().types)) == [
|
||
("x", pa.string()),
|
||
("y", pa.string()),
|
||
("z", pa.float64()),
|
||
]
|
||
|
||
values = [[s["x"], s["y"], s["z"]] for s in ds.take()]
|
||
|
||
assert sorted(values) == [["0", "a", 0.1]]
|
||
|
||
|
||
def test_parquet_read_partitioned_explicit(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
df = pd.DataFrame(
|
||
{"one": [1, 1, 1, 3, 3, 3], "two": ["a", "b", "c", "e", "f", "g"]}
|
||
)
|
||
table = pa.Table.from_pandas(df)
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=str(tmp_path),
|
||
partition_cols=["one"],
|
||
)
|
||
|
||
partitioning = Partitioning("hive", field_types={"one": int})
|
||
ds = ray.data.read_parquet(str(tmp_path), partitioning=partitioning)
|
||
|
||
# Schema is available pre-execution via driver-side sampling.
|
||
assert ds.schema() == Schema(pa.schema({"two": pa.string(), "one": pa.int64()}))
|
||
|
||
# Forces a data read.
|
||
values = [[s["one"], s["two"]] for s in ds.take()]
|
||
assert sorted(values) == [
|
||
[1, "a"],
|
||
[1, "b"],
|
||
[1, "c"],
|
||
[3, "e"],
|
||
[3, "f"],
|
||
[3, "g"],
|
||
]
|
||
|
||
# Post-materialization count / size checks (no input_files — see note
|
||
# in ``test_parquet_read_basic``).
|
||
materialized = ds.materialize()
|
||
assert materialized.count() == 6
|
||
assert materialized.size_bytes() > 0
|
||
|
||
|
||
def test_projection_pushdown_non_partitioned(ray_start_regular_shared, temp_dir):
|
||
if ray.data.DataContext.get_current().use_datasource_v2:
|
||
pytest.skip(
|
||
"Plan-string assertion is V1-specific (``Read[ReadParquet]``); V2 "
|
||
"produces a ``ListFiles → ReadFiles`` chain. Projection correctness "
|
||
"is covered by the schema/count assertions below for both paths."
|
||
)
|
||
path = "example://iris.parquet"
|
||
|
||
# Test projection from read_parquet
|
||
ds = ray.data.read_parquet(path).select_columns(["variety"])
|
||
|
||
schema = ds.schema()
|
||
|
||
assert ["variety"] == schema.base_schema.names
|
||
assert ds.count() == 150
|
||
|
||
# Test projection pushed down into read op
|
||
ds = ray.data.read_parquet(path, override_num_blocks=1).select_columns("variety")
|
||
|
||
assert explain_plan(ds._logical_plan).strip() == (
|
||
"-------- Logical Plan --------\n"
|
||
"Project[Project]\n"
|
||
"+- Read[ReadParquet]\n"
|
||
"\n-------- Logical Plan (Optimized) --------\n"
|
||
"Read[ReadParquet]\n"
|
||
"\n-------- Physical Plan --------\n"
|
||
"TaskPoolMapOperator[ReadParquet]\n"
|
||
"+- InputDataBuffer[Input]\n"
|
||
"\n-------- Physical Plan (Optimized) --------\n"
|
||
"TaskPoolMapOperator[ReadParquet]\n"
|
||
"+- InputDataBuffer[Input]"
|
||
)
|
||
|
||
# Assert schema being appropriately projected
|
||
schema = ds.schema()
|
||
assert ["variety"] == schema.base_schema.names
|
||
|
||
assert ds.count() == 150
|
||
|
||
# Assert empty projection is reading no data
|
||
ds = ray.data.read_parquet(path, override_num_blocks=1).select_columns([])
|
||
|
||
summary = ds.materialize()._raw_stats().to_summary()
|
||
|
||
assert "ReadParquet" in summary.base_name
|
||
assert summary.extra_metrics["bytes_task_outputs_generated"] == 0
|
||
|
||
|
||
def test_projection_pushdown_partitioned(ray_start_regular_shared, temp_dir):
|
||
ds = ray.data.read_parquet("example://iris.parquet").materialize()
|
||
|
||
partitioned_ds_path = f"{temp_dir}/partitioned_iris"
|
||
# Write out partitioned dataset
|
||
ds.write_parquet(partitioned_ds_path, partition_cols=["variety"])
|
||
|
||
partitioned_ds = (
|
||
ray.data.read_parquet(partitioned_ds_path)
|
||
.select_columns(["variety"])
|
||
.materialize()
|
||
)
|
||
|
||
print(partitioned_ds.schema())
|
||
|
||
assert [
|
||
"sepal.length",
|
||
"sepal.width",
|
||
"petal.length",
|
||
"petal.width",
|
||
"variety",
|
||
] == ds.take_batch(batch_format="pyarrow").column_names
|
||
|
||
assert ["variety"] == partitioned_ds.take_batch(batch_format="pyarrow").column_names
|
||
|
||
assert ds.count() == partitioned_ds.count()
|
||
|
||
|
||
def test_projection_pushdown_on_count(ray_start_regular_shared, temp_dir):
|
||
path = "example://iris.parquet"
|
||
|
||
# Test reading full dataset
|
||
# ds = ray.data.read_parquet(path).materialize()
|
||
|
||
# Test projection from read_parquet
|
||
num_rows = ray.data.read_parquet(path).count()
|
||
|
||
assert num_rows == 150
|
||
|
||
|
||
def test_parquet_read_with_udf(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
one_data = list(range(6))
|
||
df = pd.DataFrame({"one": one_data, "two": 2 * ["a"] + 2 * ["b"] + 2 * ["c"]})
|
||
table = pa.Table.from_pandas(df)
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=str(tmp_path),
|
||
partition_cols=["two"],
|
||
)
|
||
|
||
def _block_udf(block: pa.Table):
|
||
df = block.to_pandas()
|
||
df["one"] += 1
|
||
return pa.Table.from_pandas(df)
|
||
|
||
# 1 block/read task
|
||
|
||
ds = ray.data.read_parquet(
|
||
str(tmp_path), override_num_blocks=1, _block_udf=_block_udf
|
||
)
|
||
|
||
ones, twos = zip(*[[s["one"], s["two"]] for s in ds.take()])
|
||
np.testing.assert_array_equal(sorted(ones), np.array(one_data) + 1)
|
||
|
||
# 2 blocks/read tasks
|
||
|
||
ds = ray.data.read_parquet(
|
||
str(tmp_path), override_num_blocks=2, _block_udf=_block_udf
|
||
)
|
||
|
||
ones, twos = zip(*[[s["one"], s["two"]] for s in ds.take()])
|
||
np.testing.assert_array_equal(sorted(ones), np.array(one_data) + 1)
|
||
|
||
# 2 blocks/read tasks, 1 empty block
|
||
|
||
ds = ray.data.read_parquet(
|
||
str(tmp_path),
|
||
override_num_blocks=2,
|
||
partition_filter=PathPartitionFilter.of(
|
||
lambda partitions: partitions["two"] == "a"
|
||
),
|
||
_block_udf=_block_udf,
|
||
)
|
||
|
||
ones, twos = zip(*[[s["one"], s["two"]] for s in ds.take()])
|
||
np.testing.assert_array_equal(sorted(ones), np.array(one_data[:2]) + 1)
|
||
|
||
|
||
def test_parquet_reader_estimate_data_size(shutdown_only, tmp_path):
|
||
ctx = ray.data.context.DataContext.get_current()
|
||
old_decoding_size_estimation = ctx.decoding_size_estimation
|
||
ctx.decoding_size_estimation = True
|
||
try:
|
||
tensor_output_path = os.path.join(tmp_path, "tensor")
|
||
# NOTE: It's crucial to override # of blocks to get stable # of files
|
||
# produced and make sure data size estimates are stable
|
||
ray.data.range_tensor(
|
||
1000, shape=(1000,), override_num_blocks=10
|
||
).write_parquet(tensor_output_path)
|
||
ds = ray.data.read_parquet(tensor_output_path)
|
||
data_size = ds.size_bytes()
|
||
assert (
|
||
data_size >= 6_000_000 and data_size <= 10_000_000
|
||
), "estimated data size is out of expected bound"
|
||
data_size = ds.materialize().size_bytes()
|
||
assert (
|
||
data_size >= 7_000_000 and data_size <= 10_000_000
|
||
), "actual data size is out of expected bound"
|
||
|
||
datasource = ParquetDatasource(tensor_output_path)
|
||
assert (
|
||
datasource._encoding_ratio >= 300 and datasource._encoding_ratio <= 600
|
||
), "encoding ratio is out of expected bound"
|
||
data_size = datasource.estimate_inmemory_data_size()
|
||
assert (
|
||
data_size >= 6_000_000 and data_size <= 10_000_000
|
||
), "estimated data size is either out of expected bound"
|
||
assert (
|
||
data_size
|
||
== ParquetDatasource(tensor_output_path).estimate_inmemory_data_size()
|
||
), "estimated data size is not deterministic in multiple calls."
|
||
|
||
text_output_path = os.path.join(tmp_path, "text")
|
||
ray.data.range(1000).map(lambda _: {"text": "a" * 1000}).write_parquet(
|
||
text_output_path
|
||
)
|
||
ds = ray.data.read_parquet(text_output_path)
|
||
data_size = ds.size_bytes()
|
||
assert (
|
||
data_size >= 700_000 and data_size <= 2_200_000
|
||
), "estimated data size is out of expected bound"
|
||
data_size = ds.materialize().size_bytes()
|
||
assert (
|
||
data_size >= 1_000_000 and data_size <= 2_000_000
|
||
), "actual data size is out of expected bound"
|
||
|
||
datasource = ParquetDatasource(text_output_path)
|
||
assert (
|
||
datasource._encoding_ratio >= 6 and datasource._encoding_ratio <= 300
|
||
), "encoding ratio is out of expected bound"
|
||
data_size = datasource.estimate_inmemory_data_size()
|
||
assert (
|
||
data_size >= 700_000 and data_size <= 2_200_000
|
||
), "estimated data size is out of expected bound"
|
||
assert (
|
||
data_size
|
||
== ParquetDatasource(text_output_path).estimate_inmemory_data_size()
|
||
), "estimated data size is not deterministic in multiple calls."
|
||
finally:
|
||
ctx.decoding_size_estimation = old_decoding_size_estimation
|
||
|
||
|
||
def test_parquet_write(ray_start_regular_shared, tmp_path):
|
||
input_df = pd.DataFrame({"id": [0]})
|
||
ds = ray.data.from_blocks([input_df])
|
||
|
||
ds.write_parquet(tmp_path)
|
||
|
||
output_df = pd.concat(
|
||
[
|
||
pd.read_parquet(os.path.join(tmp_path, filename))
|
||
for filename in os.listdir(tmp_path)
|
||
]
|
||
)
|
||
assert rows_same(input_df, output_df)
|
||
|
||
|
||
def test_parquet_write_ignore_save_mode(ray_start_regular_shared, local_path):
|
||
data_path = local_path
|
||
path = os.path.join(data_path, "test_parquet_dir")
|
||
os.mkdir(path)
|
||
in_memory_table = pa.Table.from_pydict({"one": [1]})
|
||
ds = ray.data.from_arrow(in_memory_table)
|
||
ds.write_parquet(path, filesystem=None, mode="ignore")
|
||
|
||
# directory was created, should ignore
|
||
with os.scandir(path) as file_paths:
|
||
count_of_files = sum(1 for path in file_paths)
|
||
assert count_of_files == 0
|
||
|
||
# now remove dir
|
||
shutil.rmtree(path)
|
||
|
||
# should write
|
||
ds.write_parquet(path, filesystem=None, mode="ignore")
|
||
on_disk_table = pq.read_table(path)
|
||
|
||
assert in_memory_table.equals(on_disk_table)
|
||
|
||
|
||
def test_parquet_write_error_save_mode_simple_write(
|
||
ray_start_regular_shared, local_path
|
||
):
|
||
data_path = local_path
|
||
path = os.path.join(data_path, "test_parquet_dir")
|
||
os.mkdir(path)
|
||
in_memory_table = pa.Table.from_pydict({"one": [1]})
|
||
ds = ray.data.from_arrow(in_memory_table)
|
||
|
||
with pytest.raises(ValueError):
|
||
ds.write_parquet(path, filesystem=None, mode="error")
|
||
|
||
# now remove dir
|
||
shutil.rmtree(path)
|
||
|
||
# should write
|
||
ds.write_parquet(path, filesystem=None, mode="error")
|
||
on_disk_table = pq.read_table(path)
|
||
|
||
assert in_memory_table.equals(on_disk_table)
|
||
|
||
|
||
def test_parquet_write_error_save_mode_concurrent_write(
|
||
ray_start_regular_shared, local_path
|
||
):
|
||
path = os.path.join(local_path, "test_parquet_dir")
|
||
ds = ray.data.range(1000, override_num_blocks=4)
|
||
# Should succeed, data should not exist before write
|
||
ds.write_parquet(path, mode="error")
|
||
# Verify that data was written correctly
|
||
assert ray.data.read_parquet(path).count() == 1000
|
||
|
||
with pytest.raises(ValueError, match="already exists"):
|
||
ds.write_parquet(path, mode="error")
|
||
shutil.rmtree(path)
|
||
|
||
|
||
def test_parquet_write_append_save_mode(ray_start_regular_shared, local_path):
|
||
data_path = local_path
|
||
path = os.path.join(data_path, "test_parquet_dir")
|
||
in_memory_table = pa.Table.from_pydict({"one": [1]})
|
||
ds = ray.data.from_arrow(in_memory_table)
|
||
ds.write_parquet(path, filesystem=None, mode="append")
|
||
|
||
# one file should be added
|
||
with os.scandir(path) as file_paths:
|
||
count_of_files = sum(1 for path in file_paths)
|
||
assert count_of_files == 1
|
||
|
||
appended_in_memory_table = pa.Table.from_pydict({"two": [2]})
|
||
ds = ray.data.from_arrow(appended_in_memory_table)
|
||
ds.write_parquet(path, filesystem=None, mode="append")
|
||
|
||
# another file should be added
|
||
with os.scandir(path) as file_paths:
|
||
count_of_files = sum(1 for path in file_paths)
|
||
assert count_of_files == 2
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"filename_template,should_raise_error",
|
||
[
|
||
# Case 1: No UUID, no extension - should raise error in append mode
|
||
("myfile", True),
|
||
# Case 2: No UUID, has extension - should raise error in append mode
|
||
("myfile.parquet", True),
|
||
# Case 3: No UUID, different extension - should raise error in append mode
|
||
("myfile.txt", True),
|
||
# Case 4: Already has UUID - should not raise error
|
||
("myfile_{write_uuid}", False),
|
||
# Case 5: Already has UUID with extension - should not raise error
|
||
("myfile_{write_uuid}.parquet", False),
|
||
# Case 6: Templated filename without UUID - should raise error in append mode
|
||
("myfile-{i}", True),
|
||
# Case 7: Templated filename with extension but no UUID - should raise error in append mode
|
||
("myfile-{i}.parquet", True),
|
||
# Case 8: Templated filename with UUID already present - should not raise error
|
||
("myfile_{write_uuid}-{i}.parquet", False),
|
||
],
|
||
ids=[
|
||
"no_uuid_no_ext",
|
||
"no_uuid_with_parquet_ext",
|
||
"no_uuid_with_other_ext",
|
||
"has_uuid_no_ext",
|
||
"has_uuid_with_ext",
|
||
"templated_no_uuid_no_ext",
|
||
"templated_no_uuid_with_ext",
|
||
"templated_has_uuid",
|
||
],
|
||
)
|
||
def test_parquet_write_uuid_handling_with_custom_filename_provider(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
filename_template,
|
||
should_raise_error,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
"""Test that write_parquet correctly handles UUID validation in filenames when using custom filename providers in append mode."""
|
||
import re
|
||
|
||
from ray.data.datasource.filename_provider import FilenameProvider
|
||
|
||
class CustomFilenameProvider(FilenameProvider):
|
||
def __init__(self, filename_template, should_include_uuid):
|
||
self.filename_template = filename_template
|
||
self.should_include_uuid = should_include_uuid
|
||
|
||
def get_filename_for_task(self, write_uuid, task_index):
|
||
if self.should_include_uuid:
|
||
# Replace {write_uuid} placeholder with actual write_uuid
|
||
return self.filename_template.format(write_uuid=write_uuid, i="{i}")
|
||
else:
|
||
# Don't include UUID - this simulates the problematic case
|
||
return self.filename_template
|
||
|
||
# Create a simple dataset
|
||
ds = ray.data.range(10).repartition(1)
|
||
|
||
# Create custom filename provider
|
||
custom_provider = CustomFilenameProvider(filename_template, not should_raise_error)
|
||
|
||
if should_raise_error:
|
||
# Should raise ValueError when UUID is missing in append mode
|
||
# Updated regex to match the actual error message
|
||
with pytest.raises(
|
||
ValueError,
|
||
match=r"Write UUID.*missing from filename template.*This could result in files being overwritten.*Modify your FileNameProvider implementation",
|
||
):
|
||
ds.write_parquet(tmp_path, filename_provider=custom_provider, mode="append")
|
||
else:
|
||
# Should succeed when UUID is present
|
||
ds.write_parquet(tmp_path, filename_provider=custom_provider, mode="append")
|
||
|
||
# Check that files were created
|
||
written_files = os.listdir(tmp_path)
|
||
assert len(written_files) == 1
|
||
|
||
written_file = written_files[0]
|
||
|
||
# Verify UUID is present in filename (should be the actual write_uuid)
|
||
uuid_pattern = r"[a-f0-9]{32}" # 32 hex characters (UUID without dashes)
|
||
assert re.search(
|
||
uuid_pattern, written_file
|
||
), f"File '{written_file}' should contain UUID"
|
||
|
||
# Verify the content is correct by reading back
|
||
ds_read = ray.data.read_parquet(tmp_path)
|
||
assert ds_read.count() == 10
|
||
assert sorted([row["id"] for row in ds_read.take_all()]) == list(range(10))
|
||
|
||
|
||
def test_parquet_write_overwrite_save_mode(ray_start_regular_shared, local_path):
|
||
data_path = local_path
|
||
path = os.path.join(data_path, "test_parquet_dir")
|
||
in_memory_table = pa.Table.from_pydict({"one": [1]})
|
||
ds = ray.data.from_arrow(in_memory_table)
|
||
ds.write_parquet(path, filesystem=None, mode="overwrite")
|
||
|
||
# one file should be added
|
||
with os.scandir(path) as file_paths:
|
||
count_of_files = sum(1 for path in file_paths)
|
||
assert count_of_files == 1
|
||
|
||
overwritten_in_memory_table = pa.Table.from_pydict({"two": [2]})
|
||
ds = ray.data.from_arrow(overwritten_in_memory_table)
|
||
ds.write_parquet(path, filesystem=None, mode="overwrite")
|
||
|
||
# another file should NOT be added
|
||
with os.scandir(path) as file_paths:
|
||
count_of_files = sum(1 for path in file_paths)
|
||
assert count_of_files == 1
|
||
|
||
on_disk_table = pq.read_table(path)
|
||
assert on_disk_table.equals(overwritten_in_memory_table)
|
||
|
||
|
||
def test_parquet_file_extensions(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
table = pa.table({"food": ["spam", "ham", "eggs"]})
|
||
pq.write_table(table, tmp_path / "table.parquet")
|
||
# `spam` should be filtered out.
|
||
with open(tmp_path / "spam", "w"):
|
||
pass
|
||
|
||
ds = ray.data.read_parquet(tmp_path, file_extensions=["parquet"])
|
||
|
||
assert ds.count() == 3
|
||
|
||
|
||
def test_parquet_write_creates_dir_if_not_exists(ray_start_regular_shared, tmp_path):
|
||
ds = ray.data.range(1)
|
||
path = os.path.join(tmp_path, "does_not_exist")
|
||
|
||
ds.write_parquet(path)
|
||
|
||
assert os.path.isdir(path)
|
||
expected_df = pd.DataFrame({"id": [0]})
|
||
actual_df = pd.concat(
|
||
[pd.read_parquet(os.path.join(path, filename)) for filename in os.listdir(path)]
|
||
)
|
||
assert rows_same(actual_df, expected_df)
|
||
|
||
|
||
def test_parquet_write_does_not_create_dir_for_empty_dataset(
|
||
ray_start_regular_shared, tmp_path
|
||
):
|
||
ds = ray.data.from_blocks([pd.DataFrame({})])
|
||
path = os.path.join(tmp_path, "does_not_exist")
|
||
|
||
ds.write_parquet(path)
|
||
|
||
assert not os.path.isdir(path)
|
||
|
||
|
||
def test_parquet_write_does_not_write_empty_blocks(ray_start_regular_shared, tmp_path):
|
||
ds = ray.data.from_blocks([pd.DataFrame({}), pd.DataFrame({"id": [0]})])
|
||
path = os.path.join(tmp_path, "does_not_exist")
|
||
|
||
ds.write_parquet(path)
|
||
|
||
assert len(os.listdir(path)) == 1
|
||
expected_df = pd.DataFrame({"id": [0]})
|
||
actual_df = pd.read_parquet(os.path.join(path, os.listdir(path)[0]))
|
||
assert rows_same(actual_df, expected_df)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(None, lazy_fixture("local_path")),
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
||
(
|
||
lazy_fixture("s3_fs_with_anonymous_crendential"),
|
||
lazy_fixture("s3_path_with_anonymous_crendential"),
|
||
),
|
||
],
|
||
)
|
||
def test_parquet_roundtrip(
|
||
ray_start_regular_shared, fs, data_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(data_path, "test_parquet_dir")
|
||
if fs is None:
|
||
os.mkdir(path)
|
||
else:
|
||
fs.create_dir(_unwrap_protocol(path))
|
||
|
||
df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]})
|
||
df2 = pd.DataFrame({"one": [4, 5, 6], "two": ["e", "f", "g"]})
|
||
ds = ray.data.from_pandas([df1, df2])
|
||
ds.write_parquet(path, filesystem=fs)
|
||
|
||
ds2 = ray.data.read_parquet(path, filesystem=fs)
|
||
|
||
read_data = set(ds2.to_pandas().itertuples(index=False))
|
||
written_data = set(pd.concat([df1, df2]).itertuples(index=False))
|
||
assert read_data == written_data
|
||
|
||
# Test metadata ops.
|
||
for entry in ds2._execute().blocks:
|
||
# pyrefly: ignore[no-matching-overload]
|
||
BlockAccessor.for_block(
|
||
ray.get(entry.ref)
|
||
).size_bytes() == entry.metadata.size_bytes
|
||
|
||
if fs is None:
|
||
shutil.rmtree(path)
|
||
else:
|
||
fs.delete_dir(_unwrap_protocol(path))
|
||
|
||
|
||
def test_parquet_read_empty_file(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(tmp_path, "data.parquet")
|
||
table = pa.table({})
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path)
|
||
|
||
assert ds.take_all() == []
|
||
|
||
|
||
def test_parquet_reader_batch_size(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
path = os.path.join(tmp_path, "data.parquet")
|
||
ray.data.range_tensor(1000, shape=(1000,)).write_parquet(path)
|
||
ds = ray.data.read_parquet(path, batch_size=10)
|
||
assert ds.count() == 1000
|
||
|
||
|
||
def test_parquet_datasource_names(ray_start_regular_shared, tmp_path):
|
||
df = pd.DataFrame({"spam": [1, 2, 3]})
|
||
path = os.path.join(tmp_path, "data.parquet")
|
||
df.to_parquet(path)
|
||
|
||
assert ParquetDatasource(path).get_name() == "Parquet"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fs,data_path",
|
||
[
|
||
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
||
],
|
||
)
|
||
def test_parquet_concurrency(
|
||
ray_start_regular_shared, fs, data_path, target_max_block_size_infinite_or_default
|
||
):
|
||
df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]})
|
||
table = pa.Table.from_pandas(df1)
|
||
setup_data_path = _unwrap_protocol(data_path)
|
||
path1 = os.path.join(setup_data_path, "test1.parquet")
|
||
pq.write_table(table, path1, filesystem=fs)
|
||
df2 = pd.DataFrame({"one": [4, 5, 6], "two": ["e", "f", "g"]})
|
||
table = pa.Table.from_pandas(df2)
|
||
path2 = os.path.join(setup_data_path, "test2.parquet")
|
||
pq.write_table(table, path2, filesystem=fs)
|
||
|
||
concurrency_counter = ConcurrencyCounter.remote()
|
||
|
||
def map_batches(batch):
|
||
ray.get(concurrency_counter.inc.remote())
|
||
time.sleep(0.5)
|
||
ray.get(concurrency_counter.decr.remote())
|
||
return batch
|
||
|
||
concurrency = 1
|
||
ds = ray.data.read_parquet(
|
||
data_path,
|
||
filesystem=fs,
|
||
concurrency=concurrency,
|
||
override_num_blocks=2,
|
||
)
|
||
ds = ds.map_batches(
|
||
map_batches,
|
||
batch_size=None,
|
||
concurrency=concurrency,
|
||
)
|
||
assert ds.count() == 6
|
||
actual_max_concurrency = ray.get(concurrency_counter.get_max_concurrency.remote())
|
||
assert actual_max_concurrency <= concurrency
|
||
|
||
|
||
# NOTE: All tests above share a Ray cluster, while the tests below do not. These
|
||
# tests should only be carefully reordered to retain this invariant!
|
||
|
||
|
||
def test_parquet_read_spread(ray_start_cluster, tmp_path, restore_data_context):
|
||
ray.shutdown()
|
||
cluster = ray_start_cluster
|
||
cluster.add_node(
|
||
resources={"bar:1": 100},
|
||
num_cpus=10,
|
||
object_store_memory=2 * 1024 * 1024 * 1024,
|
||
_system_config={"max_direct_call_object_size": 0},
|
||
)
|
||
cluster.add_node(
|
||
resources={"bar:2": 100},
|
||
num_cpus=10,
|
||
object_store_memory=2 * 1024 * 1024 * 1024,
|
||
)
|
||
cluster.add_node(resources={"bar:3": 100}, num_cpus=0)
|
||
|
||
ray.init(cluster.address)
|
||
|
||
@ray.remote
|
||
def get_node_id():
|
||
return ray.get_runtime_context().get_node_id()
|
||
|
||
node1_id = ray.get(get_node_id.options(resources={"bar:1": 1}).remote())
|
||
node2_id = ray.get(get_node_id.options(resources={"bar:2": 1}).remote())
|
||
|
||
data_path = str(tmp_path)
|
||
df1 = pd.DataFrame({"one": list(range(100)), "two": list(range(100, 200))})
|
||
path1 = os.path.join(data_path, "test1.parquet")
|
||
df1.to_parquet(path1)
|
||
df2 = pd.DataFrame({"one": list(range(300, 400)), "two": list(range(400, 500))})
|
||
path2 = os.path.join(data_path, "test2.parquet")
|
||
df2.to_parquet(path2)
|
||
|
||
# Minimize the block size to prevent Ray Data from reading multiple fragments in a
|
||
# single task.
|
||
ray.data.DataContext.get_current().target_max_block_size = 1
|
||
ds = ray.data.read_parquet(data_path)
|
||
|
||
# Force reads.
|
||
bundles = ds.iter_internal_ref_bundles()
|
||
block_refs = _ref_bundles_iterator_to_block_refs_list(bundles)
|
||
ray.wait(block_refs, num_returns=len(block_refs), fetch_local=False)
|
||
location_data = ray.experimental.get_object_locations(block_refs)
|
||
locations = []
|
||
for block in block_refs:
|
||
locations.extend(location_data[block]["node_ids"])
|
||
assert set(locations) == {node1_id, node2_id}, set(locations)
|
||
|
||
|
||
@pytest.mark.parametrize("shuffle", [True, False, "file"])
|
||
def test_invalid_shuffle_arg_raises_error(
|
||
ray_start_regular_shared, shuffle, target_max_block_size_infinite_or_default
|
||
):
|
||
|
||
with pytest.raises(ValueError):
|
||
ray.data.read_parquet("example://iris.parquet", shuffle=shuffle)
|
||
|
||
|
||
@pytest.mark.parametrize("shuffle", [None, "files"])
|
||
def test_valid_shuffle_arg_does_not_raise_error(
|
||
ray_start_regular_shared, shuffle, target_max_block_size_infinite_or_default
|
||
):
|
||
ray.data.read_parquet("example://iris.parquet", shuffle=shuffle)
|
||
|
||
|
||
def test_partitioning_in_dataset_kwargs_raises_error(
|
||
ray_start_regular_shared, target_max_block_size_infinite_or_default
|
||
):
|
||
if ray.data.DataContext.get_current().use_datasource_v2:
|
||
pytest.skip(
|
||
"`dataset_kwargs` is deprecated and not supported on the DataSourceV2 path."
|
||
)
|
||
with pytest.raises(ValueError):
|
||
ray.data.read_parquet(
|
||
"example://iris.parquet", dataset_kwargs=dict(partitioning="hive")
|
||
)
|
||
|
||
|
||
def test_tensors_in_tables_parquet(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
tensor_format_context,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
"""This test verifies both V1 and V2 Tensor Type extensions of
|
||
Arrow Array types
|
||
"""
|
||
new_tensor_format = tensor_format_context
|
||
|
||
num_rows = 10_000
|
||
num_groups = 10
|
||
|
||
inner_shape = (2, 2, 2)
|
||
shape = (num_rows,) + inner_shape
|
||
num_tensor_elem = np.prod(np.array(shape))
|
||
|
||
arr = np.arange(num_tensor_elem).reshape(shape)
|
||
|
||
id_col_name = "_id"
|
||
group_col_name = "group"
|
||
tensor_col_name = "tensor"
|
||
|
||
id_vals = list(range(num_rows))
|
||
group_vals = [i % num_groups for i in id_vals]
|
||
|
||
df = pd.DataFrame(
|
||
{
|
||
id_col_name: id_vals,
|
||
group_col_name: group_vals,
|
||
tensor_col_name: [a.tobytes() for a in arr],
|
||
}
|
||
)
|
||
|
||
#
|
||
# Test #1: Verify writing tensors as ArrowTensorType (v1)
|
||
#
|
||
|
||
tensor_v1_path = f"{tmp_path}/tensor_v1"
|
||
|
||
ds = ray.data.from_pandas([df])
|
||
ds.write_parquet(tensor_v1_path)
|
||
|
||
ds = ray.data.read_parquet(
|
||
tensor_v1_path,
|
||
tensor_column_schema={tensor_col_name: (arr.dtype, inner_shape)},
|
||
override_num_blocks=10,
|
||
)
|
||
|
||
assert isinstance(
|
||
ds.schema().base_schema.field_by_name(tensor_col_name).type,
|
||
get_arrow_extension_fixed_shape_tensor_types(),
|
||
)
|
||
|
||
expected_tuples = list(zip(id_vals, group_vals, arr))
|
||
|
||
def _assert_equal(rows, expected):
|
||
values = [[s[id_col_name], s[group_col_name], s[tensor_col_name]] for s in rows]
|
||
|
||
assert len(values) == len(expected)
|
||
|
||
for v, e in zip(sorted(values, key=lambda v: v[0]), expected):
|
||
np.testing.assert_equal(v, e)
|
||
|
||
_assert_equal(ds.take_all(), expected_tuples)
|
||
|
||
#
|
||
# Test #2: Verify writing tensors as either
|
||
# - ArrowTensorTypeV2 or
|
||
# - (Arrow-native) FixedShapeTensorType
|
||
#
|
||
|
||
tensor_v2_path = f"{tmp_path}/tensor_new_{new_tensor_format}"
|
||
|
||
ds = ray.data.from_pandas([df])
|
||
ds.write_parquet(tensor_v2_path)
|
||
|
||
ds = ray.data.read_parquet(
|
||
tensor_v2_path,
|
||
tensor_column_schema={tensor_col_name: (arr.dtype, inner_shape)},
|
||
override_num_blocks=10,
|
||
)
|
||
|
||
_assert_equal(ds.take_all(), expected_tuples)
|
||
|
||
# With tensor_format_context, ARROW_NATIVE only runs when supported,
|
||
# so to_type() is safe to use without fallback
|
||
assert isinstance(
|
||
ds.schema().base_schema.field_by_name(tensor_col_name).type,
|
||
new_tensor_format.to_type(),
|
||
)
|
||
|
||
|
||
def test_multiple_files_with_ragged_arrays(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
# Test reading multiple parquet files, each of which has different-shaped
|
||
# ndarrays in the same column.
|
||
# See https://github.com/ray-project/ray/issues/47960 for more context.
|
||
num_rows = 3
|
||
ds = ray.data.range(num_rows)
|
||
|
||
def map(row):
|
||
id = row["id"] + 1
|
||
row["data"] = np.zeros((id * 100, id * 100), dtype=np.int8)
|
||
return row
|
||
|
||
# Write 3 parquet files with different-shaped ndarray values in the
|
||
# "data" column.
|
||
ds.map(map).repartition(num_rows).write_parquet(tmp_path)
|
||
|
||
# Read these 3 files, check that the result is correct.
|
||
ds2 = ray.data.read_parquet(tmp_path, override_num_blocks=1)
|
||
res = ds2.take_all()
|
||
res = sorted(res, key=lambda row: row["id"])
|
||
assert len(res) == num_rows
|
||
for index, item in enumerate(res):
|
||
assert item["id"] == index
|
||
assert item["data"].shape == (100 * (index + 1), 100 * (index + 1))
|
||
|
||
|
||
def test_count_with_filter(
|
||
ray_start_regular_shared, target_max_block_size_infinite_or_default
|
||
):
|
||
from ray.data.expressions import col, lit
|
||
|
||
ds = ray.data.read_parquet("example://iris.parquet").filter(
|
||
expr=col("sepal.length") < lit(0)
|
||
)
|
||
assert ds.count() == 0
|
||
assert isinstance(ds.count(), int)
|
||
|
||
|
||
def test_write_with_schema(ray_start_regular_shared, tmp_path):
|
||
ds = ray.data.range(1)
|
||
schema = pa.schema({"id": pa.float32()})
|
||
|
||
ds.write_parquet(tmp_path, schema=schema)
|
||
|
||
assert pq.read_table(tmp_path).schema == schema
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"row_data",
|
||
[
|
||
[{"a": 1, "b": None}, {"a": 1, "b": 2}],
|
||
[{"a": None, "b": 3}, {"a": 1, "b": 2}],
|
||
[{"a": None, "b": 1}, {"a": 1, "b": None}],
|
||
],
|
||
ids=["row1_b_null", "row1_a_null", "row_each_null"],
|
||
)
|
||
def test_write_auto_infer_nullable_fields(
|
||
tmp_path, ray_start_regular_shared, row_data, restore_data_context
|
||
):
|
||
"""
|
||
Test that when writing multiple blocks, we can automatically infer nullable
|
||
fields.
|
||
"""
|
||
ctx = DataContext.get_current()
|
||
# So that we force multiple blocks on mapping.
|
||
ctx.target_max_block_size = 1
|
||
ds = ray.data.range(len(row_data)).map(lambda row: row_data[row["id"]])
|
||
# So we force writing to a single file.
|
||
ds.write_parquet(tmp_path, min_rows_per_file=2)
|
||
|
||
|
||
def test_seed_file_shuffle(
|
||
restore_data_context, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
def write_parquet_file(path, file_index):
|
||
"""Write a dummy Parquet file with test data."""
|
||
# Create a dummy dataset with unique data for each file
|
||
data = {
|
||
"col1": range(10 * file_index, 10 * (file_index + 1)),
|
||
"col2": ["foo", "bar"] * 5,
|
||
}
|
||
table = pa.Table.from_pydict(data)
|
||
pq.write_table(table, path)
|
||
|
||
ctx = ray.data.DataContext.get_current()
|
||
ctx.execution_options.preserve_order = True
|
||
|
||
# Create temporary Parquet files for testing in the current directory
|
||
paths = [os.path.join(tmp_path, f"test_file_{i}.parquet") for i in range(5)]
|
||
for i, path in enumerate(paths):
|
||
# Write dummy Parquet files
|
||
write_parquet_file(path, i)
|
||
|
||
# Read with deterministic shuffling
|
||
shuffle_config = FileShuffleConfig(seed=42, reseed_after_execution=False)
|
||
ds1 = ray.data.read_parquet(paths, shuffle=shuffle_config)
|
||
ds2 = ray.data.read_parquet(paths, shuffle=shuffle_config)
|
||
|
||
# Verify deterministic behavior
|
||
assert ds1.take_all() == ds2.take_all()
|
||
|
||
|
||
def test_seed_file_shuffle_with_execution_update(
|
||
restore_data_context, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
def write_parquet_file(path, file_index):
|
||
"""Write a dummy Parquet file with test data."""
|
||
# Create a dummy dataset with unique data for each file
|
||
data = {
|
||
"col1": range(10 * file_index, 10 * (file_index + 1)),
|
||
"col2": ["foo", "bar"] * 5,
|
||
}
|
||
table = pa.Table.from_pydict(data)
|
||
pq.write_table(table, path)
|
||
|
||
ctx = ray.data.DataContext.get_current()
|
||
ctx.execution_options.preserve_order = True
|
||
|
||
# Create temporary Parquet files for testing in the current directory
|
||
paths = [os.path.join(tmp_path, f"test_file_{i}.parquet") for i in range(15)]
|
||
for i, path in enumerate(paths):
|
||
# Write dummy Parquet files
|
||
write_parquet_file(path, i)
|
||
|
||
shuffle_config = FileShuffleConfig(seed=42)
|
||
ds1 = ray.data.read_parquet(paths, shuffle=shuffle_config)
|
||
ds2 = ray.data.read_parquet(paths, shuffle=shuffle_config)
|
||
|
||
ds1_epoch_results = []
|
||
ds2_epoch_results = []
|
||
for i in range(5):
|
||
ds1_epoch = ds1.to_pandas()
|
||
ds2_epoch = ds2.to_pandas()
|
||
ds1_epoch_results.append(ds1_epoch)
|
||
ds2_epoch_results.append(ds2_epoch)
|
||
# For the same epoch, ds1 and ds2 should produce identical results
|
||
pd.testing.assert_frame_equal(ds1_epoch, ds2_epoch)
|
||
|
||
# Convert results to hashable format for comparison
|
||
def make_hashable(df):
|
||
"""Convert a DataFrame to a hashable string representation."""
|
||
return df.to_csv()
|
||
|
||
ds1_hashable_results = {make_hashable(result) for result in ds1_epoch_results}
|
||
ds2_hashable_results = {make_hashable(result) for result in ds2_epoch_results}
|
||
|
||
assert (
|
||
len(ds1_hashable_results) == 5
|
||
), "ds1 should produce different results across epochs"
|
||
assert (
|
||
len(ds2_hashable_results) == 5
|
||
), "ds2 should produce different results across epochs"
|
||
|
||
|
||
def test_seed_file_shuffle_with_execution_no_effect(
|
||
restore_data_context, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
def write_parquet_file(path, file_index):
|
||
"""Write a dummy Parquet file with test data."""
|
||
# Create a dummy dataset with unique data for each file
|
||
data = {
|
||
"col1": range(10 * file_index, 10 * (file_index + 1)),
|
||
"col2": ["foo", "bar"] * 5,
|
||
}
|
||
table = pa.Table.from_pydict(data)
|
||
pq.write_table(table, path)
|
||
|
||
ctx = ray.data.DataContext.get_current()
|
||
ctx.execution_options.preserve_order = True
|
||
|
||
# Create temporary Parquet files for testing in the current directory
|
||
paths = [os.path.join(tmp_path, f"test_file_{i}.parquet") for i in range(5)]
|
||
for i, path in enumerate(paths):
|
||
# Write dummy Parquet files
|
||
write_parquet_file(path, i)
|
||
|
||
shuffle_config = FileShuffleConfig(seed=42, reseed_after_execution=False)
|
||
ds1 = ray.data.read_parquet(paths, shuffle=shuffle_config)
|
||
ds2 = ray.data.read_parquet(paths, shuffle=shuffle_config)
|
||
|
||
ds1_execution_results = []
|
||
ds2_execution_results = []
|
||
for i in range(5):
|
||
ds1_execution = ds1.to_pandas()
|
||
ds2_execution = ds2.to_pandas()
|
||
ds1_execution_results.append(ds1_execution)
|
||
ds2_execution_results.append(ds2_execution)
|
||
# For the same execution, ds1 and ds2 should produce identical results
|
||
pd.testing.assert_frame_equal(ds1_execution, ds2_execution)
|
||
|
||
# Convert results to hashable format for comparison
|
||
def make_hashable(df):
|
||
"""Convert a DataFrame to a hashable string representation."""
|
||
return df.to_csv()
|
||
|
||
ds1_hashable_results = {make_hashable(result) for result in ds1_execution_results}
|
||
ds2_hashable_results = {make_hashable(result) for result in ds2_execution_results}
|
||
|
||
assert (
|
||
len(ds1_hashable_results) == 1
|
||
), "ds1 should produce the same results across executions"
|
||
assert (
|
||
len(ds2_hashable_results) == 1
|
||
), "ds2 should produce the same results across executions"
|
||
|
||
|
||
def test_read_file_with_partition_values(
|
||
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
|
||
):
|
||
# Typically, partition values are excluded from the Parquet file and are instead
|
||
# encoded in the directory structure. However, in some cases, partition values
|
||
# are also included in the Parquet file. This test verifies that case.
|
||
table = pa.Table.from_pydict({"data": [0], "year": [2024]})
|
||
os.makedirs(tmp_path / "year=2024")
|
||
pq.write_table(table, tmp_path / "year=2024" / "data.parquet")
|
||
|
||
ds = ray.data.read_parquet(tmp_path)
|
||
|
||
assert ds.take_all() == [{"data": 0, "year": 2024}]
|
||
|
||
|
||
def test_read_null_data_in_first_file(
|
||
tmp_path, ray_start_regular_shared, target_max_block_size_infinite_or_default
|
||
):
|
||
# The `read_parquet` implementation might infer the schema from the first file.
|
||
# This test ensures that implementation handles the case where the first file has no
|
||
# data and the inferred type is `null`.
|
||
pq.write_table(
|
||
pa.Table.from_pydict({"data": [None, None, None]}), tmp_path / "1.parquet"
|
||
)
|
||
pq.write_table(
|
||
pa.Table.from_pydict({"data": ["spam", "ham"]}), tmp_path / "2.parquet"
|
||
)
|
||
|
||
ds = ray.data.read_parquet(tmp_path)
|
||
|
||
rows = sorted(ds.take_all(), key=lambda row: row["data"] or "")
|
||
assert rows == [
|
||
{"data": None},
|
||
{"data": None},
|
||
{"data": None},
|
||
{"data": "ham"},
|
||
{"data": "spam"},
|
||
]
|
||
|
||
|
||
def test_read_parquet_does_not_call_infer_schema(
|
||
tmp_path, monkeypatch, ray_start_regular_shared, use_datasource_v2
|
||
):
|
||
"""The V2 read path should not call _infer_schema, which can cause O(N)
|
||
metadata reads. V1 still calls it to reconcile per-file schemas."""
|
||
|
||
num_files = 10
|
||
for i in range(num_files):
|
||
cols = {"data": [0]}
|
||
if i == 0:
|
||
cols["null_col"] = pa.nulls(1)
|
||
else:
|
||
cols["null_col"] = [1]
|
||
pq.write_table(pa.table(cols), tmp_path / f"part_{i:05d}.parquet")
|
||
|
||
mock = MagicMock()
|
||
monkeypatch.setattr(
|
||
"ray.data._internal.datasource.parquet_datasource._infer_schema",
|
||
mock,
|
||
)
|
||
mock.return_value = pa.schema({"data": pa.int64()})
|
||
ray.data.read_parquet(str(tmp_path))
|
||
if ray.data.DataContext.get_current().use_datasource_v2:
|
||
mock.assert_not_called()
|
||
else:
|
||
mock.assert_called()
|
||
|
||
|
||
def test_parquet_row_group_size_001(ray_start_regular_shared, tmp_path):
|
||
"""Verify row_group_size is respected."""
|
||
|
||
(
|
||
ray.data.range(10000)
|
||
.repartition(1)
|
||
.write_parquet(
|
||
tmp_path / "test_row_group_5k.parquet",
|
||
row_group_size=5000,
|
||
)
|
||
)
|
||
|
||
# Since version 15, use_legacy_dataset is deprecated.
|
||
if parse_version(pa.__version__) >= parse_version("15.0.0"):
|
||
ds = pq.ParquetDataset(tmp_path / "test_row_group_5k.parquet")
|
||
else:
|
||
ds = pq.ParquetDataset(
|
||
tmp_path / "test_row_group_5k.parquet",
|
||
use_legacy_dataset=False, # required for .fragments attribute
|
||
)
|
||
assert ds.fragments[0].num_row_groups == 2
|
||
|
||
|
||
def test_parquet_row_group_size_002(ray_start_regular_shared, tmp_path):
|
||
"""Verify arrow_parquet_args_fn is working with row_group_size."""
|
||
(
|
||
ray.data.range(10000)
|
||
.repartition(1)
|
||
.write_parquet(
|
||
tmp_path / "test_row_group_1k.parquet",
|
||
arrow_parquet_args_fn=lambda: {
|
||
"row_group_size": 1000, # overrides row_group_size
|
||
},
|
||
row_group_size=5000,
|
||
)
|
||
)
|
||
|
||
# Since version 15, use_legacy_dataset is deprecated.
|
||
if parse_version(pa.__version__) >= parse_version("15.0.0"):
|
||
ds = pq.ParquetDataset(tmp_path / "test_row_group_1k.parquet")
|
||
else:
|
||
ds = pq.ParquetDataset(
|
||
tmp_path / "test_row_group_1k.parquet",
|
||
use_legacy_dataset=False,
|
||
)
|
||
assert ds.fragments[0].num_row_groups == 10
|
||
|
||
|
||
@pytest.mark.parametrize("override_num_blocks", [1, 2, 3])
|
||
def test_max_block_size_none_respects_override_num_blocks(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
override_num_blocks,
|
||
target_max_block_size_infinite,
|
||
):
|
||
"""
|
||
When `DataContext.target_max_block_size` is explicitly set to ``None``,
|
||
TODO override_num_blocks should always be respected even when target_max_block_size isn't set to None.
|
||
read_parquet must still honour ``override_num_blocks``.
|
||
The read should yield the specified number of input blocks and – after a pivot –
|
||
one output row per block (since all rows have the same ID).
|
||
"""
|
||
if ray.data.DataContext.get_current().use_datasource_v2:
|
||
pytest.skip(
|
||
"DataSourceV2 does not support per-read-task block splitting for "
|
||
"``override_num_blocks`` on single-file inputs. V1's "
|
||
"``compute_additional_split_factor`` has no V2 equivalent "
|
||
"(confirmed absent in the proprietary engine as well)."
|
||
)
|
||
import os
|
||
|
||
import pandas as pd
|
||
|
||
# Build a >10 k-row Parquet file.
|
||
num_rows = 10_005
|
||
df = pd.DataFrame(
|
||
{
|
||
"ID": ["A"] * num_rows,
|
||
"values": range(num_rows),
|
||
"dttm": pd.date_range("2024-01-01", periods=num_rows, freq="h").astype(str),
|
||
}
|
||
)
|
||
file_path = os.path.join(tmp_path, "maxblock_none.parquet")
|
||
df.to_parquet(file_path)
|
||
|
||
# Read with the specified number of blocks enforced.
|
||
ds = ray.data.read_parquet(file_path, override_num_blocks=override_num_blocks)
|
||
|
||
def _pivot_data(batch: pd.DataFrame) -> pd.DataFrame: # noqa: WPS430
|
||
return batch.pivot(index="ID", columns="dttm", values="values")
|
||
|
||
out_ds = ds.map_batches(
|
||
_pivot_data,
|
||
batch_size=None,
|
||
batch_format="pandas",
|
||
)
|
||
out_df = out_ds.to_pandas()
|
||
|
||
# Create expected result using pandas pivot on original data
|
||
expected_df = df.pivot(index="ID", columns="dttm", values="values")
|
||
|
||
# Verify the schemas match (same columns)
|
||
assert set(out_df.columns) == set(expected_df.columns)
|
||
|
||
# Verify we have the expected number of rows (one per block)
|
||
assert len(out_df) == override_num_blocks
|
||
|
||
# Verify that all original values are present by comparing with expected result
|
||
# Only sum non-null values to avoid counting NaN as -1
|
||
expected_sum = expected_df.sum(skipna=True).sum()
|
||
actual_sum = out_df.sum(skipna=True).sum()
|
||
assert actual_sum == expected_sum
|
||
|
||
# Verify that the combined result contains the same data as the expected result
|
||
# by checking that each column's non-null values match
|
||
for col in expected_df.columns:
|
||
expected_values = expected_df[col].dropna()
|
||
actual_values = out_df[col].dropna()
|
||
assert len(expected_values) == len(actual_values)
|
||
assert set(expected_values) == set(actual_values)
|
||
|
||
|
||
@pytest.mark.parametrize("min_rows_per_file", [5, 10])
|
||
def test_write_partition_cols_with_min_rows_per_file(
|
||
tmp_path,
|
||
ray_start_regular_shared,
|
||
min_rows_per_file,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
"""Test write_parquet with both partition_cols and min_rows_per_file."""
|
||
|
||
# Create dataset with 2 partitions, each having 20 rows
|
||
df = pd.DataFrame(
|
||
{
|
||
"partition_col": [0] * 20 + [1] * 20, # 2 partitions with 20 rows each
|
||
"data": list(range(40)),
|
||
}
|
||
)
|
||
|
||
ds = ray.data.from_pandas(df)
|
||
ds.write_parquet(
|
||
tmp_path, partition_cols=["partition_col"], min_rows_per_file=min_rows_per_file
|
||
)
|
||
|
||
# Check partition directories exist
|
||
partition_0_dir = tmp_path / "partition_col=0"
|
||
partition_1_dir = tmp_path / "partition_col=1"
|
||
assert partition_0_dir.exists()
|
||
assert partition_1_dir.exists()
|
||
|
||
# With the new implementation that tries to minimize file count,
|
||
# each partition (20 rows) should be written as a single file
|
||
# since 20 >= min_rows_per_file for both test cases (5 and 10)
|
||
for partition_dir in [partition_0_dir, partition_1_dir]:
|
||
parquet_files = list(partition_dir.glob("*.parquet"))
|
||
|
||
# Verify total rows across all files in partition
|
||
total_rows = 0
|
||
file_sizes = []
|
||
for file_path in parquet_files:
|
||
table = pq.read_table(file_path)
|
||
file_size = len(table)
|
||
file_sizes.append(file_size)
|
||
total_rows += file_size
|
||
|
||
assert total_rows == 20 # Each partition should have 20 rows total
|
||
|
||
# Add explicit assertion about individual file sizes for clarity
|
||
print(
|
||
f"Partition {partition_dir.name} file sizes with min_rows_per_file={min_rows_per_file}: {file_sizes}"
|
||
)
|
||
|
||
# With the new optimization logic, we expect fewer files with larger sizes
|
||
# Each file should have at least min_rows_per_file rows
|
||
for file_size in file_sizes:
|
||
assert (
|
||
file_size >= min_rows_per_file
|
||
), f"File size {file_size} is less than min_rows_per_file {min_rows_per_file}"
|
||
|
||
# Verify we can read back the data correctly
|
||
ds_read = ray.data.read_parquet(tmp_path)
|
||
assert ds_read.count() == 40
|
||
assert set(ds_read.schema().names) == {"partition_col", "data"}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Verify that the data written and read back are identical
|
||
# ------------------------------------------------------------------
|
||
expected_df = df.sort_values("data").reset_index(drop=True)
|
||
actual_df = ds_read.to_pandas().sort_values("data").reset_index(drop=True)
|
||
|
||
# Parquet partition values are read back as strings; cast both sides.
|
||
actual_df["partition_col"] = actual_df["partition_col"].astype(str)
|
||
expected_df["partition_col"] = expected_df["partition_col"].astype(str)
|
||
|
||
# Align column order and compare.
|
||
actual_df = actual_df[expected_df.columns]
|
||
pd.testing.assert_frame_equal(actual_df, expected_df, check_dtype=False)
|
||
|
||
|
||
@pytest.mark.parametrize("max_rows_per_file", [5, 10, 25])
|
||
def test_write_max_rows_per_file(
|
||
tmp_path,
|
||
ray_start_regular_shared,
|
||
max_rows_per_file,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
ray.data.range(100, override_num_blocks=1).write_parquet(
|
||
tmp_path, max_rows_per_file=max_rows_per_file
|
||
)
|
||
|
||
total_rows = 0
|
||
file_sizes = []
|
||
for filename in os.listdir(tmp_path):
|
||
table = pq.read_table(os.path.join(tmp_path, filename))
|
||
file_size = len(table)
|
||
file_sizes.append(file_size)
|
||
assert file_size <= max_rows_per_file
|
||
total_rows += file_size
|
||
|
||
# Verify all rows were written
|
||
assert total_rows == 100
|
||
|
||
# Add explicit assertion about individual file sizes for clarity
|
||
print(f"File sizes with max_rows_per_file={max_rows_per_file}: {file_sizes}")
|
||
for size in file_sizes:
|
||
assert (
|
||
size <= max_rows_per_file
|
||
), f"File size {size} exceeds max_rows_per_file {max_rows_per_file}"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Verify the parquet round-trip: written data == read-back data
|
||
# ------------------------------------------------------------------
|
||
ds_reloaded = ray.data.read_parquet(tmp_path)
|
||
assert ds_reloaded.count() == 100
|
||
|
||
expected_df = (
|
||
pd.DataFrame({"id": list(range(100))}).sort_values("id").reset_index(drop=True)
|
||
)
|
||
actual_df = ds_reloaded.to_pandas().sort_values("id").reset_index(drop=True)
|
||
|
||
pd.testing.assert_frame_equal(actual_df, expected_df, check_dtype=False)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"min_rows_per_file,max_rows_per_file", [(5, 10), (10, 20), (15, 30)]
|
||
)
|
||
def test_write_min_max_rows_per_file(
|
||
tmp_path,
|
||
ray_start_regular_shared,
|
||
min_rows_per_file,
|
||
max_rows_per_file,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
ray.data.range(100, override_num_blocks=1).write_parquet(
|
||
tmp_path,
|
||
min_rows_per_file=min_rows_per_file,
|
||
max_rows_per_file=max_rows_per_file,
|
||
)
|
||
|
||
total_rows = 0
|
||
file_sizes = []
|
||
for filename in os.listdir(tmp_path):
|
||
table = pq.read_table(os.path.join(tmp_path, filename))
|
||
file_size = len(table)
|
||
file_sizes.append(file_size)
|
||
total_rows += file_size
|
||
|
||
# Verify all rows were written
|
||
assert total_rows == 100
|
||
|
||
# Add explicit assertion about individual file sizes for clarity
|
||
print(
|
||
f"File sizes with min={min_rows_per_file}, max={max_rows_per_file}: {file_sizes}"
|
||
)
|
||
for size in file_sizes:
|
||
if size < min_rows_per_file:
|
||
print(
|
||
f"File size {size} is less than min_rows_per_file {min_rows_per_file}"
|
||
)
|
||
assert (
|
||
size <= max_rows_per_file
|
||
), f"File size {size} not less than {max_rows_per_file}"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Verify the parquet round-trip: written data == read-back data
|
||
# ------------------------------------------------------------------
|
||
ds_reloaded = ray.data.read_parquet(tmp_path)
|
||
assert ds_reloaded.count() == 100
|
||
|
||
expected_df = (
|
||
pd.DataFrame({"id": list(range(100))}).sort_values("id").reset_index(drop=True)
|
||
)
|
||
actual_df = ds_reloaded.to_pandas().sort_values("id").reset_index(drop=True)
|
||
|
||
pd.testing.assert_frame_equal(actual_df, expected_df, check_dtype=False)
|
||
|
||
|
||
def test_write_max_rows_per_file_validation(tmp_path, ray_start_regular_shared):
|
||
"""Test validation of max_rows_per_file parameter."""
|
||
|
||
# Test negative value
|
||
with pytest.raises(
|
||
ValueError, match="max_rows_per_file must be a positive integer"
|
||
):
|
||
ray.data.range(100).write_parquet(tmp_path, max_rows_per_file=-1)
|
||
|
||
# Test zero value
|
||
with pytest.raises(
|
||
ValueError, match="max_rows_per_file must be a positive integer"
|
||
):
|
||
ray.data.range(100).write_parquet(tmp_path, max_rows_per_file=0)
|
||
|
||
|
||
def test_write_min_max_rows_per_file_validation(tmp_path, ray_start_regular_shared):
|
||
"""Test validation when both min and max are specified."""
|
||
|
||
# Test min > max
|
||
with pytest.raises(
|
||
ValueError,
|
||
match="min_rows_per_file .* cannot be greater than max_rows_per_file",
|
||
):
|
||
ray.data.range(100).write_parquet(
|
||
tmp_path, min_rows_per_file=20, max_rows_per_file=10
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("max_rows_per_file", [5, 10])
|
||
def test_write_partition_cols_with_max_rows_per_file(
|
||
tmp_path,
|
||
ray_start_regular_shared,
|
||
max_rows_per_file,
|
||
target_max_block_size_infinite_or_default,
|
||
):
|
||
"""Test max_rows_per_file with partition columns."""
|
||
import pyarrow.parquet as pq
|
||
|
||
# Create data with partition column
|
||
def create_row(row):
|
||
i = row["id"]
|
||
return {"id": i, "partition": i % 3, "value": f"value_{i}"}
|
||
|
||
ds = ray.data.range(30).map(create_row)
|
||
ds.write_parquet(
|
||
tmp_path, partition_cols=["partition"], max_rows_per_file=max_rows_per_file
|
||
)
|
||
|
||
# Check each partition directory
|
||
total_rows = 0
|
||
all_file_sizes = []
|
||
for partition_dir in os.listdir(tmp_path):
|
||
partition_path = os.path.join(tmp_path, partition_dir)
|
||
if os.path.isdir(partition_path):
|
||
partition_file_sizes = []
|
||
for filename in os.listdir(partition_path):
|
||
if filename.endswith(".parquet"):
|
||
table = pq.read_table(os.path.join(partition_path, filename))
|
||
file_size = len(table)
|
||
partition_file_sizes.append(file_size)
|
||
assert file_size <= max_rows_per_file
|
||
total_rows += file_size
|
||
all_file_sizes.extend(partition_file_sizes)
|
||
print(
|
||
f"Partition {partition_dir} file sizes with max_rows_per_file={max_rows_per_file}: {partition_file_sizes}"
|
||
)
|
||
|
||
# Verify all rows were written
|
||
assert total_rows == 30
|
||
|
||
# Add explicit assertion about individual file sizes for clarity
|
||
for size in all_file_sizes:
|
||
assert (
|
||
size <= max_rows_per_file
|
||
), f"File size {size} exceeds max_rows_per_file {max_rows_per_file}"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Verify the parquet round-trip: data read back must equal original
|
||
# ------------------------------------------------------------------
|
||
ds_reloaded = ray.data.read_parquet(tmp_path)
|
||
assert ds_reloaded.count() == 30
|
||
|
||
expected_rows = [
|
||
{"id": i, "partition": i % 3, "value": f"value_{i}"} for i in range(30)
|
||
]
|
||
expected_df = pd.DataFrame(expected_rows).sort_values("id").reset_index(drop=True)
|
||
actual_df = ds_reloaded.to_pandas().sort_values("id").reset_index(drop=True)
|
||
|
||
# Align column order for a strict equality check.
|
||
actual_df = actual_df[expected_df.columns]
|
||
# Parquet partition values are read back as strings; make both sides `str`
|
||
# so the value-level comparison succeeds (dtype may still differ).
|
||
actual_df["partition"] = actual_df["partition"].astype(str)
|
||
expected_df["partition"] = expected_df["partition"].astype(str)
|
||
|
||
pd.testing.assert_frame_equal(actual_df, expected_df, check_dtype=False)
|
||
|
||
|
||
@dataclass
|
||
class RowGroupLimitCase:
|
||
row_group_size: Optional[int]
|
||
min_rows_per_file: Optional[int]
|
||
max_rows_per_file: Optional[int]
|
||
expected_min: Optional[int]
|
||
expected_max: Optional[int]
|
||
expected_max_file: Optional[int]
|
||
|
||
|
||
ROW_GROUP_LIMIT_CASES = [
|
||
RowGroupLimitCase(
|
||
row_group_size=None,
|
||
min_rows_per_file=None,
|
||
max_rows_per_file=None,
|
||
expected_min=None,
|
||
expected_max=None,
|
||
expected_max_file=None,
|
||
),
|
||
RowGroupLimitCase(
|
||
row_group_size=1000,
|
||
min_rows_per_file=None,
|
||
max_rows_per_file=None,
|
||
expected_min=1000,
|
||
expected_max=1000,
|
||
expected_max_file=None,
|
||
),
|
||
RowGroupLimitCase(
|
||
row_group_size=None,
|
||
min_rows_per_file=500,
|
||
max_rows_per_file=None,
|
||
expected_min=500,
|
||
expected_max=None,
|
||
expected_max_file=None,
|
||
),
|
||
RowGroupLimitCase(
|
||
row_group_size=None,
|
||
min_rows_per_file=None,
|
||
max_rows_per_file=2000,
|
||
expected_min=None,
|
||
expected_max=2000,
|
||
expected_max_file=2000,
|
||
),
|
||
RowGroupLimitCase(
|
||
row_group_size=1000,
|
||
min_rows_per_file=500,
|
||
max_rows_per_file=2000,
|
||
expected_min=1000,
|
||
expected_max=1000,
|
||
expected_max_file=2000,
|
||
),
|
||
RowGroupLimitCase(
|
||
row_group_size=3000,
|
||
min_rows_per_file=500,
|
||
max_rows_per_file=2000,
|
||
expected_min=2000,
|
||
expected_max=2000,
|
||
expected_max_file=2000,
|
||
),
|
||
RowGroupLimitCase(
|
||
row_group_size=None,
|
||
min_rows_per_file=2000000, # Greater than 1024 * 1024 (1048576)
|
||
max_rows_per_file=None,
|
||
expected_min=2000000,
|
||
expected_max=2000000,
|
||
expected_max_file=2000000,
|
||
),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"case",
|
||
ROW_GROUP_LIMIT_CASES,
|
||
ids=[f"case_{i}" for i in range(len(ROW_GROUP_LIMIT_CASES))],
|
||
)
|
||
def test_choose_row_group_limits_parameterized(case):
|
||
"""Validate the helper across representative inputs."""
|
||
from ray.data._internal.datasource.parquet_datasink import choose_row_group_limits
|
||
|
||
result = choose_row_group_limits(
|
||
case.row_group_size, case.min_rows_per_file, case.max_rows_per_file
|
||
)
|
||
assert result == (
|
||
case.expected_min,
|
||
case.expected_max,
|
||
case.expected_max_file,
|
||
), f"Unexpected result for {case}"
|
||
|
||
# Invariants when both bounds are known.
|
||
min_rows, max_rows, _ = result
|
||
if min_rows is not None and max_rows is not None:
|
||
assert min_rows <= max_rows
|
||
|
||
|
||
def test_write_parquet_large_min_rows_per_file_exceeds_arrow_default(
|
||
tmp_path, ray_start_regular_shared
|
||
):
|
||
from ray.data._internal.datasource.parquet_datasink import (
|
||
ARROW_DEFAULT_MAX_ROWS_PER_GROUP,
|
||
)
|
||
|
||
"""Test that min_rows_per_file > ARROW_DEFAULT_MAX_ROWS_PER_GROUP triggers max_rows_per_group setting."""
|
||
# ARROW_DEFAULT_MAX_ROWS_PER_GROUP = 1024 * 1024 = 1048576
|
||
# We'll use a min_rows_per_file that exceeds this threshold
|
||
min_rows_per_file = (
|
||
2 * ARROW_DEFAULT_MAX_ROWS_PER_GROUP
|
||
) # 2097152, which is > 1048576
|
||
|
||
# Create a dataset with the required number of rows
|
||
ds = ray.data.range(min_rows_per_file, override_num_blocks=1)
|
||
|
||
# Write with min_rows_per_file > ARROW_DEFAULT_MAX_ROWS_PER_GROUP
|
||
# This should trigger the condition where max_rows_per_group and max_rows_per_file
|
||
# are set to min_rows_per_group (which comes from min_rows_per_file)
|
||
ds.write_parquet(tmp_path, min_rows_per_file=min_rows_per_file)
|
||
|
||
# Verify that the parquet files were written correctly
|
||
written_files = [f for f in os.listdir(tmp_path) if f.endswith(".parquet")]
|
||
assert len(written_files) == 1
|
||
|
||
# Read back the data to verify correctness
|
||
ds_read = ray.data.read_parquet(tmp_path)
|
||
assert ds_read.count() == min_rows_per_file
|
||
|
||
|
||
def test_read_parquet_with_zero_row_groups(shutdown_only, tmp_path):
|
||
"""Test reading a parquet file with 0 row groups."""
|
||
# Create an empty parquet file (0 row groups)
|
||
empty_path = os.path.join(tmp_path, "empty.parquet")
|
||
schema = pa.schema({"id": pa.int64()})
|
||
with pq.ParquetWriter(empty_path, schema):
|
||
pass
|
||
|
||
parquet_file = pq.ParquetFile(empty_path)
|
||
assert parquet_file.num_row_groups == 0
|
||
|
||
# Test reading the empty parquet file
|
||
dataset = ray.data.read_parquet(empty_path)
|
||
assert dataset.count() == 0
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"partition_info",
|
||
[
|
||
{"partition_cols": None, "output_dir": "test_output"},
|
||
{
|
||
"partition_cols": ["id_mod"],
|
||
"output_dir": "test_output_partitioned",
|
||
},
|
||
],
|
||
ids=["no_partitioning", "with_partitioning"],
|
||
)
|
||
def test_parquet_write_parallel_overwrite(
|
||
ray_start_regular_shared, tmp_path, partition_info
|
||
):
|
||
"""Test parallel Parquet write with overwrite mode."""
|
||
|
||
partition_cols = partition_info["partition_cols"]
|
||
output_dir = partition_info["output_dir"]
|
||
|
||
# Create dataset with 1000 rows
|
||
df_data = {"id": range(1000), "value": [f"value_{i}" for i in range(1000)]}
|
||
if partition_cols:
|
||
df_data["id_mod"] = [i % 10 for i in range(1000)] # 10 partitions
|
||
df = pd.DataFrame(df_data)
|
||
ds = ray.data.from_pandas(df)
|
||
|
||
# Repartition to ensure multiple write tasks
|
||
ds = ds.repartition(10)
|
||
|
||
# Write with overwrite mode
|
||
path = os.path.join(tmp_path, output_dir)
|
||
ds.write_parquet(path, mode="overwrite", partition_cols=partition_cols)
|
||
|
||
# Read back and verify
|
||
result = ray.data.read_parquet(path)
|
||
assert result.count() == 1000
|
||
|
||
|
||
def test_read_parquet_with_none_partitioning_and_columns(tmp_path):
|
||
# Test for https://github.com/ray-project/ray/issues/55279.
|
||
table = pa.table({"column": [42]})
|
||
path = os.path.join(tmp_path, "file.parquet")
|
||
pq.write_table(table, path)
|
||
|
||
ds = ray.data.read_parquet(path, partitioning=None).select_columns(["column"])
|
||
|
||
assert ds.take_all() == [{"column": 42}]
|
||
|
||
|
||
def _create_test_data(num_rows: int) -> dict:
|
||
return {
|
||
"int_col": list(range(num_rows)),
|
||
"float_col": [float(i) for i in range(num_rows)],
|
||
"str_col": [f"str_{i}" for i in range(num_rows)],
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"batch_size,filter_expr,expected_rows,description",
|
||
[
|
||
# No batch size cases
|
||
(None, "int_col > 500", 499, "No batch size, int > 500"),
|
||
(None, "int_col < 200", 200, "No batch size, int < 200"),
|
||
(
|
||
None,
|
||
"float_col == 42.0",
|
||
1,
|
||
"No batch size, float == 42.0",
|
||
),
|
||
(
|
||
None,
|
||
"str_col == 'str_42'",
|
||
1,
|
||
"No batch size, str == str_42",
|
||
),
|
||
# Batch size cases
|
||
(100, "int_col > 500", 499, "Fixed batch size, int > 500"),
|
||
(200, "int_col < 200", 200, "Fixed batch size, int < 200"),
|
||
(
|
||
300,
|
||
"float_col == 42.0",
|
||
1,
|
||
"Fixed batch size, float == 42.0",
|
||
),
|
||
(
|
||
400,
|
||
"str_col == 'str_42'",
|
||
1,
|
||
"Fixed batch size, str == str_42",
|
||
),
|
||
],
|
||
)
|
||
def test_read_parquet_with_filter_selectivity(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
batch_size,
|
||
filter_expr,
|
||
expected_rows,
|
||
description,
|
||
):
|
||
"""Test reading parquet files with filter expressions and different batch sizes."""
|
||
num_rows = 1000
|
||
data = _create_test_data(num_rows)
|
||
table = pa.Table.from_pydict(data)
|
||
|
||
file_path = os.path.join(tmp_path, "test.parquet")
|
||
pq.write_table(table, file_path, row_group_size=200)
|
||
|
||
if batch_size is not None:
|
||
ray.data.DataContext.get_current().target_max_block_size = batch_size
|
||
ds = ray.data.read_parquet(file_path).filter(expr=filter_expr)
|
||
|
||
assert ds.count() == expected_rows, (
|
||
f"{description}: Filter '{filter_expr}' returned {ds.count()} rows, "
|
||
f"expected {expected_rows}"
|
||
)
|
||
|
||
# Verify schema has expected columns and types
|
||
assert ds.schema().base_schema == table.schema
|
||
|
||
|
||
@pytest.mark.parametrize("batch_size", [None, 100, 200, 10_000])
|
||
@pytest.mark.parametrize(
|
||
"columns",
|
||
[
|
||
# Empty projection
|
||
[],
|
||
["int_col"],
|
||
["int_col", "float_col", "str_col"],
|
||
],
|
||
)
|
||
def test_read_parquet_with_columns_selectivity(
|
||
ray_start_regular_shared,
|
||
tmp_path,
|
||
batch_size,
|
||
columns,
|
||
):
|
||
"""Test reading parquet files with different column selections and batch sizes."""
|
||
num_rows = 1000
|
||
data = _create_test_data(num_rows)
|
||
table = pa.Table.from_pydict(data)
|
||
|
||
file_path = os.path.join(tmp_path, "test.parquet")
|
||
pq.write_table(table, file_path, row_group_size=200)
|
||
|
||
if batch_size is not None:
|
||
ray.data.DataContext.get_current().target_max_block_size = batch_size
|
||
ds = ray.data.read_parquet(file_path).select_columns(columns)
|
||
|
||
assert ds.count() == num_rows, (
|
||
f"Column selection {columns} with batch_size={batch_size} "
|
||
f"returned {ds.count()} rows, expected {num_rows}"
|
||
)
|
||
|
||
assert set(ds.schema().names) == set(columns), (
|
||
f"Column selection {columns} with batch_size={batch_size} "
|
||
f"returned columns {ds.schema().names}"
|
||
)
|
||
|
||
|
||
def test_get_parquet_dataset_fs_serialization_fallback(
|
||
ray_start_regular_shared, tmp_path: pathlib.Path
|
||
):
|
||
"""Test that the fallback mechanism for serializing the filesystem works."""
|
||
# 1) Local parquet file
|
||
local_file = tmp_path / "test.parquet"
|
||
pq.write_table(
|
||
pa.Table.from_pandas(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})), local_file
|
||
)
|
||
|
||
# 2) Problematic fsspec FS wrapped as a *PyArrow* FS so ParquetDataset accepts it
|
||
class BadFSSpec(fsspec.AbstractFileSystem):
|
||
protocol = "file"
|
||
|
||
def info(self, path, **kwargs):
|
||
# Wrong shape → fsspec/pyarrow will later blow up with TypeError
|
||
return ["not", "a", "dict"]
|
||
|
||
def ls(self, path, **kwargs):
|
||
return [{"name": str(path), "type": "file", "size": os.path.getsize(path)}]
|
||
|
||
def open(self, path, mode="rb", **kwargs):
|
||
return open(path, mode)
|
||
|
||
problematic_fs = PyFileSystem(FSSpecHandler(BadFSSpec()))
|
||
|
||
# 3) Direct ParquetDataset in worker → should raise (TypeError/ArrowException)
|
||
@ray.remote
|
||
def direct_parquet_usage(paths, fs, kwargs):
|
||
import pyarrow.parquet as pq
|
||
|
||
return pq.ParquetDataset(paths, filesystem=fs, **(kwargs or {}))
|
||
|
||
with pytest.raises(Exception) as exc_info:
|
||
ray.get(direct_parquet_usage.remote([str(local_file)], problematic_fs, {}))
|
||
|
||
msg = str(exc_info.value).lower()
|
||
assert any(
|
||
k in msg
|
||
for k in ["typeerror", "filesystem", "cannot wrap", "pickle", "serialize"]
|
||
)
|
||
|
||
# 4) Helper should succeed (fallback re-resolves to LocalFileSystem inside worker)
|
||
@ray.remote
|
||
def call_helper(paths, fs, kwargs):
|
||
from ray.data._internal.datasource.parquet_datasource import get_parquet_dataset
|
||
|
||
return get_parquet_dataset(paths, filesystem=fs, dataset_kwargs=kwargs)
|
||
|
||
ds = ray.get(call_helper.remote([str(local_file)], problematic_fs, {}))
|
||
assert ds is not None
|
||
|
||
|
||
@pytest.fixture
|
||
def hive_partitioned_dataset(tmp_path):
|
||
"""Create a Hive-partitioned Parquet dataset for testing."""
|
||
# Create test data with multiple partitions
|
||
num_partitions = 3
|
||
rows_per_partition = 10
|
||
data = []
|
||
for partition_val in range(num_partitions):
|
||
for i in range(rows_per_partition):
|
||
data.append(
|
||
{
|
||
"id": partition_val * rows_per_partition + i,
|
||
"value": f"val_{partition_val}_{i}",
|
||
"score": partition_val * 10 + i,
|
||
"country": f"country_{partition_val % 2}",
|
||
"year": 2020 + partition_val,
|
||
}
|
||
)
|
||
|
||
# Create base DataFrame
|
||
base_df = pd.DataFrame(data)
|
||
|
||
# Write as Hive-partitioned Parquet
|
||
partitioned_path = os.path.join(tmp_path, "partitioned_data")
|
||
table = pa.Table.from_pandas(base_df)
|
||
pq.write_to_dataset(
|
||
table,
|
||
root_path=partitioned_path,
|
||
partition_cols=["country", "year"],
|
||
existing_data_behavior="overwrite_or_ignore",
|
||
)
|
||
|
||
return partitioned_path, base_df
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"operations",
|
||
[
|
||
# Single operations
|
||
("select",),
|
||
("select_partition_and_data",),
|
||
("select_data_only",),
|
||
("rename_partition",),
|
||
("rename_data",),
|
||
("rename_partition_and_data",),
|
||
("filter_partition",),
|
||
("filter_data",),
|
||
("filter_partition_and_data",),
|
||
("with_column",),
|
||
# Two-operation combinations
|
||
("select", "rename_partition"),
|
||
("select", "rename_data"),
|
||
("select", "rename_partition_and_data"),
|
||
("select", "filter_partition"),
|
||
("select", "filter_data"),
|
||
# Test narrowing projection: select all columns, then narrow to exclude some partition columns
|
||
("select", "select_partition_and_data"),
|
||
("select", "select_data_only"),
|
||
("rename_partition", "filter_partition"),
|
||
("rename_partition", "filter_data"),
|
||
("rename_data", "filter_partition"),
|
||
("rename_data", "filter_data"),
|
||
("rename_partition_and_data", "filter_partition_and_data"),
|
||
("with_column", "rename_partition"),
|
||
("with_column", "rename_data"),
|
||
("with_column", "filter_data"),
|
||
# Three-operation combinations
|
||
("select", "rename_partition", "filter_partition"),
|
||
("select", "rename_data", "filter_data"),
|
||
("select", "rename_partition_and_data", "filter_partition_and_data"),
|
||
("rename_partition", "filter_partition", "with_column"),
|
||
("rename_data", "filter_data", "with_column"),
|
||
# Four-operation combinations
|
||
(
|
||
"select",
|
||
"rename_partition_and_data",
|
||
"filter_partition_and_data",
|
||
"with_column",
|
||
),
|
||
],
|
||
ids=lambda ops: "_".join(ops) if isinstance(ops, tuple) else ops,
|
||
)
|
||
@pytest.mark.skipif(
|
||
get_pyarrow_version() < parse_version("14.0.0"),
|
||
reason="Hive partitioned parquet operations require pyarrow >= 14.0.0",
|
||
)
|
||
def test_hive_partitioned_parquet_operations(
|
||
ray_start_regular_shared,
|
||
hive_partitioned_dataset,
|
||
operations,
|
||
):
|
||
"""Test various operations on Hive-partitioned Parquet datasets.
|
||
|
||
This test verifies that select_columns, rename_columns, filter, and with_column
|
||
work correctly with Hive-partitioned datasets, including combinations of operations.
|
||
All operations are tested without materializing to ensure projection pushdown works.
|
||
"""
|
||
from ray.data.expressions import col
|
||
|
||
partitioned_path, base_df = hive_partitioned_dataset
|
||
|
||
# Define operations with their implementations for both pandas and Ray
|
||
class ColumnTracker:
|
||
"""Helper to track column names as they get renamed."""
|
||
|
||
def __init__(self, columns: list[str]) -> None:
|
||
"""Initialize tracker with column names.
|
||
|
||
Args:
|
||
columns: List of column names to track (identity mapping initially).
|
||
"""
|
||
self.names: dict[str, str] = {col: col for col in columns}
|
||
|
||
def __getitem__(self, key: str) -> str:
|
||
return self.names[key]
|
||
|
||
def rename(self, rename_map: dict[str, str]) -> None:
|
||
"""Update column names based on rename map."""
|
||
self.names.update(rename_map)
|
||
|
||
def _apply_rename(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
base_rename_map: dict[str, str],
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply rename operation to pandas DataFrame or Ray Dataset."""
|
||
rename_map = {cols[k]: v for k, v in base_rename_map.items()}
|
||
cols.rename(rename_map)
|
||
return (
|
||
data.rename_columns(rename_map)
|
||
if is_ray_ds
|
||
else data.rename(columns=rename_map)
|
||
)
|
||
|
||
def _apply_select(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply select operation."""
|
||
selected_cols = [
|
||
cols["id"],
|
||
cols["value"],
|
||
cols["score"],
|
||
cols["country"],
|
||
cols["year"],
|
||
]
|
||
return data.select_columns(selected_cols) if is_ray_ds else data[selected_cols]
|
||
|
||
def _apply_select_partition_and_data(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply select partition and data operation (selects only country and id)."""
|
||
selected_cols = [
|
||
cols["country"],
|
||
cols["id"],
|
||
]
|
||
return data.select_columns(selected_cols) if is_ray_ds else data[selected_cols]
|
||
|
||
def _apply_select_data_only(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply select data only operation (selects only data columns, no partition columns)."""
|
||
selected_cols = [
|
||
cols["id"],
|
||
cols["value"],
|
||
cols["score"],
|
||
]
|
||
return data.select_columns(selected_cols) if is_ray_ds else data[selected_cols]
|
||
|
||
def _apply_rename_partition(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply rename partition operation."""
|
||
base_rename_map = {"country": "country_renamed", "year": "year_renamed"}
|
||
return _apply_rename(data, cols, is_ray_ds, base_rename_map)
|
||
|
||
def _apply_rename_data(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply rename data operation."""
|
||
base_rename_map = {"id": "id_renamed", "value": "value_renamed"}
|
||
return _apply_rename(data, cols, is_ray_ds, base_rename_map)
|
||
|
||
def _apply_rename_partition_and_data(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply rename partition and data operation."""
|
||
base_rename_map = {
|
||
"country": "country_renamed",
|
||
"year": "year_renamed",
|
||
"id": "id_renamed",
|
||
"value": "value_renamed",
|
||
}
|
||
return _apply_rename(data, cols, is_ray_ds, base_rename_map)
|
||
|
||
def _apply_filter_partition(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply filter partition operation."""
|
||
if is_ray_ds:
|
||
return data.filter(expr=(col(cols["country"]) == "country_0"))
|
||
else:
|
||
return data[data[cols["country"]] == "country_0"]
|
||
|
||
def _apply_filter_data(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply filter data operation."""
|
||
if is_ray_ds:
|
||
return data.filter(expr=(col(cols["score"]) >= 10))
|
||
else:
|
||
return data[data[cols["score"]] >= 10]
|
||
|
||
def _apply_filter_partition_and_data(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply filter partition and data operation."""
|
||
if is_ray_ds:
|
||
return data.filter(
|
||
expr=(col(cols["country"]) == "country_0") & (col(cols["score"]) >= 10)
|
||
)
|
||
else:
|
||
return data[
|
||
(data[cols["country"]] == "country_0") & (data[cols["score"]] >= 10)
|
||
]
|
||
|
||
def _apply_with_column(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply with_column operation."""
|
||
if is_ray_ds:
|
||
return data.with_column("new_col", col(cols["score"]) * 2)
|
||
else:
|
||
data = data.copy()
|
||
data["new_col"] = data[cols["score"]] * 2
|
||
return data
|
||
|
||
# Dispatch dictionary mapping operation names to their handlers
|
||
op_handlers = {
|
||
"select": _apply_select,
|
||
"select_partition_and_data": _apply_select_partition_and_data,
|
||
"select_data_only": _apply_select_data_only,
|
||
"rename_partition": _apply_rename_partition,
|
||
"rename_data": _apply_rename_data,
|
||
"rename_partition_and_data": _apply_rename_partition_and_data,
|
||
"filter_partition": _apply_filter_partition,
|
||
"filter_data": _apply_filter_data,
|
||
"filter_partition_and_data": _apply_filter_partition_and_data,
|
||
"with_column": _apply_with_column,
|
||
}
|
||
|
||
def apply_operation(
|
||
data: Union[pd.DataFrame, "ray.data.Dataset"],
|
||
op: str,
|
||
cols: ColumnTracker,
|
||
is_ray_ds: bool = False,
|
||
) -> Union[pd.DataFrame, "ray.data.Dataset"]:
|
||
"""Apply a single operation to pandas DataFrame or Ray Dataset."""
|
||
handler = op_handlers[op]
|
||
return handler(data, cols, is_ray_ds)
|
||
|
||
# Apply operations to pandas DataFrame for expected results
|
||
expected_df = base_df.copy()
|
||
expected_cols = ColumnTracker(list(base_df.columns))
|
||
for op in operations:
|
||
expected_df = apply_operation(expected_df, op, expected_cols, is_ray_ds=False)
|
||
|
||
# Apply operations to Ray Dataset
|
||
ds = ray.data.read_parquet(partitioned_path)
|
||
ds_cols = ColumnTracker(list(base_df.columns))
|
||
for op in operations:
|
||
ds = apply_operation(ds, op, ds_cols, is_ray_ds=True)
|
||
|
||
# Convert to pandas and normalize for comparison
|
||
actual_df = ds.to_pandas()
|
||
|
||
# Normalize column types (partition columns are strings in Parquet)
|
||
for col_name in ["country", "country_renamed", "year", "year_renamed"]:
|
||
if col_name in expected_df.columns:
|
||
expected_df[col_name] = expected_df[col_name].astype(str)
|
||
if col_name in actual_df.columns:
|
||
actual_df[col_name] = actual_df[col_name].astype(str)
|
||
|
||
# Sort both DataFrames for consistent comparison
|
||
sort_cols = sorted(set(expected_df.columns) & set(actual_df.columns))
|
||
expected_df = expected_df.sort_values(by=sort_cols).reset_index(drop=True)
|
||
actual_df = actual_df.sort_values(by=sort_cols).reset_index(drop=True)
|
||
|
||
# Ensure column order matches
|
||
actual_df = actual_df[expected_df.columns]
|
||
|
||
# Verify results match
|
||
assert rows_same(actual_df, expected_df), (
|
||
f"Operations {operations} produced different results.\n"
|
||
f"Expected columns: {list(expected_df.columns)}\n"
|
||
f"Actual columns: {list(actual_df.columns)}\n"
|
||
f"Expected shape: {expected_df.shape}\n"
|
||
f"Actual shape: {actual_df.shape}\n"
|
||
f"Expected head:\n{expected_df.head()}\n"
|
||
f"Actual head:\n{actual_df.head()}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("choice", ["default", "hive", "filename", "ray_default"])
|
||
@pytest.mark.skipif(
|
||
get_pyarrow_version() < parse_version("14.0.0"),
|
||
reason="Hive partitioned parquet operations require pyarrow >= 14.0.0",
|
||
)
|
||
def test_write_parquet_partitioning(choice, tmp_path):
|
||
|
||
# Ray's default is "hive", while pyarrow's default is "directory" (when None).
|
||
kwargs = {
|
||
"default": (
|
||
{"partitioning_flavor": None},
|
||
pds.partitioning(field_names=["grp"], flavor=None),
|
||
),
|
||
"hive": ({"partitioning_flavor": "hive"}, pds.partitioning(flavor="hive")),
|
||
"filename": (
|
||
{"partitioning_flavor": "filename"},
|
||
pds.partitioning(
|
||
pa.schema([pa.field("grp", pa.int64())]), flavor="filename"
|
||
),
|
||
),
|
||
"ray_default": (
|
||
{},
|
||
"hive",
|
||
),
|
||
}
|
||
|
||
parquet_kwargs, partitioning = kwargs[choice]
|
||
|
||
ds = ray.data.range(1000).add_column(
|
||
"grp", lambda x: x["id"] % 10, batch_format="numpy"
|
||
)
|
||
|
||
ds.write_parquet(
|
||
tmp_path,
|
||
partition_cols=["grp"],
|
||
**parquet_kwargs,
|
||
mode="overwrite",
|
||
)
|
||
|
||
pq_ds = pq.ParquetDataset(
|
||
tmp_path,
|
||
partitioning=partitioning,
|
||
)
|
||
df = pq_ds.read_pandas().to_pandas()
|
||
|
||
assert len(df) == 1000
|
||
assert df["grp"].nunique() == 10
|
||
assert set(df.columns.tolist()) == {"id", "grp"}
|
||
|
||
|
||
def test_fsspec_filesystem(ray_start_regular_shared, tmp_path):
|
||
"""Same as `test_parquet_write` but using a custom, fsspec filesystem.
|
||
|
||
TODO (Alex): We should write a similar test with a mock PyArrow fs, but
|
||
unfortunately pa.fs._MockFileSystem isn't serializable, so this may require
|
||
some effort.
|
||
"""
|
||
from fsspec.implementations.local import LocalFileSystem
|
||
|
||
df1 = pd.DataFrame({"one": [1, 2, 3], "two": ["a", "b", "c"]})
|
||
df2 = pd.DataFrame({"one": [4, 5, 6], "two": ["e", "f", "g"]})
|
||
table = pa.Table.from_pandas(df1)
|
||
path1 = os.path.join(str(tmp_path), "test1.parquet")
|
||
pq.write_table(table, path1)
|
||
table = pa.Table.from_pandas(df2)
|
||
path2 = os.path.join(str(tmp_path), "test2.parquet")
|
||
pq.write_table(table, path2)
|
||
|
||
fs = LocalFileSystem()
|
||
|
||
ds = ray.data.read_parquet([path1, path2], filesystem=fs)
|
||
|
||
# Test metadata-only parquet ops.
|
||
assert ds.count() == 6
|
||
|
||
out_path = os.path.join(tmp_path, "out")
|
||
os.mkdir(out_path)
|
||
|
||
ds._set_uuid("data")
|
||
ds.write_parquet(out_path)
|
||
|
||
actual_data = set(pd.read_parquet(out_path).itertuples(index=False))
|
||
expected_data = set(pd.concat([df1, df2]).itertuples(index=False))
|
||
assert actual_data == expected_data, (actual_data, expected_data)
|
||
|
||
|
||
class TestParquetFragmentBatchSizeCoercion:
|
||
"""Regression: PyArrow ``Fragment.to_batches`` uses a C int for ``batch_size``.
|
||
|
||
``_coerce_pyarrow_fragment_batch_size`` raises for non-positive values and clamps
|
||
values above ``_MAX_PYARROW_TO_BATCHES_BATCH_SIZE`` to that maximum.
|
||
"""
|
||
|
||
@pytest.mark.parametrize(
|
||
"raw,expected",
|
||
[
|
||
(2**31, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE),
|
||
(10**12, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE),
|
||
(_MAX_PYARROW_TO_BATCHES_BATCH_SIZE, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE),
|
||
(0, ValueError),
|
||
(-3, ValueError),
|
||
(10_000, 10_000),
|
||
],
|
||
)
|
||
def test_coerce_pyarrow_fragment_batch_size(self, raw, expected):
|
||
if expected is ValueError:
|
||
with pytest.raises(ValueError, match="Batch size must be > 0"):
|
||
_coerce_pyarrow_fragment_batch_size(raw)
|
||
else:
|
||
assert _coerce_pyarrow_fragment_batch_size(raw) == expected
|
||
|
||
@pytest.mark.parametrize(
|
||
"to_batches_kwargs,expected_batch_size_passed_to_to_batches",
|
||
[
|
||
({"batch_size": 10**12}, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE),
|
||
({"batch_size": 2**31}, _MAX_PYARROW_TO_BATCHES_BATCH_SIZE),
|
||
({"batch_size": 10_000}, 10_000),
|
||
({"batch_size": np.int64(10_000)}, 10_000),
|
||
({"batch_size": 0}, ValueError),
|
||
({"batch_size": -3}, ValueError),
|
||
({"batch_size": -1}, ValueError),
|
||
],
|
||
)
|
||
def test_read_batches_from_coerces_fragment_batch_size_to_c_int_range(
|
||
self, to_batches_kwargs, expected_batch_size_passed_to_to_batches
|
||
):
|
||
"""``batch_size`` in ``to_batches_kwargs`` is coerced for PyArrow's C int."""
|
||
|
||
captured: dict = {}
|
||
|
||
def fake_to_batches(
|
||
*, columns=None, filter=None, schema=None, use_threads=False, **kwargs
|
||
):
|
||
captured["batch_size"] = kwargs.get("batch_size")
|
||
return iter([])
|
||
|
||
fragment = MagicMock()
|
||
fragment.path = "/tmp/test.parquet"
|
||
fragment.to_batches = fake_to_batches
|
||
fragment.physical_schema = pa.schema([("x", pa.int64())])
|
||
|
||
schema = pa.schema([("x", pa.int64())])
|
||
|
||
def run_read():
|
||
return list(
|
||
_read_batches_from(
|
||
fragment,
|
||
schema=schema,
|
||
data_columns=["x"],
|
||
partition_columns=None,
|
||
partitioning=Partitioning("hive"),
|
||
to_batches_kwargs=to_batches_kwargs,
|
||
)
|
||
)
|
||
|
||
if expected_batch_size_passed_to_to_batches is ValueError:
|
||
with pytest.raises(ValueError, match="Batch size must be > 0"):
|
||
run_read()
|
||
else:
|
||
assert run_read() == []
|
||
assert captured["batch_size"] == expected_batch_size_passed_to_to_batches
|
||
|
||
|
||
def test_get_safe_batch_size_skips_zero_uncompressed_row_groups(tmp_path):
|
||
"""Regression: a row group whose selected columns have zero uncompressed
|
||
size (e.g. all-null nested data) should not cause ZeroDivisionError."""
|
||
import pyarrow.parquet as pq
|
||
|
||
from ray.data._internal.datasource.parquet_datasource import (
|
||
_get_safe_batch_size_for_nested_types,
|
||
)
|
||
|
||
table = pa.table({"x": pa.array([[1, 2]], type=pa.list_(pa.int64()))})
|
||
path = str(tmp_path / "test.parquet")
|
||
pq.write_table(table, path)
|
||
|
||
pf = pq.ParquetFile(path)
|
||
# Pass empty column_indices so _row_group_uncompressed_size returns 0
|
||
# for a row group with non-zero rows — should not raise ZeroDivisionError.
|
||
batch_size = _get_safe_batch_size_for_nested_types(pf, column_indices=[])
|
||
assert batch_size >= 1
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def nested_parquet_exceeding_2gb(tmp_path_factory):
|
||
"""Create a Parquet file with nested columns (list<struct>) whose string
|
||
data in a single row group exceeds Arrow's ~2GB chunking threshold.
|
||
|
||
This triggers ARROW-5030 / ArrowNotImplementedError when read via the
|
||
Arrow Dataset Scanner (fragment.to_batches).
|
||
"""
|
||
tmp_path = tmp_path_factory.mktemp("nested_parquet")
|
||
|
||
num_rows = 2500
|
||
items_per_row = 10
|
||
payload_size = 50_000 # os.urandom bytes -> 100KB hex string per item
|
||
|
||
ids = list(range(num_rows))
|
||
nested_data = [
|
||
[
|
||
{
|
||
"key": f"item_{i}_{j}",
|
||
"payload": os.urandom(payload_size).hex(),
|
||
"value": j,
|
||
}
|
||
for j in range(items_per_row)
|
||
]
|
||
for i in ids
|
||
]
|
||
|
||
schema = pa.schema(
|
||
[
|
||
("id", pa.int64()),
|
||
(
|
||
"nested_col",
|
||
pa.list_(
|
||
pa.struct(
|
||
[
|
||
("key", pa.string()),
|
||
("payload", pa.string()),
|
||
("value", pa.int64()),
|
||
]
|
||
)
|
||
),
|
||
),
|
||
]
|
||
)
|
||
|
||
table = pa.table({"id": ids, "nested_col": nested_data}, schema=schema)
|
||
file_path = os.path.join(str(tmp_path), "data.parquet")
|
||
pq.write_table(table, file_path, row_group_size=num_rows, use_dictionary=False)
|
||
return str(tmp_path), file_path, num_rows, schema
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
parse_version(pa.__version__) < parse_version("16.0.0"),
|
||
reason="PyArrow < 16 cannot construct >2 GB nested arrays from Python lists",
|
||
)
|
||
@pytest.mark.timeout(300)
|
||
def test_read_parquet_nested_type_arrow_not_implemented_fallback(
|
||
ray_start_regular_shared, nested_parquet_exceeding_2gb
|
||
):
|
||
"""Test that read_parquet succeeds on Parquet files with nested column types
|
||
whose data exceeds Arrow's ~2GB chunking threshold, by falling back to
|
||
pq.ParquetFile.iter_batches.
|
||
|
||
Regression test for https://github.com/ray-project/ray/issues/61675
|
||
See also: https://github.com/apache/arrow/issues/21526 (ARROW-5030)
|
||
"""
|
||
data_dir, _, num_rows, schema = nested_parquet_exceeding_2gb
|
||
ds = ray.data.read_parquet(data_dir)
|
||
total_rows = 0
|
||
for batch in ds.iter_batches(batch_format="pyarrow", batch_size=100):
|
||
total_rows += batch.num_rows
|
||
assert "id" in batch.column_names
|
||
assert "nested_col" in batch.column_names
|
||
assert total_rows == num_rows
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
parse_version(pa.__version__) < parse_version("16.0.0"),
|
||
reason="PyArrow < 16 cannot construct >2 GB nested arrays from Python lists",
|
||
)
|
||
@pytest.mark.timeout(300)
|
||
def test_read_parquet_nested_fallback_triggered_when_filter_references_nested_column(
|
||
ray_start_regular_shared, nested_parquet_exceeding_2gb
|
||
):
|
||
"""When the projection excludes the large nested column but the filter
|
||
references it, the V2 reader must still trigger the fallback because the
|
||
scanner would otherwise hit ARROW-5030 while decoding the column for
|
||
row-level filter evaluation.
|
||
"""
|
||
import pyarrow.dataset as pds
|
||
|
||
from ray.data import DataContext
|
||
from ray.data._internal.datasource.parquet_datasource import (
|
||
_needs_nested_type_fallback,
|
||
_resolve_read_columns,
|
||
)
|
||
|
||
if not DataContext.get_current().use_datasource_v2:
|
||
pytest.skip("V2-only: fallback decision lives in ParquetFileReader (V2).")
|
||
|
||
_, file_path, _, _ = nested_parquet_exceeding_2gb
|
||
|
||
fragment = next(pds.dataset(file_path, format="parquet").get_fragments())
|
||
|
||
# Sanity: the fragment has a flat column we could project alone without
|
||
# triggering the fallback (covered by the sibling _skipped test).
|
||
assert _needs_nested_type_fallback(fragment, columns=["id"]) is False
|
||
|
||
# When the projection excludes ``nested_col`` but the filter references
|
||
# it, the V2 reader resolves the union of projected + filter-referenced
|
||
# columns before deciding whether to use the fallback. That union must
|
||
# include ``nested_col`` so the fallback is triggered.
|
||
read_columns = _resolve_read_columns(
|
||
columns=["id"],
|
||
filter_expr=pds.field("nested_col").is_valid(),
|
||
filter_columns=["nested_col"],
|
||
)
|
||
assert read_columns is not None and "nested_col" in read_columns
|
||
assert _needs_nested_type_fallback(fragment, read_columns) is True
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
parse_version(pa.__version__) < parse_version("16.0.0"),
|
||
reason="PyArrow < 16 cannot construct >2 GB nested arrays from Python lists",
|
||
)
|
||
@pytest.mark.timeout(300)
|
||
def test_read_parquet_nested_fallback_skipped_when_only_flat_columns_selected(
|
||
ray_start_regular_shared, nested_parquet_exceeding_2gb
|
||
):
|
||
"""When only non-nested columns are requested from a file that also contains
|
||
large nested columns, the fallback reader should NOT be triggered because the
|
||
selected columns do not contain susceptible nested types.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from ray.data._internal.datasource.parquet_datasource import (
|
||
_needs_nested_type_fallback,
|
||
)
|
||
|
||
data_dir, file_path, num_rows, _ = nested_parquet_exceeding_2gb
|
||
|
||
# Verify that the fallback IS needed when all columns are considered.
|
||
import pyarrow.dataset as pds
|
||
|
||
fragment = next(pds.dataset(file_path, format="parquet").get_fragments())
|
||
assert _needs_nested_type_fallback(fragment) is True
|
||
# But NOT needed when only the flat "id" column is requested.
|
||
assert _needs_nested_type_fallback(fragment, columns=["id"]) is False
|
||
|
||
# End-to-end: reading only "id" should use the normal scanner path, not
|
||
# the fallback. Patch to detect whether fallback is invoked.
|
||
with patch(
|
||
"ray.data._internal.datasource.parquet_datasource"
|
||
"._get_safe_batch_size_for_nested_types"
|
||
) as mock_safe:
|
||
ds = ray.data.read_parquet(data_dir).select_columns(["id"])
|
||
total_rows = 0
|
||
for batch in ds.iter_batches(batch_format="pyarrow", batch_size=100):
|
||
total_rows += batch.num_rows
|
||
assert batch.column_names == ["id"]
|
||
assert total_rows == num_rows
|
||
# The fallback batch-size helper should never have been called.
|
||
mock_safe.assert_not_called()
|
||
|
||
|
||
def test_parquet_sampling_fails_on_permanent_error(ray_start_regular_shared, tmp_path):
|
||
"""Test that parquet sampling does not hang on permanent OSError (e.g.,
|
||
permission denied). Instead, it should fail with a clear error after
|
||
limited retries. Regression test for #57278."""
|
||
from unittest.mock import patch
|
||
|
||
# Write a valid parquet file so that fragment discovery succeeds.
|
||
table = pa.table({"col": [1, 2, 3]})
|
||
pq.write_table(table, os.path.join(tmp_path, "data.parquet"))
|
||
|
||
# Patch the remote sampling function to always raise PermissionError
|
||
# (a subclass of OSError), simulating invalid credentials.
|
||
original_fn = (
|
||
"ray.data._internal.datasource.parquet_datasource._fetch_parquet_file_info"
|
||
)
|
||
|
||
def _raise_permission_error(*args, **kwargs):
|
||
raise PermissionError("Access Denied: invalid credentials")
|
||
|
||
with patch(original_fn, new=_raise_permission_error):
|
||
with pytest.raises(Exception, match="Access Denied"):
|
||
ray.data.read_parquet(str(tmp_path)).materialize()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
sys.exit(pytest.main(["-v", __file__]))
|