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

621 lines
21 KiB
Python

from typing import Any, Dict, List, Optional, Type
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
from typing_extensions import Hashable
import ray
from ray._common.retry import matches_error
from ray.data._internal.datasource.parquet_datasource import ParquetDatasource
from ray.data._internal.execution.interfaces import ExecutionResources
from ray.data._internal.execution.util import merge_label_selector
from ray.data._internal.logical.interfaces import LogicalPlan
from ray.data._internal.logical.interfaces.logical_operator import LogicalOperator
from ray.data._internal.logical.operators import Read
from ray.data._internal.memory_tracing import (
leak_report,
trace_allocation,
trace_deallocation,
)
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
from ray.data._internal.remote_fn import _make_hashable, cached_remote_fn
from ray.data._internal.usage.util import (
_recorded_operators,
_recorded_operators_lock,
)
from ray.data._internal.util import (
NULL_SENTINEL,
find_partition_index,
get_max_task_capacity,
iterate_with_retry,
merge_resources_to_ray_remote_args,
rows_same,
)
from ray.data.tests.conftest import * # noqa: F401, F403
def _check_usage_record(op_names: List[str], clear_after_check: Optional[bool] = True):
"""Check if operators with given names in `op_names` have been used.
If `clear_after_check` is True, we clear the list of recorded operators
(so that subsequent checks do not use existing records of operator usage)."""
for op_name in op_names:
assert op_name not in ("Unknown", "ReadCustom", "WriteCustom"), op_name
with _recorded_operators_lock:
assert _recorded_operators.get(op_name, 0) > 0, (
op_name,
_recorded_operators,
)
if clear_after_check:
with _recorded_operators_lock:
_recorded_operators.clear()
# Utilities for structural logical plan inspection
# These provide type-safe alternatives to string-based plan matching
def plan_has_operator(plan: LogicalPlan, op_type: Type[LogicalOperator]) -> bool:
"""Check if plan contains at least one operator of given type.
Args:
plan: The logical plan to inspect
op_type: The operator type to search for
Returns:
True if at least one operator of the given type exists in the plan
"""
return any(isinstance(op, op_type) for op in plan.dag.post_order_iter())
def plan_operator_comes_before(
plan: LogicalPlan,
first_type: Type[LogicalOperator],
second_type: Type[LogicalOperator],
) -> bool:
"""Check if any operator of first_type comes before any operator of second_type.
Args:
plan: The logical plan to inspect
first_type: The operator type that should come first
second_type: The operator type that should come second
Returns:
True if at least one operator of first_type appears before at least one
operator of second_type in post-order traversal, False otherwise.
"""
operators = list(plan.dag.post_order_iter())
first_indices = [i for i, op in enumerate(operators) if isinstance(op, first_type)]
second_indices = [
i for i, op in enumerate(operators) if isinstance(op, second_type)
]
if not first_indices or not second_indices:
return False
# Check if the earliest first_type comes before the earliest second_type
return min(first_indices) < min(second_indices)
def get_operators_of_type(
plan: LogicalPlan, op_type: Type[LogicalOperator]
) -> List[LogicalOperator]:
"""Get all operators of a specific type from the plan.
Args:
plan: The logical plan to inspect
op_type: The operator type to search for
Returns:
List of all operators of the given type in post-order traversal
"""
return [op for op in plan.dag.post_order_iter() if isinstance(op, op_type)]
def get_operator_types(plan: LogicalPlan) -> List[str]:
"""Get list of operator type names in post-order traversal.
Args:
plan: The logical plan to inspect
Returns:
List of operator class names in the order they appear in post-order traversal
"""
return [type(op).__name__ for op in plan.dag.post_order_iter()]
def test_cached_remote_fn():
def foo():
pass
cpu_only_foo = cached_remote_fn(foo, num_cpus=1)
cached_cpu_only_foo = cached_remote_fn(foo, num_cpus=1)
assert cpu_only_foo == cached_cpu_only_foo
gpu_only_foo = cached_remote_fn(foo, num_gpus=1)
assert cpu_only_foo != gpu_only_foo
def test_null_sentinel():
"""Check that NULL_SENTINEL sorts greater than any other value."""
assert NULL_SENTINEL != NULL_SENTINEL
assert NULL_SENTINEL < NULL_SENTINEL
assert NULL_SENTINEL <= NULL_SENTINEL
assert not NULL_SENTINEL > NULL_SENTINEL
assert not NULL_SENTINEL >= NULL_SENTINEL
# With NoneType
assert None > NULL_SENTINEL
assert None >= NULL_SENTINEL
assert NULL_SENTINEL < None
assert NULL_SENTINEL <= None
assert NULL_SENTINEL != None # noqa: E711
# With np.nan
assert np.nan > NULL_SENTINEL
assert np.nan >= NULL_SENTINEL
assert NULL_SENTINEL < np.nan
assert NULL_SENTINEL <= np.nan
assert NULL_SENTINEL != np.nan
# Rest
assert NULL_SENTINEL > 1000
assert NULL_SENTINEL > "abc"
assert NULL_SENTINEL != 1000
assert NULL_SENTINEL != "abc"
assert not NULL_SENTINEL < 1000
assert not NULL_SENTINEL < "abc"
assert not NULL_SENTINEL <= 1000
assert not NULL_SENTINEL <= "abc"
assert NULL_SENTINEL >= 1000
assert NULL_SENTINEL >= "abc"
def test_make_hashable():
valid_args = {
"int": 0,
"float": 1.2,
"str": "foo",
"dict": {
0: 0,
1.2: 1.2,
},
"list": list(range(10)),
"tuple": tuple(range(3)),
"type": Hashable,
}
hashable_args = _make_hashable(valid_args)
assert hash(hashable_args) == hash(
(
("dict", ((0, 0), (1.2, 1.2))),
("float", 1.2),
("int", 0),
("list", (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)),
("str", "foo"),
("tuple", (0, 1, 2)),
("type", Hashable),
)
)
# Invalid case # 1: can't mix up key types
invalid_args = {0: 1, "bar": "baz"}
with pytest.raises(TypeError) as exc_info:
_make_hashable(invalid_args)
assert (
str(exc_info.value) == "'<' not supported between instances of 'str' and 'int'"
)
@pytest.mark.parametrize("enabled", [False, True])
def test_memory_tracing(enabled):
ctx = ray.data.context.DataContext.get_current()
ctx.trace_allocations = enabled
ref1 = ray.put(np.zeros(1024 * 1024))
ref2 = ray.put(np.zeros(1024 * 1024))
ref3 = ray.put(np.zeros(1024 * 1024))
trace_allocation(ref1, "test1")
trace_allocation(ref2, "test2")
trace_allocation(ref3, "test5")
trace_deallocation(ref1, "test3", free=False)
trace_deallocation(ref2, "test4", free=True)
ray.get(ref1)
with pytest.raises(ray.exceptions.ObjectFreedError):
ray.get(ref2)
report = leak_report()
print(report)
if enabled:
assert "Leaked object, created at test1" in report, report
assert "Leaked object, created at test5" in report, report
assert "Freed object from test2 at test4" in report, report
assert "skipped dealloc at test3" in report, report
else:
assert "test1" not in report, report
assert "test2" not in report, report
assert "test3" not in report, report
assert "test4" not in report, report
assert "test5" not in report, report
def get_parquet_read_logical_op(
ray_remote_args: Optional[Dict[str, Any]] = None,
**read_kwargs,
) -> Read:
datasource = ParquetDatasource(paths="example://iris.parquet")
if "parallelism" not in read_kwargs:
read_kwargs["parallelism"] = 10
read_op = Read(
datasource=datasource,
datasource_or_legacy_reader=datasource,
ray_remote_args=ray_remote_args,
**read_kwargs,
)
return read_op
@ray.remote(num_cpus=0)
class ConcurrencyCounter:
def __init__(self):
self.concurrency = 0
self.max_concurrency = 0
def inc(self):
self.concurrency += 1
if self.concurrency > self.max_concurrency:
self.max_concurrency = self.concurrency
return self.concurrency
def decr(self):
self.concurrency -= 1
return self.concurrency
def get_max_concurrency(self):
return self.max_concurrency
def test_iterate_with_retry():
has_raised_error = False
class MockIterable:
"""Iterate over the numbers 0, 1, 2, and raise an error on the first iteration
attempt.
"""
def __init__(self, fail_at_index=3):
self._index = -1
self._fail_at_index = fail_at_index
def __iter__(self):
return self
def __next__(self):
self._index += 1
if self._index >= 10:
raise StopIteration
nonlocal has_raised_error
if self._index == self._fail_at_index and not has_raised_error:
has_raised_error = True
raise RuntimeError("Transient error")
return self._index
expected = list(range(10))
assert list(iterate_with_retry(MockIterable, description="get item")) == expected
has_raised_error = False
assert (
list(iterate_with_retry(MockIterable, description="get item", max_attempts=2))
== expected
)
def test_iterate_with_retry_unwrap_cause():
"""unwrap_cause=True makes `match` patterns search e.__cause__ as well."""
attempts = 0
class MockIterable:
def __init__(self):
nonlocal attempts
attempts += 1
def __iter__(self):
return self
def __next__(self):
try:
raise RuntimeError("transient inner")
except RuntimeError as inner:
raise ValueError("outer wrapper") from inner
# unwrap_cause=True: pattern matches the cause → all attempts consumed.
attempts = 0
with pytest.raises(ValueError, match="outer wrapper"):
list(
iterate_with_retry(
MockIterable,
description="get item",
match=["transient inner"],
max_attempts=2,
unwrap_cause=True,
)
)
assert attempts == 2
# unwrap_cause=False: cause invisible → not retryable → only one attempt.
attempts = 0
with pytest.raises(ValueError, match="outer wrapper"):
list(
iterate_with_retry(
MockIterable,
description="get item",
match=["transient inner"],
max_attempts=2,
unwrap_cause=False,
)
)
assert attempts == 1
def test_iterate_with_retry_matches_class_name():
"""Patterns can match the exception class name (e.g., 'RateLimit')."""
class RateLimitError(Exception):
pass
attempts = 0
class MockIterable:
def __init__(self):
nonlocal attempts
attempts += 1
def __iter__(self):
return self
def __next__(self):
raise RateLimitError("Error code: 429")
attempts = 0
with pytest.raises(RateLimitError):
list(
iterate_with_retry(
MockIterable,
description="get item",
match=["RateLimit"],
max_attempts=2,
)
)
assert attempts == 2
@pytest.mark.parametrize(
"pattern, error_message, expected",
[
# Plain substring match.
("transient", "transient network error", True),
# Regex match when substring check fails.
("40[0-9]", "HTTP 404 not found", True),
# Substring takes priority: literal "(503)" found before regex is tried.
("(503)", "error (503) returned", True),
# Invalid regex falls back to False, not re.error.
("[unclosed", "some error message", False),
# No match at all.
("rate limit", "connection refused", False),
],
)
def test_matches_error(pattern, error_message, expected):
"""Retry helper matches substring first, then regex; invalid patterns do not raise."""
assert matches_error(pattern, error_message) is expected
def test_find_partition_index_single_column_ascending():
table = pa.table({"value": [1, 2, 2, 3, 5]})
sort_key = SortKey(key=["value"], descending=[False])
assert find_partition_index(table, (0,), sort_key) == 0 # all entries > 0
assert find_partition_index(table, (2,), sort_key) == 1 # first match index
assert find_partition_index(table, (4,), sort_key) == 4 # belongs after 3, before 5
assert find_partition_index(table, (6,), sort_key) == 5 # all entries < 6
def test_find_partition_index_single_column_descending():
table = pa.table({"value": [5, 3, 2, 2, 1]})
sort_key = SortKey(key=["value"], descending=[True])
assert find_partition_index(table, (6,), sort_key) == 0 # belongs before 5
assert find_partition_index(table, (3,), sort_key) == 2 # after the last 3
assert find_partition_index(table, (2,), sort_key) == 4 # after the last 2
assert find_partition_index(table, (0,), sort_key) == 5 # all entries > 0
def test_find_partition_index_multi_column():
# Table sorted by col1 asc, then col2 desc.
table = pa.table({"col1": [1, 1, 1, 2, 2], "col2": [3, 2, 1, 2, 1]})
sort_key = SortKey(key=["col1", "col2"], descending=[False, True])
# Insert value (1,3) -> belongs before (1,2)
assert find_partition_index(table, (1, 3), sort_key) == 0
# Insert value (1,2) -> belongs after the first (1,3) and before (1,2)
# because col1 ties, col2 descending
assert find_partition_index(table, (1, 2), sort_key) == 1
# Insert value (2,2) -> belongs right before (2,2) that starts at index 3
assert find_partition_index(table, (2, 2), sort_key) == 3
# Insert value (0, 4) -> belongs at index 0 (all col1 > 0)
assert find_partition_index(table, (0, 4), sort_key) == 0
# Insert value (2,0) -> belongs after (2,1)
assert find_partition_index(table, (2, 0), sort_key) == 5
def test_find_partition_index_with_nulls():
# _NullSentinel is sorted greater, so they appear after all real values.
table = pa.table({"value": [1, 2, 3, None, None]})
sort_key = SortKey(key=["value"], descending=[False])
# Insert (2,) -> belongs after 1, before 2 => index 1
# (But the actual find_partition_index uses the table as-is.)
assert find_partition_index(table, (2,), sort_key) == 1
# Insert (4,) -> belongs before any null => index 3
assert find_partition_index(table, (4,), sort_key) == 3
# Insert (None,) -> always belongs at the end
assert find_partition_index(table, (None,), sort_key) == 3
def test_find_partition_index_with_nan():
# NaN sorts after regular values in Arrow (before nulls).
table = pa.table({"value": [1.0, 2.0, 3.0, float("nan"), float("nan")]})
sort_key = SortKey(key=["value"], descending=[False])
assert find_partition_index(table, (2.0,), sort_key) == 1
assert find_partition_index(table, (4.0,), sort_key) == 3
def test_find_partition_index_with_nan_and_nulls():
# NaN sorts after regular values, nulls sort after NaN.
table = pa.table({"value": [1.0, 2.0, 3.0, float("nan"), None]})
sort_key = SortKey(key=["value"], descending=[False])
assert find_partition_index(table, (2.0,), sort_key) == 1
assert find_partition_index(table, (4.0,), sort_key) == 3
assert find_partition_index(table, (None,), sort_key) == 3
def test_find_partition_index_duplicates():
table = pa.table({"value": [2, 2, 2, 2, 2]})
sort_key = SortKey(key=["value"], descending=[False])
# Insert (2,) in a table of all 2's -> first matching index is 0
assert find_partition_index(table, (2,), sort_key) == 0
# Insert (1,) -> belongs at index 0
assert find_partition_index(table, (1,), sort_key) == 0
# Insert (3,) -> belongs at index 5
assert find_partition_index(table, (3,), sort_key) == 5
def test_find_partition_index_duplicates_descending():
table = pa.table({"value": [2, 2, 2, 2, 2]})
sort_key = SortKey(key=["value"], descending=[True])
# Insert (2,) in a table of all 2's -> belongs at index 5
assert find_partition_index(table, (2,), sort_key) == 5
# Insert (1,) -> belongs at index 5
assert find_partition_index(table, (1,), sort_key) == 5
# Insert (3,) -> belongs at index 0
assert find_partition_index(table, (3,), sort_key) == 0
def test_merge_resources_to_ray_remote_args():
ray_remote_args = {}
ray_remote_args = merge_resources_to_ray_remote_args(1, 1, 1, ray_remote_args)
assert ray_remote_args == {"num_cpus": 1, "num_gpus": 1, "memory": 1}
ray_remote_args = {"other_resource": 1}
ray_remote_args = merge_resources_to_ray_remote_args(1, 1, 1, ray_remote_args)
assert ray_remote_args == {
"num_cpus": 1,
"num_gpus": 1,
"memory": 1,
"other_resource": 1,
}
@pytest.mark.parametrize(
"actual, expected, expected_equal",
[
(pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [1]}), True),
# Different value.
(pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [2]}), False),
# Extra column.
(pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [1], "b": [2]}), False),
# Different number of rows.
(pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [1, 1]}), False),
# Same rows, but different order.
(pd.DataFrame({"a": [1, 2]}), pd.DataFrame({"a": [2, 1]}), True),
],
)
def test_rows_same(actual: pd.DataFrame, expected: pd.DataFrame, expected_equal: bool):
if expected_equal:
assert rows_same(actual, expected)
else:
with pytest.raises(AssertionError):
assert rows_same(actual, expected)
@pytest.mark.parametrize(
"allocated,min_scheduling,expected",
[
(None, ExecutionResources(cpu=1, gpu=1), 0),
(ExecutionResources(cpu=1, gpu=1), ExecutionResources(cpu=1, gpu=1), 1),
(
ExecutionResources(cpu=1, gpu=1),
ExecutionResources(cpu=0, gpu=0),
float("inf"),
),
(ExecutionResources(cpu=1, gpu=1), ExecutionResources(cpu=0, gpu=1), 1),
(ExecutionResources(cpu=1, gpu=1), ExecutionResources(cpu=1, gpu=0), 1),
],
)
def test_get_max_task_capacity(allocated, min_scheduling, expected):
assert get_max_task_capacity(allocated, min_scheduling) == expected
class TestMergeLabelSelector:
"""Tests for ``merge_label_selector``.
The helper merges a DataContext-level label_selector into a ray_remote_args
dict. Operator-level entries win on key conflicts.
"""
def test_ctx_none_returns_input_unchanged(self):
args = {"num_cpus": 1}
assert merge_label_selector(args, None) is args
def test_ctx_empty_returns_input_unchanged(self):
args = {"num_cpus": 1}
assert merge_label_selector(args, {}) is args
def test_ctx_only(self):
args = {"num_cpus": 1}
out = merge_label_selector(args, {"subcluster": "train"})
assert out == {"num_cpus": 1, "label_selector": {"subcluster": "train"}}
assert args == {"num_cpus": 1} # input not mutated
def test_op_only_no_ctx(self):
args = {"label_selector": {"node": "X"}}
assert merge_label_selector(args, None) is args
def test_op_and_ctx_no_collision(self):
args = {"label_selector": {"node": "X"}}
out = merge_label_selector(args, {"subcluster": "train"})
assert out["label_selector"] == {"subcluster": "train", "node": "X"}
def test_op_wins_on_collision(self):
args = {"label_selector": {"subcluster": "val"}}
out = merge_label_selector(args, {"subcluster": "train"})
assert out["label_selector"] == {"subcluster": "val"}
def test_input_not_mutated(self):
args = {"label_selector": {"node": "X"}}
ctx = {"subcluster": "train"}
merge_label_selector(args, ctx)
assert args == {"label_selector": {"node": "X"}}
assert ctx == {"subcluster": "train"}
def test_execution_options_label_selector_field():
"""Smoke test that ExecutionOptions exposes label_selector."""
from ray.data._internal.execution.interfaces import ExecutionOptions
options = ExecutionOptions()
assert options.label_selector is None
options = ExecutionOptions(label_selector={"subcluster": "train"})
assert options.label_selector == {"subcluster": "train"}
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))