Files
2026-07-13 13:17:40 +08:00

1603 lines
50 KiB
Python

import asyncio
import logging
import math
import os
import random
import threading
import time
from typing import Iterable, Iterator, List, Literal, Optional
from unittest.mock import Mock
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.compute as pc
import pytest
import ray
from ray._common.test_utils import (
run_string_as_driver,
wait_for_condition,
)
from ray.data._internal.arrow_ops.transform_pyarrow import (
MIN_PYARROW_VERSION_TYPE_PROMOTION,
)
from ray.data._internal.planner.plan_udf_map_op import (
_generate_transform_fn_for_async_map,
_MapActorContext,
)
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.block import Block, BlockMetadata
from ray.data.context import DataContext
from ray.data.datasource import Datasource, ReadTask
from ray.data.exceptions import UserCodeException
from ray.data.tests.conftest import * # noqa
from ray.data.tests.test_util import ConcurrencyCounter # noqa
from ray.data.tests.util import extract_values
from ray.exceptions import RayTaskError
from ray.runtime_env import RuntimeEnv
from ray.tests.conftest import * # noqa
def test_specifying_num_cpus_and_num_gpus_logs_warning(
shutdown_only, propagate_logs, caplog, target_max_block_size_infinite_or_default
):
ray.init(num_cpus=1, num_gpus=1)
with caplog.at_level(logging.WARNING):
ray.data.range(1).map(lambda x: x, num_cpus=1, num_gpus=1).take(1)
assert (
"Specifying both num_cpus and num_gpus for map tasks is experimental"
in caplog.text
), caplog.text
def test_invalid_max_tasks_in_flight_raises_error():
with pytest.raises(ValueError):
ray.data.ActorPoolStrategy(max_tasks_in_flight_per_actor=0)
@pytest.mark.parametrize("concurrency", [(2, 1), -1])
def test_invalid_concurrency_raises_error(shutdown_only, concurrency):
ray.init()
class UDF:
def __call__(self, row):
return row
with pytest.raises(ValueError):
ray.data.range(1).map(UDF, concurrency=concurrency)
def test_callable_classes(shutdown_only, target_max_block_size_infinite_or_default):
ray.init(num_cpus=2)
ds = ray.data.range(10, override_num_blocks=10)
class StatefulFn:
def __init__(self):
self.num_reuses = 0
def __call__(self, x):
r = self.num_reuses
self.num_reuses += 1
return {"id": np.array([r])}
# map
actor_reuse = ds.map(StatefulFn, concurrency=1).take()
assert sorted(extract_values("id", actor_reuse)) == [
[v] for v in list(range(10))
], actor_reuse
class StatefulFn:
def __init__(self):
self.num_reuses = 0
def __call__(self, x):
r = self.num_reuses
self.num_reuses += 1
return [{"id": r}]
# flat map
actor_reuse = extract_values("id", ds.flat_map(StatefulFn, concurrency=1).take())
assert sorted(actor_reuse) == list(range(10)), actor_reuse
class StatefulFn:
def __init__(self):
self.num_reuses = 0
def __call__(self, x):
r = self.num_reuses
self.num_reuses += 1
return {"id": np.array([r])}
# map batches
actor_reuse = extract_values(
"id",
ds.map_batches(StatefulFn, batch_size=1, concurrency=1).take(),
)
assert sorted(actor_reuse) == list(range(10)), actor_reuse
class StatefulFn:
def __init__(self):
self.num_reuses = 0
def __call__(self, x):
r = self.num_reuses
self.num_reuses += 1
return r > 0
# filter
actor_reuse = ds.filter(StatefulFn, concurrency=1).take()
assert len(actor_reuse) == 9, actor_reuse
class StatefulFnWithArgs:
def __init__(self, arg, kwarg):
assert arg == 1
assert kwarg == 2
def __call__(self, x, arg, kwarg):
assert arg == 1
assert kwarg == 2
return x
# map_batches & map with args & kwargs
for ds_map in (ds.map_batches, ds.map):
result = ds_map(
StatefulFnWithArgs,
concurrency=1,
fn_args=(1,),
fn_kwargs={"kwarg": 2},
fn_constructor_args=(1,),
fn_constructor_kwargs={"kwarg": 2},
).take()
assert sorted(extract_values("id", result)) == list(range(10)), result
class StatefulFlatMapFnWithArgs:
def __init__(self, arg, kwarg):
self._arg = arg
assert arg == 1
assert kwarg == 2
def __call__(self, x, arg, kwarg):
assert arg == 1
assert kwarg == 2
return [x] * self._arg
# flat_map with args & kwargs
result = ds.flat_map(
StatefulFlatMapFnWithArgs,
concurrency=1,
fn_args=(1,),
fn_kwargs={"kwarg": 2},
fn_constructor_args=(1,),
fn_constructor_kwargs={"kwarg": 2},
).take()
assert sorted(extract_values("id", result)) == list(range(10)), result
class StatefulFilterFnWithArgs:
def __init__(self, arg, kwarg):
assert arg == 1
assert kwarg == 2
def __call__(self, x, arg, kwarg):
assert arg == 1
assert kwarg == 2
return True
# fiter with args & kwargs
result = ds.filter(
StatefulFilterFnWithArgs,
concurrency=1,
fn_args=(1,),
fn_kwargs={"kwarg": 2},
fn_constructor_args=(1,),
fn_constructor_kwargs={"kwarg": 2},
).take()
assert sorted(extract_values("id", result)) == list(range(10)), result
def test_transform_failure(shutdown_only, target_max_block_size_infinite_or_default):
ray.init(num_cpus=2)
ds = ray.data.from_items([0, 10], override_num_blocks=2)
def mapper(x):
time.sleep(x)
raise ValueError("oops")
return x
with pytest.raises(ray.exceptions.RayTaskError):
ds.map(mapper).materialize()
def test_actor_task_failure(
shutdown_only, restore_data_context, target_max_block_size_infinite_or_default
):
ray.init(num_cpus=2)
ctx = DataContext.get_current()
ctx.actor_task_retry_on_errors = [ValueError]
ds = ray.data.from_items([0, 10], override_num_blocks=2)
class Mapper:
def __init__(self):
self._counter = 0
def __call__(self, x):
if self._counter < 2:
self._counter += 1
raise ValueError("oops")
return x
ds.map_batches(Mapper, concurrency=1).materialize()
def test_task_retry_on_errors_succeeds(restore_data_context):
ctx = DataContext.get_current()
ctx.retried_map_errors = ["transient error"]
ctx.max_map_retries = 3
class FlakyUDF:
def __init__(self):
self._counter = 0
def __call__(self, batch):
self._counter += 1
if self._counter <= 2:
raise ValueError("transient error")
return batch
result = ray.data.range(2, override_num_blocks=1).map_batches(FlakyUDF).take_all()
assert sorted(extract_values("id", result)) == list(range(2)), result
def test_task_retry_on_errors_exhausted(restore_data_context):
ctx = DataContext.get_current()
ctx.retried_map_errors = ["persistent bug"]
ctx.max_map_retries = 2
def always_fails(batch):
raise ValueError("persistent bug")
with pytest.raises(ray.exceptions.RayTaskError):
ray.data.range(2, override_num_blocks=1).map_batches(always_fails).take_all()
def test_task_retry_non_matching_exception_not_retried(restore_data_context):
ctx = DataContext.get_current()
ctx.retried_map_errors = ["rate limit"]
def udf(batch):
raise ValueError("not a retryable error")
with pytest.raises(ray.exceptions.RayTaskError):
ray.data.range(2, override_num_blocks=1).map_batches(udf).take_all()
def test_task_retry_true_retries_any_exception(shutdown_only, restore_data_context):
ctx = DataContext.get_current()
ctx.retried_map_errors = True
ctx.max_map_retries = 3
class FlakyUDF:
def __init__(self):
self._counter = 0
def __call__(self, batch):
self._counter += 1
if self._counter <= 2:
raise RuntimeError("any kind of transient error")
if self._counter <= 3:
raise ValueError("also a retryable error")
return batch
result = ray.data.range(2, override_num_blocks=1).map_batches(FlakyUDF).take_all()
assert sorted(extract_values("id", result)) == list(range(2)), result
def test_gpu_workers_not_reused(
shutdown_only, target_max_block_size_infinite_or_default
):
"""By default, in Ray Core if `num_gpus` is specified workers will not be reused
for tasks invocation.
For more context check out https://github.com/ray-project/ray/issues/29624"""
ray.init(num_gpus=1)
total_blocks = 5
ds = ray.data.range(5, override_num_blocks=total_blocks)
def _get_worker_id(_):
return {"worker_id": ray.get_runtime_context().get_worker_id()}
unique_worker_ids = ds.map(_get_worker_id, num_gpus=1).unique("worker_id")
assert len(unique_worker_ids) == total_blocks
@pytest.mark.parametrize(
"concurrency",
[
"spam",
# Two and three-tuples are valid for callable classes but not for functions.
(1, 2),
(1, 2, 3),
(1, 2, 3, 4),
],
)
def test_invalid_func_concurrency_raises(ray_start_regular_shared, concurrency):
ds = ray.data.range(1)
with pytest.raises(ValueError):
ds.map(lambda x: x, concurrency=concurrency)
@pytest.mark.parametrize("concurrency", ["spam", (1, 2, 3, 4)])
def test_invalid_class_concurrency_raises(ray_start_regular_shared, concurrency):
class Fn:
def __call__(self, row):
return row
ds = ray.data.range(1)
with pytest.raises(ValueError):
ds.map(Fn, concurrency=concurrency)
@pytest.mark.parametrize("udf_kind", ["gen", "func"])
def test_flat_map(
ray_start_regular_shared, udf_kind, target_max_block_size_infinite_or_default
):
ds = ray.data.range(3)
if udf_kind == "gen":
def _udf(item: dict) -> Iterator[int]:
for _ in range(2):
yield {"id": item["id"] + 1}
elif udf_kind == "func":
def _udf(item: dict) -> dict:
return [{"id": item["id"] + 1} for _ in range(2)]
else:
pytest.fail(f"Invalid udf_kind: {udf_kind}")
assert sorted(extract_values("id", ds.flat_map(_udf).take())) == [
1,
1,
2,
2,
3,
3,
]
# Helper function to process timestamp data in nanoseconds
def process_timestamp_data(row):
# Convert numpy.datetime64 to pd.Timestamp if needed
if isinstance(row["timestamp"], np.datetime64):
row["timestamp"] = pd.Timestamp(row["timestamp"])
# Add 1ns to timestamp
row["timestamp"] = row["timestamp"] + pd.Timedelta(1, "ns")
# Ensure the timestamp column is in the expected dtype (datetime64[ns])
row["timestamp"] = pd.to_datetime(row["timestamp"], errors="raise")
return row
def process_timestamp_data_batch_arrow(batch: pa.Table) -> pa.Table:
# Convert pyarrow Table to pandas DataFrame to process the timestamp column
df = batch.to_pandas()
df["timestamp"] = df["timestamp"].apply(
lambda x: pd.Timestamp(x) if isinstance(x, np.datetime64) else x
)
# Add 1ns to timestamp
df["timestamp"] = df["timestamp"] + pd.Timedelta(1, "ns")
# Convert back to pyarrow Table
return pa.table(df)
def process_timestamp_data_batch_pandas(batch: pd.DataFrame) -> pd.DataFrame:
# Add 1ns to timestamp column
batch["timestamp"] = batch["timestamp"] + pd.Timedelta(1, "ns")
return batch
@pytest.mark.parametrize(
"df, expected_df",
[
pytest.param(
pd.DataFrame(
{
"id": [1, 2, 3],
"timestamp": pd.to_datetime(
[
"2024-01-01 00:00:00.123456789",
"2024-01-02 00:00:00.987654321",
"2024-01-03 00:00:00.111222333",
]
),
"value": [10.123456789, 20.987654321, 30.111222333],
}
),
pd.DataFrame(
{
"id": [1, 2, 3],
"timestamp": pd.to_datetime(
[
"2024-01-01 00:00:00.123456790",
"2024-01-02 00:00:00.987654322",
"2024-01-03 00:00:00.111222334",
]
),
"value": [10.123456789, 20.987654321, 30.111222333],
}
),
id="nanoseconds_increment_map",
)
],
)
def test_map_timestamp_nanosecs(
df, expected_df, ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Verify handling timestamp with nanosecs in map"""
ray_data = ray.data.from_pandas(df)
result = ray_data.map(process_timestamp_data)
processed_df = result.to_pandas()
processed_df["timestamp"] = processed_df["timestamp"].astype("datetime64[ns]")
expected_df = expected_df.astype(processed_df.dtypes.to_dict())
pd.testing.assert_frame_equal(processed_df, expected_df)
def test_add_column(ray_start_regular_shared):
"""Tests the add column API."""
# Test with pyarrow batch format
ds = ray.data.range(5).add_column(
"foo", lambda x: pa.array([1] * x.num_rows), batch_format="pyarrow"
)
assert ds.take(1) == [{"id": 0, "foo": 1}]
# Test with chunked array batch format
ds = ray.data.range(5).add_column(
"foo", lambda x: pa.chunked_array([[1] * x.num_rows]), batch_format="pyarrow"
)
assert ds.take(1) == [{"id": 0, "foo": 1}]
ds = ray.data.range(5).add_column(
"foo", lambda x: pc.add(x["id"], 1), batch_format="pyarrow"
)
assert ds.take(1) == [{"id": 0, "foo": 1}]
# Adding a column that is already there should not result in an error
ds = ray.data.range(5).add_column(
"id", lambda x: pc.add(x["id"], 1), batch_format="pyarrow"
)
assert ds.take(2) == [{"id": 1}, {"id": 2}]
# Adding a column in the wrong format should result in an error
with pytest.raises(
ray.exceptions.UserCodeException, match="For pyarrow batch format"
):
ds = ray.data.range(5).add_column("id", lambda x: [1], batch_format="pyarrow")
assert ds.take(2) == [{"id": 1}, {"id": 2}]
# Test with numpy batch format
ds = ray.data.range(5).add_column(
"foo", lambda x: np.array([1] * len(x[list(x.keys())[0]])), batch_format="numpy"
)
assert ds.take(1) == [{"id": 0, "foo": 1}]
ds = ray.data.range(5).add_column(
"foo", lambda x: np.add(x["id"], 1), batch_format="numpy"
)
assert ds.take(1) == [{"id": 0, "foo": 1}]
# Adding a column that is already there should not result in an error
ds = ray.data.range(5).add_column(
"id", lambda x: np.add(x["id"], 1), batch_format="numpy"
)
assert ds.take(2) == [{"id": 1}, {"id": 2}]
# Adding a column in the wrong format should result in an error
with pytest.raises(
ray.exceptions.UserCodeException, match="For numpy batch format"
):
ds = ray.data.range(5).add_column("id", lambda x: [1], batch_format="numpy")
assert ds.take(2) == [{"id": 1}, {"id": 2}]
# Test with pandas batch format
ds = ray.data.range(5).add_column("foo", lambda x: pd.Series([1] * x.shape[0]))
assert ds.take(1) == [{"id": 0, "foo": 1}]
ds = ray.data.range(5).add_column("foo", lambda x: x["id"] + 1)
assert ds.take(1) == [{"id": 0, "foo": 1}]
# Adding a column that is already there should not result in an error
ds = ray.data.range(5).add_column("id", lambda x: x["id"] + 1)
assert ds.take(2) == [{"id": 1}, {"id": 2}]
# Adding a column in the wrong format may result in an error
with pytest.raises(ray.exceptions.UserCodeException):
ds = ray.data.range(5).add_column(
"id", lambda x: range(7), batch_format="pandas"
)
assert ds.take(2) == [{"id": 1}, {"id": 2}]
ds = ray.data.range(5).add_column("const", lambda _: 3, batch_format="pandas")
assert ds.take(2) == [{"id": 0, "const": 3}, {"id": 1, "const": 3}]
with pytest.raises(ValueError):
ds = ray.data.range(5).add_column("id", 0)
# Test that an invalid batch_format raises an error
with pytest.raises(ValueError):
ray.data.range(5).add_column("foo", lambda x: x["id"] + 1, batch_format="foo")
def test_add_column_to_pandas(ray_start_regular_shared):
# Refer to issue https://github.com/ray-project/ray/issues/51758
ds = ray.data.from_pandas(
pd.DataFrame({"a": list(range(20))}), override_num_blocks=2
)
ds = ds.add_column(
"foo1", lambda df: pd.Series([1] * len(df)), batch_format="pandas"
)
ds = ds.add_column(
"foo2", lambda df: pd.DatetimeIndex([1] * len(df)), batch_format="pandas"
)
ds = ds.add_column(
"foo3", lambda df: pd.DataFrame({"foo": [1] * len(df)}), batch_format="pandas"
)
for row in ds.iter_rows():
assert row["foo1"] == 1 and row["foo2"] == pd.Timestamp(1) and row["foo3"] == 1
@pytest.mark.parametrize(
"names, expected_schema",
[
({"spam": "foo", "ham": "bar"}, ["foo", "bar"]),
({"spam": "foo"}, ["foo", "ham"]),
(["foo", "bar"], ["foo", "bar"]),
],
)
def test_rename_columns(
ray_start_regular_shared,
names,
expected_schema,
target_max_block_size_infinite_or_default,
):
ds = ray.data.from_items([{"spam": 0, "ham": 0}])
renamed_ds = ds.rename_columns(names)
renamed_schema_names = renamed_ds.schema().names
assert sorted(renamed_schema_names) == sorted(expected_schema)
@pytest.mark.parametrize(
"names, expected_exception, expected_message",
[
# Case 1: Empty dictionary, should raise ValueError
({}, ValueError, "rename_columns received 'names' with no entries."),
# Case 2: Invalid dictionary (duplicate values), should raise ValueError
(
{"spam": "foo", "ham": "foo"},
ValueError,
"rename_columns received duplicate values in the 'names': "
"{'spam': 'foo', 'ham': 'foo'}",
),
# Case 3: Dictionary with non-string keys/values, should raise ValueError
(
{"spam": 1, "ham": "bar"},
ValueError,
"rename_columns requires both keys and values in the 'names' to be "
"strings.",
),
# Case 4: Empty list, should raise ValueError
(
[],
ValueError,
"rename_columns requires 'names' with at least one column name.",
),
# Case 5: List with duplicate values, should raise ValueError
(
["foo", "bar", "foo"],
ValueError,
"rename_columns received duplicate values in the 'names': "
"['foo', 'bar', 'foo']",
),
# Case 6: List with non-string values, should raise ValueError
(
["foo", "bar", 1],
ValueError,
"rename_columns requires all elements in the 'names' to be strings.",
),
# Case 7: Mismatched length of list and current column names, should raise
# ValueError
(
["foo", "bar", "baz"],
ValueError,
"rename_columns requires 'names': ['foo', 'bar', 'baz'] length match "
"current schema names: ['spam', 'ham'].",
),
# Case 8: Invalid type for `names` (integer instead of dict or list), should
# raise TypeError
(
42,
TypeError,
"rename_columns expected names to be either List[str] or Dict[str, str], "
"got <class 'int'>.",
),
],
)
def test_rename_columns_error_cases(
ray_start_regular_shared,
names,
expected_exception,
expected_message,
target_max_block_size_infinite_or_default,
):
# Simulate a dataset with two columns: "spam" and "ham"
ds = ray.data.from_items([{"spam": 0, "ham": 0}])
# Test that the correct exception is raised
with pytest.raises(expected_exception) as exc_info:
ds.rename_columns(names)
# Verify that the exception message matches the expected message
assert str(exc_info.value) == expected_message
def test_drop_columns(
ray_start_regular_shared, tmp_path, target_max_block_size_infinite_or_default
):
df = pd.DataFrame({"col1": [1, 2, 3], "col2": [2, 3, 4], "col3": [3, 4, 5]})
ds1 = ray.data.from_pandas(df)
ds1.write_parquet(str(tmp_path))
ds2 = ray.data.read_parquet(str(tmp_path))
for ds in [ds1, ds2]:
assert ds.drop_columns(["col2"]).take(1) == [{"col1": 1, "col3": 3}]
assert ds.drop_columns(["col1", "col3"]).take(1) == [{"col2": 2}]
assert ds.drop_columns([]).take(1) == [{"col1": 1, "col2": 2, "col3": 3}]
assert ds.drop_columns(["col1", "col2", "col3"]).take(1) == []
assert ds.drop_columns(["col1", "col2"]).take(1) == [{"col3": 3}]
with pytest.raises(ValueError, match="drop_columns expects unique column names"):
ds1.drop_columns(["col1", "col2", "col2"])
@pytest.mark.parametrize(
"source,eager",
[
# Parquet source: input ``pa.Schema`` is known, so ``drop_columns``
# raises immediately at the call site.
("parquet", True),
# ``from_pandas`` source: input schema is a ``PandasBlockSchema``,
# not a ``pa.Schema``, so the typed-chain reshape is skipped and
# the error surfaces inside ``materialize`` as a
# ``UserCodeException`` wrapping the PyArrow ``KeyError``.
("pandas", False),
# UDF chain: input schema is opaque downstream of ``map_batches``,
# same fallback as ``pandas``.
("udf", False),
],
)
def test_drop_columns_missing_column_raises(
ray_start_regular_shared,
tmp_path,
target_max_block_size_infinite_or_default,
source,
eager,
):
df = pd.DataFrame({"col1": [1, 2, 3], "col2": [2, 3, 4], "col3": [3, 4, 5]})
if source == "parquet":
ray.data.from_pandas(df).write_parquet(str(tmp_path))
ds = ray.data.read_parquet(str(tmp_path))
elif source == "pandas":
ds = ray.data.from_pandas(df)
else:
ds = ray.data.from_pandas(df).map_batches(lambda b: b)
if eager:
with pytest.raises(KeyError, match="not found in dataset schema"):
ds.drop_columns(["dummy_col", "col1", "col2"])
else:
with pytest.raises((UserCodeException, KeyError)):
ds.drop_columns(["dummy_col", "col1", "col2"]).materialize()
def test_select_rename_columns(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
ds = ray.data.range(1)
def map_fn(row):
return {"a": "a", "b": "b", "c": "c"}
ds = ds.map(map_fn)
result = ds.rename_columns({"a": "A"}).select_columns("A").take_all()
assert result == [{"A": "a"}]
result = ds.rename_columns({"a": "A"}).select_columns("b").take_all()
assert result == [{"b": "b"}]
result = ds.rename_columns({"a": "x", "b": "y"}).select_columns("c").take_all()
assert result == [{"c": "c"}]
result = ds.rename_columns({"a": "x", "b": "y"}).select_columns("x").take_all()
assert result == [{"x": "a"}]
result = ds.rename_columns({"a": "x", "b": "y"}).select_columns("y").take_all()
assert result == [{"y": "b"}]
result = ds.rename_columns({"a": "b", "b": "a"}).select_columns("b").take_all()
assert result == [{"b": "a"}]
result = ds.rename_columns({"a": "b", "b": "a"}).select_columns("a").take_all()
assert result == [{"a": "b"}]
def test_select_columns(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
# Test pandas and arrow
df = pd.DataFrame({"col1": [1, 2, 3], "col2": [2, 3, 4], "col3": [3, 4, 5]})
ds1 = ray.data.from_pandas(df)
ds2 = ds1.map_batches(lambda pa: pa, batch_size=1, batch_format="pyarrow")
for each_ds in [ds1, ds2]:
# Test selecting with empty columns
assert each_ds.select_columns(cols=["col1", "col2", "col3"]).take(1) == [
{"col1": 1, "col2": 2, "col3": 3}
]
assert each_ds.select_columns(cols=["col1", "col2"]).take(1) == [
{"col1": 1, "col2": 2}
]
assert each_ds.select_columns(cols=["col2", "col1"]).take(1) == [
{"col1": 1, "col2": 2}
]
# Test selecting columns with duplicates
with pytest.raises(ValueError, match="expected unique column names"):
each_ds.select_columns(cols=["col1", "col2", "col2"]).schema()
# Test selecting a column that is not in the dataset schema
with pytest.raises((UserCodeException, KeyError)):
each_ds.select_columns(cols=["col1", "col2", "dummy_col"]).materialize()
@pytest.mark.parametrize(
"cols, expected_exception, expected_error",
[
(
None,
TypeError,
"select_columns requires 'cols' to be a string or a list of strings.",
),
(
1,
TypeError,
"select_columns requires 'cols' to be a string or a list of strings.",
),
(
[1],
ValueError,
"select_columns requires all elements of 'cols' to be strings.",
),
],
)
def test_select_columns_validation(
ray_start_regular_shared,
cols,
expected_exception,
expected_error,
target_max_block_size_infinite_or_default,
):
df = pd.DataFrame({"col1": [1, 2, 3], "col2": [2, 3, 4], "col3": [3, 4, 5]})
ds1 = ray.data.from_pandas(df)
with pytest.raises(expected_exception, match=expected_error):
ds1.select_columns(cols=cols)
def test_map_with_objects_and_tensors(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
# Tests https://github.com/ray-project/ray/issues/45235
class UnsupportedType:
pass
def f(batch):
batch_size = len(batch["id"])
return {
"array": np.zeros((batch_size, 32, 32, 3)),
"unsupported": [UnsupportedType()] * batch_size,
}
ray.data.range(1).map_batches(f).materialize()
def test_random_sample(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
def ensure_sample_size_close(dataset, sample_percent=0.5):
r1 = dataset.random_sample(sample_percent)
assert math.isclose(
r1.count(), int(dataset.count() * sample_percent), rel_tol=2, abs_tol=2
)
ds = ray.data.range(10, override_num_blocks=2)
ensure_sample_size_close(ds)
ds = ray.data.range_tensor(5, override_num_blocks=2, shape=(2, 2))
ensure_sample_size_close(ds)
# imbalanced datasets
ds1 = ray.data.range(1, override_num_blocks=1)
ds2 = ray.data.range(2, override_num_blocks=1)
ds3 = ray.data.range(3, override_num_blocks=1)
# noinspection PyTypeChecker
ds = ds1.union(ds2).union(ds3)
ensure_sample_size_close(ds)
# Small datasets
ds1 = ray.data.range(5, override_num_blocks=5)
ensure_sample_size_close(ds1)
def test_random_sample_checks(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
with pytest.raises(ValueError):
# Cannot sample -1
ray.data.range(1).random_sample(-1)
with pytest.raises(ValueError):
# Cannot sample from empty dataset
ray.data.range(0).random_sample(0.2)
with pytest.raises(ValueError):
# Cannot sample fraction > 1
ray.data.range(1).random_sample(10)
def test_random_sample_fixed_seed_0001(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
"""Tests random_sample() with a fixed seed.
https://github.com/ray-project/ray/pull/51401
This test is to ensure that the random sampling is reproducible.
In the following example, we generate a deterministic seed sequence
for each block. Each block generates 10 ranndom numbers and we pick
10% of them. The indices from Ray Data should be the same as the
ones generated by numpy.
"""
ds = ray.data.range(100, override_num_blocks=10).random_sample(
fraction=0.1, seed=1234
)
result = ds.to_pandas()["id"].to_numpy()
# Expected:
expected = np.array([8, 49, 71, 78, 81, 85])
np.testing.assert_array_equal(result, expected)
@pytest.mark.parametrize("dtype", ["numpy", "pandas", "pyarrow"])
@pytest.mark.parametrize("num_blocks, num_rows_per_block", [(1, 1000), (10, 100)])
@pytest.mark.parametrize("fraction", [0.1, 0.5, 1.0])
@pytest.mark.parametrize("seed", [1234, 4321, 0])
def test_random_sample_fixed_seed_0002(
ray_start_regular_shared,
dtype,
num_blocks,
num_rows_per_block,
fraction,
seed,
target_max_block_size_infinite_or_default,
):
"""Checks if random_sample() gives the same result across different parameters. This is to
test whether the result from random_sample() can be computed explicitly using numpy functions.
The expected result (sampled row indices) is deterministic for a fixed seed and number of blocks.
"""
def generate_data(n_per_block: int, n_blocks: int):
for i in range(n_blocks):
yield {
"item": np.arange(i * n_per_block, (i + 1) * n_per_block),
}
if dtype == "numpy":
ds = ray.data.from_items(
np.arange(num_rows_per_block * num_blocks), override_num_blocks=num_blocks
)
elif dtype == "pandas":
data = [pd.DataFrame(b) for b in generate_data(num_rows_per_block, num_blocks)]
ds = ray.data.from_pandas(data)
elif dtype == "pyarrow":
data = [
pa.Table.from_pydict(b)
for b in generate_data(num_rows_per_block, num_blocks)
]
ds = ray.data.from_arrow(data)
else:
raise ValueError(f"Unknown dtype: {dtype}")
ds = ds.random_sample(fraction=fraction, seed=seed)
# Seed sequence for each block: [task_idx, seed]
expected_raw = np.concatenate(
[
np.random.default_rng([i, seed]).random(num_rows_per_block)
for i in range(num_blocks)
]
)
# Sample the random numbers and get the indices
expected = np.where(expected_raw < fraction)[0]
assert ds.count() == len(expected)
assert set(ds.to_pandas()["item"].to_list()) == set(expected.tolist())
def test_warn_large_udfs(
ray_start_regular_shared, target_max_block_size_infinite_or_default
):
driver = """
import ray
import numpy as np
from ray.data._internal.execution.operators.map_operator import MapOperator
large_object = np.zeros(MapOperator.MAP_UDF_WARN_SIZE_THRESHOLD + 1, dtype=np.int8)
class LargeUDF:
def __init__(self):
self.data = large_object
def __call__(self, batch):
return batch
ds = ray.data.range(1)
ds = ds.map_batches(LargeUDF, concurrency=1)
assert ds.take_all() == [{"id": 0}]
"""
output = run_string_as_driver(driver)
assert "The UDF of operator MapBatches(LargeUDF) is too large" in output
# 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_actor_udf_cleanup(
shutdown_only,
tmp_path,
restore_data_context,
target_max_block_size_infinite_or_default,
):
"""Test that for the actor map operator, the UDF object is deleted properly."""
ray.shutdown()
ray.init(num_cpus=2)
test_file = tmp_path / "test.txt"
# Simulate the case that the UDF depends on some external resources that
# need to be cleaned up.
class StatefulUDF:
def __init__(self):
with open(test_file, "w") as f:
f.write("test")
def __call__(self, row):
return row
def __del__(self):
# Delete the file when the UDF is deleted.
os.remove(test_file)
ds = ray.data.range(10)
ds = ds.map(StatefulUDF, concurrency=1)
assert sorted(extract_values("id", ds.take_all())) == list(range(10))
wait_for_condition(lambda: not os.path.exists(test_file))
def test_actor_pool_strategy_default_num_actors(
shutdown_only, target_max_block_size_infinite_or_default
):
import time
class UDFClass:
def __call__(self, x):
time.sleep(1)
return x
num_cpus = 5
ray.shutdown()
ray.init(num_cpus=num_cpus)
compute_strategy = ray.data.ActorPoolStrategy()
ray.data.range(10, override_num_blocks=10).map_batches(
UDFClass, compute=compute_strategy, batch_size=1
).materialize()
def test_actor_pool_strategy_bundles_to_max_actors(
shutdown_only, target_max_block_size_infinite_or_default
):
"""Tests that blocks are bundled up to the specified max number of actors."""
class UDFClass:
def __call__(self, x):
return x
max_size = 2
ds = (
ray.data.range(10, override_num_blocks=10)
.map_batches(UDFClass, batch_size=None, concurrency=max_size)
.materialize()
)
# Check batch size is still respected.
ds = (
ray.data.range(10, override_num_blocks=10)
.map_batches(UDFClass, batch_size=10, concurrency=max_size)
.materialize()
)
assert "1 blocks" in ds.stats()
def test_nonserializable_map_batches(
shutdown_only, target_max_block_size_infinite_or_default
):
lock = threading.Lock()
x = ray.data.range(10)
# Check that the `inspect_serializability` trace was printed
with pytest.raises(TypeError, match=r".*was found to be non-serializable.*"):
x.map_batches(lambda _: lock).take(1)
@pytest.mark.parametrize("udf_kind", ["coroutine", "async_gen"])
def test_async_map_batches(
shutdown_only, udf_kind, target_max_block_size_infinite_or_default
):
ray.shutdown()
ray.init(num_cpus=10)
class AsyncActor:
def __init__(self):
pass
if udf_kind == "async_gen":
async def __call__(self, batch):
for i in batch["id"]:
await asyncio.sleep((i % 5) / 100)
yield {"input": [i], "output": [2**i]}
elif udf_kind == "coroutine":
async def __call__(self, batch):
await asyncio.sleep(random.randint(0, 5) / 100)
return {
"input": list(batch["id"]),
"output": [2**i for i in batch["id"]],
}
else:
pytest.fail(f"Unknown udf_kind: {udf_kind}")
n = 10
ds = ray.data.range(n, override_num_blocks=2)
ds = ds.map(lambda x: x)
ds = ds.map_batches(AsyncActor, batch_size=1, concurrency=1, max_concurrency=2)
start_t = time.time()
output = ds.take_all()
runtime = time.time() - start_t
assert runtime < sum(range(n)), runtime
expected_output = [{"input": i, "output": 2**i} for i in range(n)]
assert sorted(output, key=lambda row: row["input"]) == expected_output, (
output,
expected_output,
)
@pytest.mark.parametrize("udf_kind", ["coroutine", "async_gen"])
def test_async_flat_map(
shutdown_only, udf_kind, target_max_block_size_infinite_or_default
):
class AsyncActor:
def __init__(self):
pass
if udf_kind == "async_gen":
async def __call__(self, row):
id = row["id"]
yield {"id": id}
await asyncio.sleep(random.randint(0, 5) / 100)
yield {"id": id + 1}
elif udf_kind == "coroutine":
async def __call__(self, row):
id = row["id"]
await asyncio.sleep(random.randint(0, 5) / 100)
return [{"id": id}, {"id": id + 1}]
else:
pytest.fail(f"Unknown udf_kind: {udf_kind}")
n = 10
ds = ray.data.from_items([{"id": i} for i in range(0, n, 2)])
ds = ds.flat_map(AsyncActor, concurrency=1, max_concurrency=2)
output = ds.take_all()
assert sorted(extract_values("id", output)) == list(range(n))
class TestGenerateTransformFnForAsyncMap:
@pytest.fixture
def mock_actor_async_ctx(self):
# Use new signature: only is_async and udf_instances
_map_actor_ctx = _MapActorContext(is_async=True, udf_instances={})
import ray
ray.data._map_actor_context = _map_actor_ctx
yield _map_actor_ctx
# Shutdown async loop thread before cleanup to prevent hanging
if _map_actor_ctx.udf_map_asyncio_loop is not None:
_map_actor_ctx.udf_map_asyncio_loop.call_soon_threadsafe(
_map_actor_ctx.udf_map_asyncio_loop.stop
)
if _map_actor_ctx.udf_map_asyncio_thread is not None:
_map_actor_ctx.udf_map_asyncio_thread.join(timeout=5.0)
ray.data._map_actor_context = None
def test_non_coroutine_function_assertion(
self, target_max_block_size_infinite_or_default
):
"""Test that non-coroutine function raises assertion error."""
def sync_fn(x):
return x
validate_fn = Mock()
with pytest.raises(ValueError, match="Expected a coroutine function"):
_generate_transform_fn_for_async_map(
sync_fn, validate_fn, max_concurrency=1
)
def test_zero_max_concurrent_batches_assertion(
self, target_max_block_size_infinite_or_default
):
"""Test that zero max_concurrent_batches raises assertion error."""
async def async_fn(x):
yield x
validate_fn = Mock()
with pytest.raises(AssertionError):
_generate_transform_fn_for_async_map(
async_fn, validate_fn, max_concurrency=0
)
def test_empty_input(
self, mock_actor_async_ctx, target_max_block_size_infinite_or_default
):
"""Test with empty input iterator."""
async def async_fn(x):
yield x
validate_fn = Mock()
transform_fn = _generate_transform_fn_for_async_map(
async_fn, validate_fn, max_concurrency=2
)
task_context = Mock()
assert list(transform_fn([], task_context)) == []
validate_fn.assert_not_called()
@pytest.mark.parametrize("udf_kind", ["coroutine", "async_gen"])
def test_basic_async_processing(
self, udf_kind, mock_actor_async_ctx, target_max_block_size_infinite_or_default
):
"""Test basic async processing with order preservation."""
if udf_kind == "async_gen":
async def async_fn(x):
# Randomly slow-down UDFs (capped by 5ms)
delay = random.randint(0, 5) / 1000
await asyncio.sleep(delay)
yield x
elif udf_kind == "coroutine":
async def async_fn(x):
# Randomly slow-down UDFs (capped by 5ms)
delay = random.randint(0, 5) / 1000
await asyncio.sleep(delay)
return x
else:
pytest.fail(f"Unrecognized udf_kind ({udf_kind})")
validate_fn = Mock()
transform_fn = _generate_transform_fn_for_async_map(
async_fn, validate_fn, max_concurrency=100
)
N = 10_000
task_context = Mock()
result = list(transform_fn(range(N), task_context))
assert result == list(range(N))
assert validate_fn.call_count == N
@pytest.mark.parametrize("result_len", [0, 5])
def test_basic_async_processing_with_iterator(
self,
result_len: int,
mock_actor_async_ctx,
target_max_block_size_infinite_or_default,
):
"""Test UDF that yields multiple items per input."""
async def multi_yield_fn(x):
for i in range(result_len):
yield f"processed_{x}_{i}"
validate_fn = Mock()
transform_fn = _generate_transform_fn_for_async_map(
multi_yield_fn, validate_fn, max_concurrency=2
)
task_context = Mock()
input_seq = [1, 2]
# NOTE: Outputs are expected to match input sequence ordering
expected = [f"processed_{x}_{i}" for x in input_seq for i in range(result_len)]
assert list(transform_fn(input_seq, task_context)) == expected
def test_concurrency_limiting(
self,
mock_actor_async_ctx,
restore_data_context,
target_max_block_size_infinite_or_default,
):
"""Test that concurrency is properly limited."""
max_concurrency = 10
concurrent_task_counter = 0
async def async_fn(x):
# NOTE: This is safe, since event-loop is single-threaded
nonlocal concurrent_task_counter
concurrent_task_counter += 1
assert concurrent_task_counter <= max_concurrency
yield x
# NOTE: We're doing sleep here to interrupt the task and yield
# event loop to the next one (otherwise tasks will simply be
# completed sequentially)
await asyncio.sleep(0.001)
concurrent_task_counter -= 1
validate_fn = Mock()
transform_fn = _generate_transform_fn_for_async_map(
async_fn, validate_fn, max_concurrency=max_concurrency
)
task_context = Mock()
result = list(transform_fn(range(10_000), task_context))
assert len(result) == 10_000
@pytest.mark.parametrize("failure_kind", ["udf", "validation"])
def test_exception_in_udf(
self,
failure_kind: str,
mock_actor_async_ctx,
target_max_block_size_infinite_or_default,
):
"""Test exception handling in UDF."""
udf_failure_msg = "UDF failure"
validation_failure_msg = "Validation failure"
async def failing_async_fn(x):
if failure_kind == "udf" and x == 2:
raise ValueError(udf_failure_msg)
yield x
def validate_fn(x):
if failure_kind == "validation" and x == 2:
raise ValueError(validation_failure_msg)
transform_fn = _generate_transform_fn_for_async_map(
failing_async_fn, validate_fn, max_concurrency=2
)
task_context = Mock()
if failure_kind == "udf":
expected_exception_msg = udf_failure_msg
elif failure_kind == "validation":
expected_exception_msg = validation_failure_msg
else:
pytest.fail(f"Unexpected failure type ({failure_kind})")
with pytest.raises(ValueError, match=expected_exception_msg):
list(transform_fn([1, 2, 3], task_context))
@pytest.mark.parametrize("fn_type", ["func", "class"])
def test_map_operator_warns_on_few_inputs(
fn_type: Literal["func", "class"],
shutdown_only,
target_max_block_size_infinite_or_default,
):
if fn_type == "func":
def fn(row):
return row
else:
class fn:
def __call__(self, row):
return row
with pytest.warns(UserWarning, match="can launch at most 1 task"):
# The user specified `concurrency=2` for the map operator, but the pipeline
# can only launch one task because there's only one input block. So, Ray Data
# should emit a warning instructing the user to increase the number of input
# blocks.
ray.data.range(2, override_num_blocks=1).map(fn, concurrency=2).materialize()
def test_map_op_backpressure_configured_properly(
target_max_block_size_infinite_or_default,
):
"""This test asserts that configuration of the MapOperator generator's back-pressure is
propagated appropriately to the Ray Core
"""
total = 5
def _map_raising(r):
if isinstance(r["item"], Exception):
raise r["item"]
return r
# Reset this to make sure test is invariant of default value changes
DataContext.get_current()._max_num_blocks_in_streaming_gen_buffer = 2
# To simulate incremental iteration we are
# - Aggressively applying back-pressure (allowing no more than a single block
# to be in the queue)
# - Restrict Map Operator concurrency to run no more than 1 task at a time
#
# At the end of the pipeline we fetch only first 4 elements (instead of 5) to prevent the last 1
# from executing (1 is going to be a buffered block)
df = ray.data.from_items(
list(range(5)) + [ValueError("failed!")], override_num_blocks=6
)
# NOTE: Default back-pressure configuration allows 2 blocks in the
# generator's buffer, hence default execution will fail as we'd
# try map all 6 elements
with pytest.raises(RayTaskError) as exc_info:
df.map(_map_raising).materialize()
assert str(ValueError("failed")) in str(exc_info.value)
# Reducing number of blocks in the generator buffer, will prevent this pipeline
# from throwing
vals = (
df.map(
_map_raising,
concurrency=1,
ray_remote_args_fn=lambda: {
"_generator_backpressure_num_objects": 2, # 1 for block, 1 for metadata
},
)
.limit(total - 1)
.take_batch()["item"]
.tolist()
)
assert list(range(5))[:-1] == vals
@pytest.mark.skipif(
get_pyarrow_version() < MIN_PYARROW_VERSION_TYPE_PROMOTION,
reason="Requires pyarrow>=14 for unify_schemas in OneHotEncoder",
)
def test_map_names(target_max_block_size_infinite_or_default, capsys):
"""To test different UDF format such that the operator
has the correct representation.
The actual name is handled by
AbstractUDFMap._get_operator_name()
"""
ds = ray.data.range(5)
def _assert_explain_contains(dataset, expected):
dataset.explain()
captured = capsys.readouterr()
assert expected in captured.out, captured.out
mapped = ds.map(lambda x: {"id": str(x["id"])})
_assert_explain_contains(mapped, "Map(<lambda>)")
class C:
def __call__(self, x):
return x
mapped = ds.map(C, concurrency=4)
_assert_explain_contains(mapped, "Map(C)")
# Simple and partial functions
def func(x, y):
return x
mapped = ds.map(func, fn_args=[0])
_assert_explain_contains(mapped, "Map(func)")
from functools import partial
mapped = ds.map(partial(func, y=1))
_assert_explain_contains(mapped, "Map(func)")
# Preprocessor
from ray.data.preprocessors import OneHotEncoder
ds = ray.data.from_items(["a", "b", "c", "a", "b", "c"])
enc = OneHotEncoder(columns=["item"])
transformed = enc.fit_transform(ds)
_assert_explain_contains(transformed, "OneHotEncoder")
def test_map_with_max_calls():
ds = ray.data.range(10)
# OK to set 'max_calls' as static option
ds = ds.map(lambda x: x, max_calls=1)
assert ds.count() == 10
ds = ray.data.range(10)
# Not OK to set 'max_calls' as dynamic option
with pytest.raises(ValueError):
ds = ds.map(
lambda x: x,
ray_remote_args_fn=lambda: {"max_calls": 1},
)
ds.take_all()
def test_downstream_operators_scheduled_on_different_workers_than_read_workers(
restore_data_context, shutdown_only
):
"""Test that downstream operators don't get scheduled on same workers as reads when
``isolate_read_workers`` is ``True``.
The test works by setting an environment variable in the read worker and checking
that it isn't set in the map worker.
"""
ray.data.DataContext.get_current().isolate_read_workers = True
if ray.is_initialized():
ray.shutdown()
# This test assumes that the number of Ray worker processes is equal to the number
# of logical CPUs. This is true at the time of writing, but it's an implementation
# detail that could change. I'm using this approach since it seems like the most
# pragmatic way to test this.
ray.init(num_cpus=1)
class SetMarkerDatasource(Datasource):
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
def read_fn() -> Iterable[Block]:
os.environ["MARKER"] = "1"
yield pa.Table.from_pydict({"id": [0]})
return [
ReadTask(
read_fn,
BlockMetadata(
num_rows=1, size_bytes=4, input_files=None, exec_stats=None
),
)
]
def estimate_inmemory_data_size(self) -> Optional[int]:
return None
def check_marker_not_set(row):
assert os.environ.get("MARKER") != "1", (
"Expected MARKER to not be set in the map worker. This means the map "
"worker was scheduled on the same worker as the read worker."
)
return row
ray.data.read_datasource(SetMarkerDatasource()).map(
check_marker_not_set
).materialize()
@pytest.mark.parametrize(
"runtime_env", [{"env_vars": {"MARKER": "1"}}, RuntimeEnv(env_vars={"MARKER": "1"})]
)
def test_isolate_read_workers_preserves_runtime_env(
runtime_env, ray_start_regular_shared, restore_data_context
):
"""The `isolate_read_workers` implementation uses runtime envs to isolate workers.
This test verifies that Ray Data preserves the user-specified runtime env when you
set the flag.
"""
ray.data.DataContext.get_current().isolate_read_workers = True
class CheckEnvVarDatasource(Datasource):
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
def read_fn() -> Iterable[Block]:
assert os.environ.get("MARKER") == "1"
yield pa.Table.from_pydict({"id": [0]})
return [
ReadTask(
read_fn,
BlockMetadata(
num_rows=1, size_bytes=4, input_files=None, exec_stats=None
),
)
]
def estimate_inmemory_data_size(self) -> Optional[int]:
return None
ray.data.read_datasource(
CheckEnvVarDatasource(), ray_remote_args={"runtime_env": runtime_env}
).materialize()
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))