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) 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__]))