Files
ray-project--ray/python/ray/_common/test_utils.py
T
2026-07-13 13:17:40 +08:00

531 lines
16 KiB
Python

"""Test utilities for Ray.
This module contains test utility classes that are distributed with the Ray package
and can be used by external libraries and tests. These utilities must remain in
_common/ (not in tests/) to be accessible in the Ray package distribution.
"""
import asyncio
import inspect
import logging
import os
import subprocess
import sys
import threading
import time
import traceback
import uuid
from collections import defaultdict
from collections.abc import Awaitable
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, Iterator, List, Optional, Set
import ray
import ray._common.usage.usage_lib as ray_usage_lib
import ray._private.utils
from ray._common.network_utils import build_address
from ray._common.utils import decode
logger = logging.getLogger(__name__)
try:
from prometheus_client.core import Metric
from prometheus_client.parser import Sample, text_string_to_metric_families
except (ImportError, ModuleNotFoundError):
Metric = None
Sample = None
def text_string_to_metric_families(*args, **kwargs):
raise ModuleNotFoundError("`prometheus_client` not found")
@ray.remote(num_cpus=0)
class SignalActor:
"""A Ray actor for coordinating test execution through signals.
Useful for testing async coordination, waiting for specific states,
and synchronizing multiple actors or tasks in tests.
"""
def __init__(self):
self.ready_event = asyncio.Event()
self.num_waiters = 0
def send(self, clear: bool = False):
self.ready_event.set()
if clear:
self.ready_event.clear()
async def wait(self, should_wait: bool = True):
if should_wait:
self.num_waiters += 1
await self.ready_event.wait()
self.num_waiters -= 1
async def cur_num_waiters(self) -> int:
return self.num_waiters
@ray.remote(num_cpus=0)
class Semaphore:
"""A Ray actor implementing a semaphore for test coordination.
Useful for testing resource limiting, concurrency control,
and coordination between multiple actors or tasks.
"""
def __init__(self, value: int = 1):
self._sema = asyncio.Semaphore(value=value)
async def acquire(self):
await self._sema.acquire()
async def release(self):
self._sema.release()
async def locked(self) -> bool:
return self._sema.locked()
__all__ = ["SignalActor", "Semaphore"]
def wait_for_condition(
condition_predictor: Callable[..., bool],
timeout: float = 10,
retry_interval_ms: float = 100,
raise_exceptions: bool = False,
**kwargs: Any,
):
"""Wait until a condition is met or time out with an exception.
Args:
condition_predictor: A function that predicts the condition.
timeout: Maximum timeout in seconds.
retry_interval_ms: Retry interval in milliseconds.
raise_exceptions: If true, exceptions that occur while executing
condition_predictor won't be caught and instead will be raised.
**kwargs: Arguments to pass to the condition_predictor.
Returns:
None: Returns when the condition is met.
Raises:
RuntimeError: If the condition is not met before the timeout expires.
"""
start = time.monotonic()
last_ex = None
while time.monotonic() - start <= timeout:
try:
if condition_predictor(**kwargs):
return
except Exception:
if raise_exceptions:
raise
last_ex = ray._private.utils.format_error_message(traceback.format_exc())
time.sleep(retry_interval_ms / 1000.0)
message = "The condition wasn't met before the timeout expired."
if last_ex is not None:
message += f" Last exception: {last_ex}"
raise RuntimeError(message)
async def async_wait_for_condition(
condition_predictor: Callable[..., Awaitable[bool]],
timeout: float = 10,
retry_interval_ms: float = 100,
**kwargs: Any,
):
"""Wait until a condition is met or time out with an exception.
Args:
condition_predictor: A function that predicts the condition.
timeout: Maximum timeout in seconds.
retry_interval_ms: Retry interval in milliseconds.
**kwargs: Arguments to pass to the condition_predictor.
Returns:
None: Returns when the condition is met.
Raises:
RuntimeError: If the condition is not met before the timeout expires.
"""
start = time.monotonic()
last_ex = None
while time.monotonic() - start <= timeout:
try:
if inspect.iscoroutinefunction(condition_predictor):
if await condition_predictor(**kwargs):
return
else:
if condition_predictor(**kwargs):
return
except Exception as ex:
last_ex = ex
await asyncio.sleep(retry_interval_ms / 1000.0)
message = "The condition wasn't met before the timeout expired."
if last_ex is not None:
message += f" Last exception: {last_ex}"
raise RuntimeError(message)
@contextmanager
def simulate_s3_bucket(
port: int = 5002,
region: str = "us-west-2",
) -> Iterator[str]:
"""Context manager that simulates an S3 bucket and yields the URI.
Args:
port: The port of the localhost endpoint where S3 is being served.
region: The S3 region.
Yields:
str: URI for the simulated S3 bucket.
"""
from moto.server import ThreadedMotoServer
old_env = os.environ
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
s3_server = f"http://{build_address('localhost', port)}"
server = ThreadedMotoServer(port=port)
server.start()
url = f"s3://{uuid.uuid4().hex}?region={region}&endpoint_override={s3_server}"
yield url
server.stop()
os.environ = old_env
class TelemetryCallsite(Enum):
DRIVER = "driver"
ACTOR = "actor"
TASK = "task"
def _get_library_usages() -> Set[str]:
return set(
ray_usage_lib.get_library_usages_to_report(
ray.experimental.internal_kv.internal_kv_get_gcs_client()
)
)
def _get_extra_usage_tags() -> Dict[str, str]:
return ray_usage_lib.get_extra_usage_tags_to_report(
ray.experimental.internal_kv.internal_kv_get_gcs_client()
)
def check_library_usage_telemetry(
use_lib_fn: Callable[[], None],
*,
callsite: TelemetryCallsite,
expected_library_usages: List[Set[str]],
expected_extra_usage_tags: Optional[Dict[str, str]] = None,
):
"""Helper for writing tests to validate library usage telemetry.
`use_lib_fn` is a callable that will be called from the provided callsite.
After calling it, the telemetry data to export will be validated against
expected_library_usages and expected_extra_usage_tags.
"""
assert len(_get_library_usages()) == 0, _get_library_usages()
if callsite == TelemetryCallsite.DRIVER:
use_lib_fn()
elif callsite == TelemetryCallsite.ACTOR:
@ray.remote
class A:
def __init__(self):
use_lib_fn()
a = A.remote()
ray.get(a.__ray_ready__.remote())
elif callsite == TelemetryCallsite.TASK:
@ray.remote
def f():
use_lib_fn()
ray.get(f.remote())
else:
assert False, f"Unrecognized callsite: {callsite}"
library_usages = _get_library_usages()
extra_usage_tags = _get_extra_usage_tags()
assert library_usages in expected_library_usages, library_usages
if expected_extra_usage_tags:
assert all(
[extra_usage_tags[k] == v for k, v in expected_extra_usage_tags.items()]
), extra_usage_tags
class FakeTimer:
def __init__(self, start_time: Optional[float] = None):
self._lock = threading.Lock()
self.reset(start_time=start_time)
def reset(self, start_time: Optional[float] = None):
with self._lock:
if start_time is None:
start_time = time.time()
self._curr = start_time
def time(self) -> float:
return self._curr
def advance(self, by: float):
with self._lock:
self._curr += by
def realistic_sleep(self, amt: float):
with self._lock:
self._curr += amt + 0.001
def is_named_tuple(cls):
"""Return True if cls is a namedtuple and False otherwise."""
b = cls.__bases__
if len(b) != 1 or b[0] is not tuple:
return False
f = getattr(cls, "_fields", None)
if not isinstance(f, tuple):
return False
return all(type(n) is str for n in f)
def assert_tensors_equivalent(obj1, obj2):
"""
Recursively compare objects with special handling for torch.Tensor.
Tensors are considered equivalent if:
- Same dtype and shape
- Same device type (e.g., both 'cpu' or both 'cuda'), index ignored
- Values are equal (or close for floats)
"""
import torch
if isinstance(obj1, torch.Tensor) and isinstance(obj2, torch.Tensor):
# 1. dtype
assert obj1.dtype == obj2.dtype, f"dtype mismatch: {obj1.dtype} vs {obj2.dtype}"
# 2. shape
assert obj1.shape == obj2.shape, f"shape mismatch: {obj1.shape} vs {obj2.shape}"
# 3. device type must match (cpu/cpu or cuda/cuda), ignore index
assert (
obj1.device.type == obj2.device.type
), f"Device type mismatch: {obj1.device} vs {obj2.device}"
# 4. Compare values safely on CPU
t1_cpu = obj1.cpu()
t2_cpu = obj2.cpu()
if obj1.dtype.is_floating_point or obj1.dtype.is_complex:
assert torch.allclose(
t1_cpu, t2_cpu, atol=1e-6, rtol=1e-5
), "Floating-point tensors not close"
else:
assert torch.equal(t1_cpu, t2_cpu), "Integer/bool tensors not equal"
return
# Type must match
if type(obj1) is not type(obj2):
raise AssertionError(f"Type mismatch: {type(obj1)} vs {type(obj2)}")
# Handle namedtuples
if is_named_tuple(type(obj1)):
assert len(obj1) == len(obj2)
for a, b in zip(obj1, obj2):
assert_tensors_equivalent(a, b)
elif isinstance(obj1, dict):
assert obj1.keys() == obj2.keys()
for k in obj1:
assert_tensors_equivalent(obj1[k], obj2[k])
elif isinstance(obj1, (list, tuple)):
assert len(obj1) == len(obj2)
for a, b in zip(obj1, obj2):
assert_tensors_equivalent(a, b)
elif hasattr(obj1, "__dict__") and hasattr(obj2, "__dict__"):
# Compare user-defined objects by their public attributes
keys1 = {
k
for k in obj1.__dict__.keys()
if not k.startswith("_ray_") and k != "_pytype_"
}
keys2 = {
k
for k in obj2.__dict__.keys()
if not k.startswith("_ray_") and k != "_pytype_"
}
assert keys1 == keys2, f"Object attribute keys differ: {keys1} vs {keys2}"
for k in keys1:
assert_tensors_equivalent(obj1.__dict__[k], obj2.__dict__[k])
else:
# Fallback for primitives: int, float, str, bool, etc.
assert obj1 == obj2, f"Non-tensor values differ: {obj1} vs {obj2}"
def run_string_as_driver(
driver_script: str, env: Dict = None, encode: str = "utf-8"
) -> str:
"""Run a driver as a separate process.
Args:
driver_script: A string to run as a Python script.
env: The environment variables for the driver.
encode: The encoding to use for the driver script.
Returns:
The script's output.
"""
proc = subprocess.Popen(
[sys.executable, "-"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
with proc:
output = proc.communicate(driver_script.encode(encoding=encode))[0]
if proc.returncode:
print(decode(output, encode_type=encode))
logger.error(proc.stderr)
raise subprocess.CalledProcessError(
proc.returncode, proc.args, output, proc.stderr
)
out = decode(output, encode_type=encode)
return out
@dataclass
class MetricSamplePattern:
name: Optional[str] = None
value: Optional[str] = None
partial_label_match: Optional[Dict[str, str]] = None
def matches(self, sample: "Sample"):
if self.name is not None:
if self.name != sample.name:
return False
if self.value is not None:
if self.value != sample.value:
return False
if self.partial_label_match is not None:
for label, value in self.partial_label_match.items():
if sample.labels.get(label) != value:
return False
return True
@dataclass
class PrometheusTimeseries:
"""A collection of timeseries from multiple addresses. Each timeseries is a
collection of samples with the same metric name and labels. Concretely:
- components_dict: a dictionary of addresses to the Component labels
- metric_descriptors: a dictionary of metric names to the Metric object
- metric_samples: the latest value of each label
"""
components_dict: Dict[str, Set[str]] = field(default_factory=dict)
metric_descriptors: Dict[str, "Metric"] = field(default_factory=dict)
metric_samples: Dict[frozenset, "Sample"] = field(default_factory=dict)
def flush(self):
self.components_dict.clear()
self.metric_descriptors.clear()
self.metric_samples.clear()
def fetch_raw_prometheus(prom_addresses, timeout=None):
# Local import so minimal dependency tests can run without requests
import requests
for address in prom_addresses:
try:
kwargs = {} if timeout is None else {"timeout": timeout}
response = requests.get(f"http://{address}/metrics", **kwargs)
yield address, response.text
except requests.exceptions.ConnectionError:
continue
except requests.exceptions.Timeout:
continue
def fetch_prometheus(prom_addresses, timeout=None):
components_dict = {}
metric_descriptors = {}
metric_samples = []
for address in prom_addresses:
if address not in components_dict:
components_dict[address] = set()
for address, response in fetch_raw_prometheus(prom_addresses, timeout=timeout):
for metric in text_string_to_metric_families(response):
for sample in metric.samples:
metric_descriptors[sample.name] = metric
metric_samples.append(sample)
if "Component" in sample.labels:
components_dict[address].add(sample.labels["Component"])
return components_dict, metric_descriptors, metric_samples
def fetch_prometheus_timeseries(
prom_addreses: List[str],
result: PrometheusTimeseries,
timeout=None,
) -> PrometheusTimeseries:
components_dict, metric_descriptors, metric_samples = fetch_prometheus(
prom_addreses, timeout=timeout
)
for address, components in components_dict.items():
if address not in result.components_dict:
result.components_dict[address] = set()
result.components_dict[address].update(components)
result.metric_descriptors.update(metric_descriptors)
for sample in metric_samples:
# udpate sample to the latest value
result.metric_samples[
frozenset(list(sample.labels.items()) + [("_metric_name_", sample.name)])
] = sample
return result
def fetch_prometheus_metrics(prom_addresses: List[str]) -> Dict[str, List[Any]]:
"""Return prometheus metrics from the given addresses.
Args:
prom_addresses: List of metrics_agent addresses to collect metrics from.
Returns:
Dict mapping from metric name to list of samples for the metric.
"""
_, _, samples = fetch_prometheus(prom_addresses)
samples_by_name = defaultdict(list)
for sample in samples:
samples_by_name[sample.name].append(sample)
return samples_by_name
def fetch_prometheus_metric_timeseries(
prom_addresses: List[str],
result: PrometheusTimeseries,
timeout=None,
) -> Dict[str, List[Any]]:
samples = fetch_prometheus_timeseries(
prom_addresses, result, timeout=timeout
).metric_samples.values()
samples_by_name = defaultdict(list)
for sample in samples:
samples_by_name[sample.name].append(sample)
return samples_by_name