import base64 import os import sys import types from decimal import Decimal from tempfile import TemporaryDirectory import numpy as np import pandas as pd import pyarrow as pa import pytest from pyarrow import ArrowInvalid import ray from ray._common.test_utils import run_string_as_driver from ray.data._internal.arrow_block import ( ArrowBlockAccessor, ArrowBlockBuilder, ) from ray.data._internal.arrow_ops.transform_pyarrow import combine_chunked_array from ray.data._internal.util import GiB, MiB from ray.data.block import BlockAccessor from ray.data.context import DataContext def test_combine_chunked_fixed_width_array_large(): """Verifies `combine_chunked_array` on fixed-width arrays > 2 GiB, produces single contiguous PA Array""" # 144 MiB ones_1gb = np.ones(shape=(550, 128, 128, 4), dtype=np.int32()).ravel() # Total ~2.15 GiB input_ = pa.chunked_array( [ pa.array(ones_1gb), ] * 16 ) assert round(input_.nbytes / GiB, 2) == 2.15 result = combine_chunked_array(input_) assert isinstance(result, pa.Int32Array) @pytest.mark.parametrize( "array_type,input_factory", [ ( pa.binary(), lambda num_bytes: np.arange(num_bytes, dtype=np.uint8).tobytes(), ), ( pa.string(), lambda num_bytes: base64.encodebytes( np.arange(num_bytes, dtype=np.int8).tobytes() ).decode("ascii"), ), (pa.list_(pa.uint8()), lambda num_bytes: np.arange(num_bytes, dtype=np.uint8)), ], ) def test_combine_chunked_variable_width_array_large(array_type, input_factory): """Verifies `combine_chunked_array` on variable-width arrays > 2 GiB, safely produces new ChunkedArray with provided chunks recombined into larger ones up to INT32_MAX in size""" one_half_gb_arr = pa.array([input_factory(GiB / 2)], type=array_type) chunked_arr = pa.chunked_array( [one_half_gb_arr, one_half_gb_arr, one_half_gb_arr, one_half_gb_arr] ) # 2 GiB + offsets (4 x int32) num_bytes = chunked_arr.nbytes expected_num_bytes = 4 * one_half_gb_arr.nbytes num_chunks = len(chunked_arr.chunks) assert num_chunks == 4 assert num_bytes == expected_num_bytes # Assert attempt to combine directly fails with pytest.raises(ArrowInvalid): chunked_arr.combine_chunks() # Safe combination succeeds by avoiding overflowing combination combined = combine_chunked_array(chunked_arr) num_bytes = combined.nbytes num_chunks = len(combined.chunks) assert num_chunks == 2 assert num_bytes == expected_num_bytes def test_add_rows_with_different_column_names(ray_start_regular_shared): builder = ArrowBlockBuilder() builder.add({"col1": "spam"}) builder.add({"col2": "foo"}) block = builder.build() expected_table = pa.Table.from_pydict( {"col1": ["spam", None], "col2": [None, "foo"]} ) assert block.equals(expected_table) @pytest.fixture(scope="module") def binary_dataset_single_file_gt_2gb(): total_size = int(2.1 * GiB) chunk_size = 256 * MiB num_chunks = total_size // chunk_size remainder = total_size % chunk_size with TemporaryDirectory() as tmp_dir: dataset_path = f"{tmp_dir}/binary_dataset_gt_2gb_single_file" # Create directory os.mkdir(dataset_path) with open(f"{dataset_path}/chunk.bin", "wb") as f: for i in range(num_chunks): f.write(b"a" * chunk_size) print(f">>> Written chunk #{i}") if remainder: f.write(b"a" * remainder) print(f">>> Wrote chunked dataset at: {dataset_path}") yield dataset_path, total_size print(f">>> Cleaning up dataset: {dataset_path}") @pytest.mark.parametrize( "col_name", [ "bytes", # TODO fix numpy conversion # "text", ], ) def test_single_row_gt_2gb( ray_start_regular_shared, restore_data_context, binary_dataset_single_file_gt_2gb, col_name, ): # Disable (automatic) fallback to `ArrowPythonObjectType` extension type DataContext.get_current().enable_fallback_to_arrow_object_ext_type = False dataset_path, target_binary_size = binary_dataset_single_file_gt_2gb def _id(row): bs = row[col_name] assert round(len(bs) / GiB, 1) == round(target_binary_size / GiB, 1) return row if col_name == "text": ds = ray.data.read_text(dataset_path) elif col_name == "bytes": ds = ray.data.read_binary_files(dataset_path) total = ds.map(_id).count() assert total == 1 def test_random_shuffle(ray_start_regular_shared): TOTAL_ROWS = 10000 table = pa.table({"id": pa.array(range(TOTAL_ROWS))}) block_accessor = ArrowBlockAccessor(table) # Perform the random shuffle shuffled_table = block_accessor.random_shuffle(random_seed=None) assert shuffled_table.num_rows == TOTAL_ROWS # Access the shuffled data block_accessor = ArrowBlockAccessor(shuffled_table) shuffled_data = block_accessor.to_pandas()["id"].tolist() original_data = list(range(TOTAL_ROWS)) # Ensure the shuffled data is not identical to the original assert ( shuffled_data != original_data ), "Shuffling should result in a different order" # Ensure the entire set of original values is still in the shuffled dataset assert ( sorted(shuffled_data) == original_data ), "The shuffled data should contain all the original values" def test_register_arrow_types(ray_start_regular_shared, tmp_path): # Test that our custom arrow extension types are registered on initialization. ds = ray.data.from_items(np.zeros((8, 8, 8), dtype=np.int64)) tmp_file = f"{tmp_path}/test.parquet" ds.write_parquet(tmp_file) ds = ray.data.read_parquet(tmp_file) schema = "Column Type\n------ ----\nitem ArrowTensorTypeV2(shape=(8, 8), dtype=int64)" assert str(ds.schema()) == schema # Also run in driver script to eliminate existing imports. driver_script = """import ray ds = ray.data.read_parquet("{0}") schema = ds.schema() assert str(schema) == \"\"\"{1}\"\"\" """.format( tmp_file, schema ) run_string_as_driver(driver_script) def test_dict_doesnt_fallback_to_pandas_block(ray_start_regular_shared): # If the UDF returns a column with dict, previously, we would # fall back to pandas, because we couldn't convert it to # an Arrow block. This test checks that the block # construction now correctly goes to Arrow. def fn(batch): batch["data_dict"] = [{"data": 0} for _ in range(len(batch["id"]))] batch["data_objects"] = [ types.SimpleNamespace(a=1, b="test") for _ in range(len(batch["id"])) ] return batch ds = ray.data.range(10).map_batches(fn) ds = ds.materialize() block = ray.get(ds.get_internal_block_refs()[0]) assert isinstance(block, pa.Table), type(block) df_from_block = block.to_pandas() assert df_from_block["data_dict"].iloc[0] == {"data": 0} assert df_from_block["data_objects"].iloc[0] == types.SimpleNamespace(a=1, b="test") def fn2(batch): batch["data_none"] = [None for _ in range(len(batch["id"]))] return batch ds2 = ray.data.range(10).map_batches(fn2) ds2 = ds2.materialize() block = ray.get(ds2.get_internal_block_refs()[0]) assert isinstance(block, pa.Table), type(block) df_from_block = block.to_pandas() assert df_from_block["data_none"].iloc[0] is None # Test for https://github.com/ray-project/ray/issues/49338. def test_build_block_with_null_column(ray_start_regular_shared, restore_data_context): ctx = DataContext.get_current() ctx.execution_options.preserve_order = True # The blocks need to contain a tensor column to trigger the bug. block1 = BlockAccessor.batch_to_block( {"string": [None], "array": np.zeros((1, 2, 2))} ) block2 = BlockAccessor.batch_to_block( {"string": ["spam"], "array": np.zeros((1, 2, 2))} ) builder = ArrowBlockBuilder() builder.add_block(block1) builder.add_block(block2) block = builder.build() rows = list(BlockAccessor.for_block(block).iter_rows(True)) assert len(rows) == 2 assert rows[0]["string"] is None assert rows[1]["string"] == "spam" assert np.array_equal(rows[0]["array"], np.zeros((2, 2))) assert np.array_equal(rows[1]["array"], np.zeros((2, 2))) def test_arrow_block_timestamp_ns(ray_start_regular_shared): # Input data with nanosecond precision timestamps data_rows = [ {"col1": 1, "col2": pd.Timestamp("2023-01-01T00:00:00.123456789")}, {"col1": 2, "col2": pd.Timestamp("2023-01-01T01:15:30.987654321")}, {"col1": 3, "col2": pd.Timestamp("2023-01-01T02:30:15.111111111")}, {"col1": 4, "col2": pd.Timestamp("2023-01-01T03:45:45.222222222")}, {"col1": 5, "col2": pd.Timestamp("2023-01-01T05:00:00.333333333")}, ] # Initialize ArrowBlockBuilder arrow_builder = ArrowBlockBuilder() for row in data_rows: arrow_builder.add(row) arrow_block = arrow_builder.build() assert arrow_block.schema.field("col2").type == pa.timestamp("ns") for i, row in enumerate(data_rows): result_timestamp = arrow_block["col2"][i].as_py() # Convert both values to pandas Timestamp to preserve nanosecond precision for # comparison. assert pd.Timestamp(row["col2"]) == pd.Timestamp( result_timestamp ), f"Timestamp mismatch at row {i} in ArrowBlockBuilder output" @pytest.mark.parametrize( "input_array,transform,expected_type,expected_values", [ ( pa.array([None, None], type=pa.string()), None, pa.string(), [None, None], ), ( pa.array([None, None], type=pa.list_(pa.string())), None, pa.list_(pa.string()), [None, None], ), ( pa.array([None, None], type=pa.decimal128(10, 2)), lambda df: df.fillna({"x": 0}), pa.decimal128(10, 2), [Decimal("0.00"), Decimal("0.00")], ), ( pa.array([["a", "b"], None], type=pa.list_(pa.string())), None, pa.list_(pa.string()), [["a", "b"], None], ), ], ) def test_arrow_block_to_pandas_preserves_arrow_types_through_roundtrip( input_array, transform, expected_type, expected_values ): table = pa.table({"x": input_array}) df = ArrowBlockAccessor(table).to_pandas() assert isinstance(df.dtypes["x"], pd.ArrowDtype) assert df.dtypes["x"].pyarrow_dtype == expected_type if transform is not None: df = transform(df) roundtripped = BlockAccessor.for_block(df).to_arrow() assert roundtripped.schema.field("x").type == expected_type assert roundtripped.to_pydict() == {"x": expected_values} if __name__ == "__main__": sys.exit(pytest.main(["-v", __file__]))