chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Common Utilities Shared Across the Libraries
|
||||
|
||||
This directory contains logic shared across Ray Core and the native libraries.
|
||||
|
||||
- All dependencies by the libraries on non-public APIs in the repo should live here. Libraries should _not_ depend on `ray._private`.
|
||||
- Interfaces exposed in this directory should be treated similarly to a "developer API."
|
||||
- End users and external libraries not inside the Ray repo should not depend on any code in `ray._common` (the same as `ray._private`).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Prefix for the node id resource that is automatically added to each node.
|
||||
# For example, a node may have id `node:172.23.42.1`.
|
||||
NODE_ID_PREFIX = "node:"
|
||||
# The system resource that head node has.
|
||||
HEAD_NODE_RESOURCE_NAME = NODE_ID_PREFIX + "__internal_head__"
|
||||
|
||||
RAY_WARN_BLOCKING_GET_INSIDE_ASYNC_ENV_VAR = "RAY_WARN_BLOCKING_GET_INSIDE_ASYNC"
|
||||
@@ -0,0 +1,181 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional, TypeVar, Union, cast, overload
|
||||
|
||||
from ray.util import log_once
|
||||
from ray.util.annotations import _mark_annotated
|
||||
|
||||
# TypeVar for preserving function/class signatures through decorators.
|
||||
# Note: These decorators also accept properties, but we use Callable for the
|
||||
# common case. Properties work at runtime but won't get full type inference.
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A constant to use for any configuration that should be deprecated
|
||||
# (to check, whether this config has actually been assigned a proper value or
|
||||
# not).
|
||||
DEPRECATED_VALUE = -1
|
||||
|
||||
|
||||
def deprecation_warning(
|
||||
old: str,
|
||||
new: Optional[str] = None,
|
||||
*,
|
||||
help: Optional[str] = None,
|
||||
error: Optional[Union[bool, type[Exception]]] = None,
|
||||
stacklevel: int = 2,
|
||||
) -> None:
|
||||
"""Warns (via the `logger` object) or throws a deprecation warning/error.
|
||||
|
||||
Args:
|
||||
old: A description of the "thing" that is to be deprecated.
|
||||
new: A description of the new "thing" that replaces it.
|
||||
help: An optional help text to tell the user, what to
|
||||
do instead of using `old`.
|
||||
error: Whether or which exception to raise. If True, raise ValueError.
|
||||
If False, just warn. If `error` is-a subclass of Exception,
|
||||
raise that Exception.
|
||||
stacklevel: The stacklevel to use for the warning message.
|
||||
Use 2 to point to where this function is called, 3+ to point
|
||||
further up the stack.
|
||||
|
||||
Raises:
|
||||
ValueError: If `error=True`.
|
||||
Exception: Of type `error`, iff `error` is a sub-class of `Exception`.
|
||||
"""
|
||||
msg = "`{}` has been deprecated.{}".format(
|
||||
old, (" Use `{}` instead.".format(new) if new else f" {help}" if help else "")
|
||||
)
|
||||
|
||||
if error:
|
||||
if not isinstance(error, bool) and issubclass(error, Exception):
|
||||
# error is an Exception
|
||||
raise error(msg)
|
||||
else:
|
||||
# error is a boolean, construct ValueError ourselves
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
logger.warning(
|
||||
"DeprecationWarning: " + msg + " This will raise an error in the future!",
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
|
||||
|
||||
@overload
|
||||
def Deprecated(
|
||||
old: None = None,
|
||||
*,
|
||||
new: Optional[str] = None,
|
||||
help: Optional[str] = None,
|
||||
error: Union[bool, type[Exception]],
|
||||
) -> Callable[[F], F]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def Deprecated(
|
||||
old: str,
|
||||
*,
|
||||
new: Optional[str] = None,
|
||||
help: Optional[str] = None,
|
||||
error: Union[bool, type[Exception]],
|
||||
) -> Callable[[F], F]:
|
||||
...
|
||||
|
||||
|
||||
def Deprecated(
|
||||
old: Optional[str] = None,
|
||||
*,
|
||||
new: Optional[str] = None,
|
||||
help: Optional[str] = None,
|
||||
error: Union[bool, type[Exception]],
|
||||
) -> Callable[[F], F]:
|
||||
"""Decorator for documenting a deprecated class, method, or function.
|
||||
|
||||
Automatically adds a `deprecation.deprecation_warning(old=...,
|
||||
error=False)` to not break existing code at this point to the decorated
|
||||
class' constructor, method, or function.
|
||||
|
||||
In a next major release, this warning should then be made an error
|
||||
(by setting error=True), which means at this point that the
|
||||
class/method/function is no longer supported, but will still inform
|
||||
the user about the deprecation event.
|
||||
|
||||
In a further major release, the class, method, function should be erased
|
||||
entirely from the codebase.
|
||||
|
||||
|
||||
.. testcode::
|
||||
:skipif: True
|
||||
|
||||
from ray._common.deprecation import Deprecated
|
||||
# Deprecated class: Patches the constructor to warn if the class is
|
||||
# used.
|
||||
@Deprecated(new="NewAndMuchCoolerClass", error=False)
|
||||
class OldAndUncoolClass:
|
||||
...
|
||||
|
||||
# Deprecated class method: Patches the method to warn if called.
|
||||
class StillCoolClass:
|
||||
...
|
||||
@Deprecated(new="StillCoolClass.new_and_much_cooler_method()",
|
||||
error=False)
|
||||
def old_and_uncool_method(self, uncool_arg):
|
||||
...
|
||||
|
||||
# Deprecated function: Patches the function to warn if called.
|
||||
@Deprecated(new="new_and_much_cooler_function", error=False)
|
||||
def old_and_uncool_function(*uncool_args):
|
||||
...
|
||||
"""
|
||||
|
||||
def _inner(obj: F) -> F:
|
||||
# A deprecated class.
|
||||
if inspect.isclass(obj):
|
||||
# Patch the class' init method to raise the warning/error.
|
||||
obj_init = obj.__init__
|
||||
|
||||
def patched_init(*args, **kwargs):
|
||||
if log_once(old or obj.__name__):
|
||||
deprecation_warning(
|
||||
old=old or obj.__name__,
|
||||
new=new,
|
||||
help=help,
|
||||
error=error,
|
||||
stacklevel=3,
|
||||
)
|
||||
return obj_init(*args, **kwargs)
|
||||
|
||||
obj.__init__ = patched_init
|
||||
_mark_annotated(obj)
|
||||
# Return the patched class (with the warning/error when
|
||||
# instantiated).
|
||||
return obj
|
||||
|
||||
# A deprecated class method or function.
|
||||
# Patch with the warning/error at the beginning.
|
||||
def _ctor(*args, **kwargs):
|
||||
if log_once(old or obj.__name__):
|
||||
deprecation_warning(
|
||||
old=old or obj.__name__,
|
||||
new=new,
|
||||
help=help,
|
||||
error=error,
|
||||
stacklevel=3,
|
||||
)
|
||||
# Call the deprecated method/function.
|
||||
return obj(*args, **kwargs)
|
||||
|
||||
# Only apply @wraps for actual callables, not properties/descriptors.
|
||||
# Setting __wrapped__ on a property causes inspect.unwrap() to return
|
||||
# the property, which breaks inspect.signature() in the tracing helper.
|
||||
if callable(obj):
|
||||
_ctor = wraps(obj)(_ctor)
|
||||
|
||||
# Return the patched class method/function.
|
||||
return cast(F, _ctor)
|
||||
|
||||
# Return the prepared decorator.
|
||||
return _inner
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import ray
|
||||
from ray._common.logging_constants import LogKey
|
||||
|
||||
|
||||
class CoreContextFilter(logging.Filter):
|
||||
TASK_LEVEL_LOG_KEYS = [
|
||||
LogKey.TASK_ID.value,
|
||||
LogKey.TASK_NAME.value,
|
||||
LogKey.TASK_FUNCTION_NAME.value,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_ray_core_logging_context(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the ray core logging context as a dict.
|
||||
Only use this function if you need include the attributes to the log record
|
||||
yourself by bypassing the filter.
|
||||
"""
|
||||
if not ray.is_initialized():
|
||||
# There is no additional context if ray is not initialized
|
||||
return {}
|
||||
|
||||
runtime_context = ray.get_runtime_context()
|
||||
ray_core_logging_context = {
|
||||
LogKey.JOB_ID.value: runtime_context.get_job_id(),
|
||||
LogKey.WORKER_ID.value: runtime_context.get_worker_id(),
|
||||
LogKey.NODE_ID.value: runtime_context.get_node_id(),
|
||||
}
|
||||
if runtime_context.worker.mode == ray.WORKER_MODE:
|
||||
ray_core_logging_context[
|
||||
LogKey.ACTOR_ID.value
|
||||
] = runtime_context.get_actor_id()
|
||||
ray_core_logging_context[
|
||||
LogKey.TASK_ID.value
|
||||
] = runtime_context.get_task_id()
|
||||
ray_core_logging_context[
|
||||
LogKey.TASK_NAME.value
|
||||
] = runtime_context.get_task_name()
|
||||
ray_core_logging_context[
|
||||
LogKey.TASK_FUNCTION_NAME.value
|
||||
] = runtime_context.get_task_function_name()
|
||||
ray_core_logging_context[
|
||||
LogKey.ACTOR_NAME.value
|
||||
] = runtime_context.get_actor_name()
|
||||
return ray_core_logging_context
|
||||
|
||||
def filter(self, record):
|
||||
context = self.get_ray_core_logging_context()
|
||||
for key, value in context.items():
|
||||
if value is not None and value != "":
|
||||
setattr(record, key, value)
|
||||
return True
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ray._common.logging_constants import (
|
||||
LOGGER_FLATTEN_KEYS,
|
||||
LOGRECORD_STANDARD_ATTRS,
|
||||
LogKey,
|
||||
)
|
||||
from ray._private.log import INTERNAL_TIMESTAMP_LOG_KEY
|
||||
from ray._private.ray_constants import LOGGER_FORMAT
|
||||
|
||||
|
||||
def _append_flatten_attributes(formatted_attrs: Dict[str, Any], key: str, value: Any):
|
||||
"""Flatten the dictionary values for special keys and append the values in place.
|
||||
|
||||
If the key is in `LOGGER_FLATTEN_KEYS`, the value will be flattened and appended
|
||||
to the `formatted_attrs` dictionary. Otherwise, the key-value pair will be appended
|
||||
directly.
|
||||
"""
|
||||
if key in LOGGER_FLATTEN_KEYS:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(
|
||||
f"Expected a dictionary passing into {key}, but got {type(value)}"
|
||||
)
|
||||
for k, v in value.items():
|
||||
if k in formatted_attrs:
|
||||
raise KeyError(f"Found duplicated key in the log record: {k}")
|
||||
formatted_attrs[k] = v
|
||||
else:
|
||||
formatted_attrs[key] = value
|
||||
|
||||
|
||||
class AbstractFormatter(logging.Formatter, ABC):
|
||||
def __init__(self, fmt=None, datefmt=None, style="%", validate=True) -> None:
|
||||
super().__init__(fmt, datefmt, style, validate)
|
||||
self._additional_log_standard_attrs = []
|
||||
|
||||
def set_additional_log_standard_attrs(
|
||||
self, additional_log_standard_attrs: List[str]
|
||||
) -> None:
|
||||
self._additional_log_standard_attrs = additional_log_standard_attrs
|
||||
|
||||
@property
|
||||
def additional_log_standard_attrs(self) -> List[str]:
|
||||
return self._additional_log_standard_attrs
|
||||
|
||||
def generate_record_format_attrs(
|
||||
self,
|
||||
record: logging.LogRecord,
|
||||
exclude_default_standard_attrs,
|
||||
) -> dict:
|
||||
record_format_attrs = {}
|
||||
|
||||
# If `exclude_default_standard_attrs` is False, include the standard attributes.
|
||||
# Otherwise, include only Ray and user-provided context.
|
||||
if not exclude_default_standard_attrs:
|
||||
record_format_attrs.update(
|
||||
{
|
||||
LogKey.ASCTIME.value: self.formatTime(record),
|
||||
LogKey.LEVELNAME.value: record.levelname,
|
||||
LogKey.MESSAGE.value: record.getMessage(),
|
||||
LogKey.FILENAME.value: record.filename,
|
||||
LogKey.LINENO.value: record.lineno,
|
||||
LogKey.PROCESS.value: record.process,
|
||||
}
|
||||
)
|
||||
if record.exc_info:
|
||||
if not record.exc_text:
|
||||
record.exc_text = self.formatException(record.exc_info)
|
||||
record_format_attrs[LogKey.EXC_TEXT.value] = record.exc_text
|
||||
|
||||
# Add the user specified additional standard attributes.
|
||||
for key in self._additional_log_standard_attrs:
|
||||
_append_flatten_attributes(
|
||||
record_format_attrs, key, getattr(record, key, None)
|
||||
)
|
||||
|
||||
for key, value in record.__dict__.items():
|
||||
# Both Ray and user-provided context are stored in `record_format`.
|
||||
if key not in LOGRECORD_STANDARD_ATTRS:
|
||||
_append_flatten_attributes(record_format_attrs, key, value)
|
||||
|
||||
# Format the internal timestamp to the standardized `timestamp_ns` key.
|
||||
if INTERNAL_TIMESTAMP_LOG_KEY in record_format_attrs:
|
||||
record_format_attrs[LogKey.TIMESTAMP_NS.value] = record_format_attrs.pop(
|
||||
INTERNAL_TIMESTAMP_LOG_KEY
|
||||
)
|
||||
|
||||
return record_format_attrs
|
||||
|
||||
@abstractmethod
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class JSONFormatter(AbstractFormatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
record_format_attrs = self.generate_record_format_attrs(
|
||||
record, exclude_default_standard_attrs=False
|
||||
)
|
||||
return json.dumps(record_format_attrs)
|
||||
|
||||
|
||||
class TextFormatter(AbstractFormatter):
|
||||
def __init__(self, fmt=None, datefmt=None, style="%", validate=True) -> None:
|
||||
super().__init__(fmt, datefmt, style, validate)
|
||||
self._inner_formatter = logging.Formatter(LOGGER_FORMAT)
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
s = self._inner_formatter.format(record)
|
||||
record_format_attrs = self.generate_record_format_attrs(
|
||||
record, exclude_default_standard_attrs=True
|
||||
)
|
||||
|
||||
additional_attrs = " ".join(
|
||||
[f"{key}={value}" for key, value in record_format_attrs.items()]
|
||||
)
|
||||
return f"{s} {additional_attrs}"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Logging-related constants shared across Ray libraries.
|
||||
|
||||
Used to distinguish standard Python logging attributes from Ray or user context.
|
||||
See: https://docs.python.org/3/library/logging.html#logrecord-attributes
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
LOGRECORD_STANDARD_ATTRS = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"message",
|
||||
"module",
|
||||
"msecs",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
)
|
||||
|
||||
LOGGER_FLATTEN_KEYS = {
|
||||
"ray_serve_extra_fields",
|
||||
}
|
||||
|
||||
|
||||
class LogKey(str, Enum):
|
||||
# Core context
|
||||
JOB_ID = "job_id"
|
||||
WORKER_ID = "worker_id"
|
||||
NODE_ID = "node_id"
|
||||
ACTOR_ID = "actor_id"
|
||||
TASK_ID = "task_id"
|
||||
ACTOR_NAME = "actor_name"
|
||||
TASK_NAME = "task_name"
|
||||
TASK_FUNCTION_NAME = "task_func_name"
|
||||
|
||||
# Logger built-in context
|
||||
ASCTIME = "asctime"
|
||||
LEVELNAME = "levelname"
|
||||
MESSAGE = "message"
|
||||
FILENAME = "filename"
|
||||
LINENO = "lineno"
|
||||
EXC_TEXT = "exc_text"
|
||||
PROCESS = "process"
|
||||
|
||||
# Ray logging context
|
||||
TIMESTAMP_NS = "timestamp_ns"
|
||||
@@ -0,0 +1,27 @@
|
||||
import socket
|
||||
from contextlib import closing
|
||||
|
||||
from ray._raylet import ( # noqa: F401
|
||||
build_address,
|
||||
get_all_interfaces_ip,
|
||||
get_localhost_ip,
|
||||
is_ipv6,
|
||||
is_localhost,
|
||||
node_ip_address_from_perspective,
|
||||
parse_address,
|
||||
)
|
||||
|
||||
|
||||
def find_free_port(family: socket.AddressFamily = socket.AF_INET) -> int:
|
||||
"""Find a free port on the local machine.
|
||||
|
||||
Args:
|
||||
family: The socket address family (AF_INET for IPv4, AF_INET6 for IPv6).
|
||||
Defaults to AF_INET.
|
||||
|
||||
Returns:
|
||||
An available port number.
|
||||
"""
|
||||
with closing(socket.socket(family, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""This module provides the Python API for emitting internal Ray events
|
||||
via the ONE-Event framework. Events are buffered and exported through
|
||||
the C++ RayEventRecorder.
|
||||
"""
|
||||
|
||||
from ray._common.observability.internal_event import InternalEventBuilder
|
||||
from ray._common.observability.platform_events import PlatformEventBuilder
|
||||
|
||||
__all__ = [
|
||||
"InternalEventBuilder",
|
||||
"PlatformEventBuilder",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Base class for building internal Ray events.
|
||||
|
||||
This module provides the base class for creating event builders that can
|
||||
emit events to dashboard-agents aggregator agent service.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from ray._raylet import RayEvent
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent as RayEventProto
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class InternalEventBuilder(ABC):
|
||||
"""Abstract base class for building internal Ray events.
|
||||
|
||||
Subclasses implement specific event types
|
||||
and must implement get_entity_id() and serialize_event_data().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_type: int,
|
||||
event_type: int,
|
||||
nested_event_field_number: int,
|
||||
severity: int = RayEventProto.Severity.INFO,
|
||||
message: str = "",
|
||||
session_name: str = "",
|
||||
):
|
||||
"""Initialize the event builder.
|
||||
|
||||
Args:
|
||||
source_type: RayEvent.SourceType enum value. Use
|
||||
RayEventProto.SourceType.<NAME> constants (e.g.,
|
||||
RayEventProto.SourceType.JOBS,
|
||||
RayEventProto.SourceType.GCS).
|
||||
event_type: RayEvent.EventType enum value. Use
|
||||
RayEventProto.EventType.<NAME> constants (e.g.,
|
||||
RayEventProto.EventType.SUBMISSION_JOB_DEFINITION_EVENT,
|
||||
RayEventProto.EventType.DRIVER_JOB_LIFECYCLE_EVENT).
|
||||
nested_event_field_number: The field number in RayEvent proto for the
|
||||
nested event message. Use RayEventProto.<FIELD>_FIELD_NUMBER
|
||||
constants (e.g.,
|
||||
RayEventProto.SUBMISSION_JOB_DEFINITION_EVENT_FIELD_NUMBER).
|
||||
severity: RayEvent.Severity enum value (default INFO).
|
||||
message: Optional message associated with the event.
|
||||
session_name: The Ray session name.
|
||||
"""
|
||||
self._source_type = source_type
|
||||
self._event_type = event_type
|
||||
self._nested_event_field_number = nested_event_field_number
|
||||
self._severity = severity
|
||||
self._message = message
|
||||
self._session_name = session_name
|
||||
|
||||
@abstractmethod
|
||||
def get_entity_id(self) -> str:
|
||||
"""Return the unique entity ID for this event.
|
||||
|
||||
The entity ID is used to associate related events (e.g., definition
|
||||
and lifecycle events for the same job).
|
||||
|
||||
Returns:
|
||||
A string identifier unique to this entity (e.g., node_id/task_id/actor_id/job_id).
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def serialize_event_data(self) -> bytes:
|
||||
"""Serialize the event-specific protobuf data to bytes.
|
||||
|
||||
Returns:
|
||||
Serialized protobuf bytes of the nested event message
|
||||
(e.g., SubmissionJobDefinitionEvent.SerializeToString()).
|
||||
"""
|
||||
pass
|
||||
|
||||
def build(
|
||||
self,
|
||||
event_id: Optional[bytes] = None,
|
||||
timestamp_ns: Optional[int] = None,
|
||||
) -> RayEvent:
|
||||
"""Build the Cython RayEvent object for submission.
|
||||
|
||||
Args:
|
||||
event_id: Optional explicit event id bytes. When omitted, the C++ layer
|
||||
generates a random id (matching the convention used by the other
|
||||
RayEventInterface subclasses). Provide an explicit value to reuse
|
||||
an id from an upstream source (e.g., a Kubernetes event uid).
|
||||
timestamp_ns: Optional explicit event timestamp in nanoseconds since the
|
||||
unix epoch. When omitted, the C++ layer captures the current time
|
||||
at construction. Provide an explicit value for platform events that
|
||||
carry their own source timestamp.
|
||||
|
||||
Returns:
|
||||
A RayEvent object.
|
||||
"""
|
||||
return RayEvent(
|
||||
source_type=self._source_type,
|
||||
event_type=self._event_type,
|
||||
severity=self._severity,
|
||||
entity_id=self.get_entity_id(),
|
||||
message=self._message,
|
||||
session_name=self._session_name,
|
||||
serialized_data=self.serialize_event_data(),
|
||||
nested_event_field_number=self._nested_event_field_number,
|
||||
event_id=event_id if event_id is not None else b"",
|
||||
timestamp_ns=int(timestamp_ns) if timestamp_ns is not None else 0,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ray._common.observability.internal_event import InternalEventBuilder
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent as RayEventProto
|
||||
from ray.core.generated.platform_event_pb2 import PlatformEvent, Source
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class PlatformEventBuilder(InternalEventBuilder):
|
||||
"""Builder for creating infrastructure PlatformEvents (e.g., from Kubernetes)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_uid: str,
|
||||
platform: int = Source.Platform.PLATFORM_UNSPECIFIED, # Source.Platform enum
|
||||
object_kind: str = "",
|
||||
object_name: str = "",
|
||||
reason: str = "",
|
||||
message: str = "",
|
||||
severity: int = RayEventProto.Severity.INFO,
|
||||
component: str = "",
|
||||
source_metadata: Optional[Dict[str, str]] = None,
|
||||
custom_fields: Optional[Dict[str, str]] = None,
|
||||
session_name: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
source_type=RayEventProto.SourceType.CLUSTER_LIFECYCLE,
|
||||
event_type=RayEventProto.EventType.PLATFORM_EVENT,
|
||||
nested_event_field_number=RayEventProto.PLATFORM_EVENT_FIELD_NUMBER,
|
||||
severity=severity,
|
||||
message=message,
|
||||
session_name=session_name,
|
||||
)
|
||||
self._event_uid = event_uid
|
||||
self._platform = platform
|
||||
self._object_kind = object_kind
|
||||
self._object_name = object_name
|
||||
self._reason = reason
|
||||
self._component = component
|
||||
self._source_metadata = source_metadata
|
||||
self._custom_fields = custom_fields
|
||||
|
||||
def get_entity_id(self) -> str:
|
||||
return self._event_uid
|
||||
|
||||
def serialize_event_data(self) -> bytes:
|
||||
source_proto = Source(
|
||||
platform=self._platform,
|
||||
component=self._component,
|
||||
)
|
||||
if self._source_metadata:
|
||||
for k, v in self._source_metadata.items():
|
||||
source_proto.metadata[k] = v
|
||||
|
||||
event = PlatformEvent(
|
||||
source=source_proto,
|
||||
object_kind=self._object_kind,
|
||||
object_name=self._object_name,
|
||||
message=self._message,
|
||||
reason=self._reason,
|
||||
)
|
||||
if self._custom_fields:
|
||||
for k, v in self._custom_fields.items():
|
||||
event.custom_fields[k] = v
|
||||
|
||||
return event.SerializeToString()
|
||||
@@ -0,0 +1,113 @@
|
||||
# ruff: noqa
|
||||
import packaging.version
|
||||
|
||||
# Pydantic is a dependency of `ray["default"]` but not the minimal installation,
|
||||
# so handle the case where it isn't installed.
|
||||
try:
|
||||
import pydantic
|
||||
|
||||
PYDANTIC_INSTALLED = True
|
||||
except ImportError:
|
||||
pydantic = None
|
||||
PYDANTIC_INSTALLED = False
|
||||
|
||||
|
||||
if not PYDANTIC_INSTALLED:
|
||||
IS_PYDANTIC_2 = False
|
||||
BaseModel = None
|
||||
Extra = None
|
||||
Field = None
|
||||
NonNegativeFloat = None
|
||||
NonNegativeInt = None
|
||||
PositiveFloat = None
|
||||
PositiveInt = None
|
||||
PrivateAttr = None
|
||||
StrictInt = None
|
||||
ValidationError = None
|
||||
root_validator = None
|
||||
validator = None
|
||||
|
||||
def is_subclass_of_base_model(obj):
|
||||
return False
|
||||
|
||||
elif not hasattr(pydantic, "__version__") or packaging.version.parse(
|
||||
pydantic.__version__
|
||||
) < packaging.version.parse("2.0"):
|
||||
raise ImportError(
|
||||
"Pydantic v1 is no longer supported in Ray. " "Please upgrade to `pydantic>=2`."
|
||||
)
|
||||
else:
|
||||
IS_PYDANTIC_2 = True
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Extra,
|
||||
Field,
|
||||
NonNegativeFloat,
|
||||
NonNegativeInt,
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
PrivateAttr,
|
||||
StrictInt,
|
||||
ValidationError,
|
||||
root_validator,
|
||||
validator,
|
||||
)
|
||||
|
||||
def is_subclass_of_base_model(obj):
|
||||
return issubclass(obj, BaseModel)
|
||||
|
||||
|
||||
def _iter_model_field_types():
|
||||
model_field_types = []
|
||||
|
||||
try:
|
||||
from pydantic.fields import ModelField as model_field_type
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
model_field_types.append(model_field_type)
|
||||
|
||||
try:
|
||||
from pydantic.v1.fields import ModelField as compat_model_field_type
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
if compat_model_field_type not in model_field_types:
|
||||
model_field_types.append(compat_model_field_type)
|
||||
|
||||
return model_field_types
|
||||
|
||||
|
||||
def register_pydantic_serializers(serialization_context):
|
||||
if not PYDANTIC_INSTALLED:
|
||||
return
|
||||
|
||||
# Pydantic's Cython validators are not serializable.
|
||||
# https://github.com/cloudpipe/cloudpickle/issues/408
|
||||
#
|
||||
# FastAPI can still surface Pydantic's v1 compatibility ModelField under
|
||||
# Pydantic v2, so we need to register serializers for both types until that
|
||||
# compatibility path is no longer used upstream.
|
||||
for model_field_type in _iter_model_field_types():
|
||||
serialization_context._register_cloudpickle_serializer(
|
||||
model_field_type,
|
||||
custom_serializer=lambda o: {
|
||||
"name": o.name,
|
||||
# outer_type_ is the original type for ModelFields,
|
||||
# while type_ can be updated later with the nested type
|
||||
# like int for List[int].
|
||||
"type_": o.outer_type_,
|
||||
"class_validators": o.class_validators,
|
||||
"model_config": o.model_config,
|
||||
"default": o.default,
|
||||
"default_factory": o.default_factory,
|
||||
"required": o.required,
|
||||
"alias": o.alias,
|
||||
"field_info": o.field_info,
|
||||
},
|
||||
custom_deserializer=(
|
||||
lambda kwargs, model_field_type=model_field_type: model_field_type(
|
||||
**kwargs
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Default max_concurrency option in @ray.remote for async actors.
|
||||
DEFAULT_MAX_CONCURRENCY_ASYNC = 1000
|
||||
|
||||
LOGGING_ROTATE_BYTES = 512 * 1024 * 1024 # 512MB.
|
||||
LOGGING_ROTATE_BACKUP_COUNT = 5 # 5 Backup files at max.
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Manage, parse and validate options for Ray tasks, actors and actor methods."""
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
import ray
|
||||
from ray._private import ray_constants
|
||||
from ray._private.label_utils import (
|
||||
validate_fallback_strategy,
|
||||
validate_label_selector,
|
||||
)
|
||||
from ray._private.utils import get_ray_doc_version
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
from ray.util.scheduling_strategies import (
|
||||
NodeAffinitySchedulingStrategy,
|
||||
NodeLabelSchedulingStrategy,
|
||||
PlacementGroupSchedulingStrategy,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Option:
|
||||
# Type constraint of an option.
|
||||
type_constraint: Optional[Union[type, Tuple[type]]] = None
|
||||
# Value constraint of an option.
|
||||
# The callable should return None if there is no error.
|
||||
# Otherwise, return the error message.
|
||||
value_constraint: Optional[Callable[[Any], Optional[str]]] = None
|
||||
# Default value.
|
||||
default_value: Any = None
|
||||
|
||||
def validate(self, keyword: str, value: Any):
|
||||
"""Validate the option."""
|
||||
if self.type_constraint is not None:
|
||||
if not isinstance(value, self.type_constraint):
|
||||
raise TypeError(
|
||||
f"The type of keyword '{keyword}' must be {self.type_constraint}, "
|
||||
f"but received type {type(value)}"
|
||||
)
|
||||
if self.value_constraint is not None:
|
||||
possible_error_message = self.value_constraint(value)
|
||||
if possible_error_message:
|
||||
raise ValueError(possible_error_message)
|
||||
|
||||
|
||||
def _counting_option(name: str, infinite: bool = True, default_value: Any = None):
|
||||
"""This is used for positive and discrete options.
|
||||
|
||||
Args:
|
||||
name: The name of the option keyword.
|
||||
infinite: If True, user could use -1 to represent infinity.
|
||||
default_value: The default value for this option.
|
||||
|
||||
Returns:
|
||||
An Option object.
|
||||
"""
|
||||
if infinite:
|
||||
return Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x >= -1)
|
||||
else f"The keyword '{name}' only accepts None, 0, -1"
|
||||
" or a positive integer, where -1 represents infinity.",
|
||||
default_value=default_value,
|
||||
)
|
||||
return Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x >= 0)
|
||||
else f"The keyword '{name}' only accepts None, 0 or a positive integer.",
|
||||
default_value=default_value,
|
||||
)
|
||||
|
||||
|
||||
def _validate_resource_quantity(name, quantity):
|
||||
if quantity < 0:
|
||||
return f"The quantity of resource {name} cannot be negative"
|
||||
if (
|
||||
isinstance(quantity, float)
|
||||
and quantity != 0.0
|
||||
and int(quantity * ray._raylet.RESOURCE_UNIT_SCALING) == 0
|
||||
):
|
||||
return (
|
||||
f"The precision of the fractional quantity of resource {name}"
|
||||
" cannot go beyond 0.0001"
|
||||
)
|
||||
resource_name = "GPU" if name == "num_gpus" else name
|
||||
if resource_name in ray._private.accelerators.get_all_accelerator_resource_names():
|
||||
(
|
||||
valid,
|
||||
error_message,
|
||||
) = ray._private.accelerators.get_accelerator_manager_for_resource(
|
||||
resource_name
|
||||
).validate_resource_request_quantity(
|
||||
quantity
|
||||
)
|
||||
if not valid:
|
||||
return error_message
|
||||
return None
|
||||
|
||||
|
||||
def _resource_option(name: str, default_value: Any = None):
|
||||
"""This is used for resource related options."""
|
||||
return Option(
|
||||
(float, int, type(None)),
|
||||
lambda x: None if (x is None) else _validate_resource_quantity(name, x),
|
||||
default_value=default_value,
|
||||
)
|
||||
|
||||
|
||||
def _validate_resources(resources: Optional[Dict[str, float]]) -> Optional[str]:
|
||||
if resources is None:
|
||||
return None
|
||||
|
||||
if "CPU" in resources or "GPU" in resources:
|
||||
return (
|
||||
"Use the 'num_cpus' and 'num_gpus' keyword instead of 'CPU' and 'GPU' "
|
||||
"in 'resources' keyword"
|
||||
)
|
||||
|
||||
for name, quantity in resources.items():
|
||||
possible_error_message = _validate_resource_quantity(name, quantity)
|
||||
if possible_error_message:
|
||||
return possible_error_message
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_common_options = {
|
||||
"label_selector": Option((dict, type(None)), lambda x: validate_label_selector(x)),
|
||||
"fallback_strategy": Option(
|
||||
(list, type(None)), lambda x: validate_fallback_strategy(x)
|
||||
),
|
||||
"accelerator_type": Option((str, type(None))),
|
||||
"memory": _resource_option("memory"),
|
||||
"name": Option((str, type(None))),
|
||||
"num_cpus": _resource_option("num_cpus"),
|
||||
"num_gpus": _resource_option("num_gpus"),
|
||||
"object_store_memory": _counting_option("object_store_memory", False),
|
||||
# TODO(suquark): "placement_group", "placement_group_bundle_index"
|
||||
# and "placement_group_capture_child_tasks" are deprecated,
|
||||
# use "scheduling_strategy" instead.
|
||||
"placement_group": Option(
|
||||
(type(None), str, PlacementGroup), default_value="default"
|
||||
),
|
||||
"placement_group_bundle_index": Option(int, default_value=-1),
|
||||
"placement_group_capture_child_tasks": Option((bool, type(None))),
|
||||
"resources": Option((dict, type(None)), lambda x: _validate_resources(x)),
|
||||
"runtime_env": Option((dict, type(None))),
|
||||
"scheduling_strategy": Option(
|
||||
(
|
||||
type(None),
|
||||
str,
|
||||
PlacementGroupSchedulingStrategy,
|
||||
NodeAffinitySchedulingStrategy,
|
||||
NodeLabelSchedulingStrategy,
|
||||
)
|
||||
),
|
||||
"enable_task_events": Option(bool, default_value=True),
|
||||
"_labels": Option((dict, type(None))),
|
||||
}
|
||||
|
||||
|
||||
def issubclass_safe(obj: Any, cls_: type) -> bool:
|
||||
try:
|
||||
return issubclass(obj, cls_)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
_task_only_options = {
|
||||
"max_calls": _counting_option("max_calls", False, default_value=0),
|
||||
# Normal tasks may be retried on failure this many times.
|
||||
# TODO(swang): Allow this to be set globally for an application.
|
||||
"max_retries": _counting_option(
|
||||
"max_retries", default_value=ray_constants.DEFAULT_TASK_MAX_RETRIES
|
||||
),
|
||||
# override "_common_options"
|
||||
"num_cpus": _resource_option("num_cpus", default_value=1),
|
||||
"num_returns": Option(
|
||||
(int, str, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x == "dynamic" or x == "streaming" or x >= 0)
|
||||
else "Default None. When None is passed, "
|
||||
"The default value is 1 for a task and actor task, and "
|
||||
"'streaming' for generator tasks and generator actor tasks. "
|
||||
"The keyword 'num_returns' only accepts None, "
|
||||
"a non-negative integer, "
|
||||
"'streaming' (for generators), or 'dynamic'. 'dynamic' flag "
|
||||
"will be deprecated in the future, and it is recommended to use "
|
||||
"'streaming' instead.",
|
||||
default_value=None,
|
||||
),
|
||||
"object_store_memory": Option( # override "_common_options"
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None)
|
||||
else "Setting 'object_store_memory' is not implemented for tasks",
|
||||
),
|
||||
"retry_exceptions": Option(
|
||||
(bool, list, tuple),
|
||||
lambda x: None
|
||||
if (
|
||||
isinstance(x, bool)
|
||||
or (
|
||||
isinstance(x, (list, tuple))
|
||||
and all(issubclass_safe(x_, Exception) for x_ in x)
|
||||
)
|
||||
)
|
||||
else "retry_exceptions must be either a boolean or a list of exceptions",
|
||||
default_value=False,
|
||||
),
|
||||
"_generator_backpressure_num_objects": Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if x != 0
|
||||
else (
|
||||
"_generator_backpressure_num_objects=0 is not allowed. "
|
||||
"Use a value > 0. If the value is equal to 1, the behavior "
|
||||
"is identical to Python generator (generator 1 object "
|
||||
"whenever `next` is called). Use -1 to disable this feature. "
|
||||
),
|
||||
),
|
||||
"_num_objects_per_yield": Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x > 0)
|
||||
else (
|
||||
"_num_objects_per_yield is a private streaming generator option "
|
||||
"that must be set to a positive integer."
|
||||
),
|
||||
default_value=1,
|
||||
),
|
||||
}
|
||||
|
||||
_actor_only_options = {
|
||||
"concurrency_groups": Option((list, dict, type(None))),
|
||||
"enable_tensor_transport": Option((bool, type(None)), default_value=None),
|
||||
"lifetime": Option(
|
||||
(str, type(None)),
|
||||
lambda x: None
|
||||
if x in (None, "detached", "non_detached")
|
||||
else "actor `lifetime` argument must be one of 'detached', "
|
||||
"'non_detached' and 'None'.",
|
||||
),
|
||||
"max_concurrency": _counting_option("max_concurrency", False),
|
||||
"max_restarts": _counting_option("max_restarts", default_value=0),
|
||||
"max_task_retries": _counting_option("max_task_retries", default_value=0),
|
||||
"max_pending_calls": _counting_option("max_pending_calls", default_value=-1),
|
||||
"namespace": Option((str, type(None))),
|
||||
"get_if_exists": Option(bool, default_value=False),
|
||||
"allow_out_of_order_execution": Option((bool, type(None))),
|
||||
# Actor-wide cap on the number of unconsumed streaming-generator
|
||||
# objects across all generator tasks running on the actor. Coexists
|
||||
# with the per-method `_generator_backpressure_num_objects`: both
|
||||
# apply, and the producer blocks on whichever is tighter. -1 (or
|
||||
# None / unset) disables the actor-wide cap.
|
||||
"_actor_generator_backpressure_num_objects": Option(
|
||||
(int, type(None)),
|
||||
lambda x: None
|
||||
if (x is None or x > 0 or x == -1)
|
||||
else (
|
||||
"_actor_generator_backpressure_num_objects must be > 0 to cap the "
|
||||
"actor's total unconsumed generator objects, or -1 to disable. "
|
||||
f"Got {x}."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
# Priority is important here because during dictionary update, same key with higher
|
||||
# priority overrides the same key with lower priority. We make use of priority
|
||||
# to set the correct default value for tasks / actors.
|
||||
|
||||
# priority: _common_options > _actor_only_options > _task_only_options
|
||||
valid_options: Dict[str, Option] = {
|
||||
**_task_only_options,
|
||||
**_actor_only_options,
|
||||
**_common_options,
|
||||
}
|
||||
# priority: _task_only_options > _common_options
|
||||
task_options: Dict[str, Option] = {**_common_options, **_task_only_options}
|
||||
# priority: _actor_only_options > _common_options
|
||||
actor_options: Dict[str, Option] = {**_common_options, **_actor_only_options}
|
||||
|
||||
remote_args_error_string = (
|
||||
"The @ray.remote decorator must be applied either with no arguments and no "
|
||||
"parentheses, for example '@ray.remote', or it must be applied using some of "
|
||||
f"the arguments in the list {list(valid_options.keys())}, for example "
|
||||
"'@ray.remote(num_returns=2, resources={\"CustomResource\": 1})'."
|
||||
)
|
||||
|
||||
|
||||
def _check_deprecate_placement_group(options: Dict[str, Any]):
|
||||
"""Check if deprecated placement group option exists."""
|
||||
placement_group = options.get("placement_group", "default")
|
||||
scheduling_strategy = options.get("scheduling_strategy")
|
||||
# TODO(suquark): @ray.remote(placement_group=None) is used in
|
||||
# "python/ray.data._internal/remote_fn.py" and many other places,
|
||||
# while "ray.data.read_api.read_datasource" set "scheduling_strategy=SPREAD".
|
||||
# This might be a bug, but it is also ok to allow them co-exist.
|
||||
if (placement_group not in ("default", None)) and (scheduling_strategy is not None):
|
||||
raise ValueError(
|
||||
"Placement groups should be specified via the "
|
||||
"scheduling_strategy option. "
|
||||
"The placement_group option is deprecated."
|
||||
)
|
||||
|
||||
|
||||
def _warn_if_using_deprecated_placement_group(
|
||||
options: Dict[str, Any], caller_stacklevel: int
|
||||
):
|
||||
placement_group = options["placement_group"]
|
||||
placement_group_bundle_index = options["placement_group_bundle_index"]
|
||||
placement_group_capture_child_tasks = options["placement_group_capture_child_tasks"]
|
||||
if placement_group != "default":
|
||||
warnings.warn(
|
||||
"placement_group parameter is deprecated. Use "
|
||||
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
|
||||
"instead, see the usage at "
|
||||
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
|
||||
DeprecationWarning,
|
||||
stacklevel=caller_stacklevel + 1,
|
||||
)
|
||||
if placement_group_bundle_index != -1:
|
||||
warnings.warn(
|
||||
"placement_group_bundle_index parameter is deprecated. Use "
|
||||
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
|
||||
"instead, see the usage at "
|
||||
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
|
||||
DeprecationWarning,
|
||||
stacklevel=caller_stacklevel + 1,
|
||||
)
|
||||
if placement_group_capture_child_tasks:
|
||||
warnings.warn(
|
||||
"placement_group_capture_child_tasks parameter is deprecated. Use "
|
||||
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
|
||||
"instead, see the usage at "
|
||||
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
|
||||
DeprecationWarning,
|
||||
stacklevel=caller_stacklevel + 1,
|
||||
)
|
||||
|
||||
|
||||
def validate_task_options(
|
||||
options: Dict[str, Any],
|
||||
in_options: bool,
|
||||
is_generator_callable: Optional[bool] = None,
|
||||
):
|
||||
"""Options check for Ray tasks.
|
||||
|
||||
Args:
|
||||
options: Options for Ray tasks.
|
||||
in_options: If True, we are checking the options under the context of
|
||||
".options()".
|
||||
is_generator_callable: Optional bool indicating whether the callable is a
|
||||
generator function. If provided and num_returns is 'streaming' or
|
||||
'dynamic', validates that the callable is a generator.
|
||||
"""
|
||||
for k, v in options.items():
|
||||
if k not in task_options:
|
||||
raise ValueError(
|
||||
f"Invalid option keyword {k} for remote functions. "
|
||||
f"Valid ones are {list(task_options)}."
|
||||
)
|
||||
task_options[k].validate(k, v)
|
||||
if in_options and "max_calls" in options:
|
||||
raise ValueError("Setting 'max_calls' is not supported in '.options()'.")
|
||||
_check_deprecate_placement_group(options)
|
||||
|
||||
if is_generator_callable is not None:
|
||||
num_returns = options.get("num_returns")
|
||||
if num_returns is not None:
|
||||
validate_num_returns(is_generator_callable, num_returns)
|
||||
|
||||
|
||||
def validate_actor_options(options: Dict[str, Any], in_options: bool):
|
||||
"""Options check for Ray actors.
|
||||
|
||||
Args:
|
||||
options: Options for Ray actors.
|
||||
in_options: If True, we are checking the options under the context of
|
||||
".options()".
|
||||
"""
|
||||
for k, v in options.items():
|
||||
if k not in actor_options:
|
||||
raise ValueError(
|
||||
f"Invalid option keyword {k} for actors. "
|
||||
f"Valid ones are {list(actor_options)}."
|
||||
)
|
||||
actor_options[k].validate(k, v)
|
||||
|
||||
if in_options and "concurrency_groups" in options:
|
||||
raise ValueError(
|
||||
"Setting 'concurrency_groups' is not supported in '.options()'."
|
||||
)
|
||||
|
||||
if options.get("get_if_exists") and not options.get("name"):
|
||||
raise ValueError("The actor name must be specified to use `get_if_exists`.")
|
||||
|
||||
if "object_store_memory" in options:
|
||||
warnings.warn(
|
||||
"Setting 'object_store_memory'"
|
||||
" for actors is deprecated since it doesn't actually"
|
||||
" reserve the required object store memory."
|
||||
f" Use object spilling that's enabled by default (https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/objects/object-spilling.html) " # noqa: E501
|
||||
"instead to bypass the object store memory size limitation.",
|
||||
DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
_check_deprecate_placement_group(options)
|
||||
|
||||
|
||||
def validate_num_returns(is_generator_callable: bool, num_returns: Any) -> None:
|
||||
"""Validate num_returns for @ray.remote and @ray.method decorators.
|
||||
|
||||
This function validates:
|
||||
1. If num_returns is an integer < 0, it should fail fast.
|
||||
2. If num_returns='streaming' or 'dynamic' is used with a non-generator
|
||||
function, it should fail fast.
|
||||
|
||||
Args:
|
||||
is_generator_callable: Whether the callable is a generator function or
|
||||
async generator function.
|
||||
num_returns: The num_returns value to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If num_returns < 0, or if num_returns is 'streaming' or 'dynamic'
|
||||
but the callable is not a generator function or async generator function.
|
||||
"""
|
||||
if num_returns is None:
|
||||
return
|
||||
|
||||
# Validate num_returns < 0
|
||||
if isinstance(num_returns, int) and num_returns < 0:
|
||||
raise ValueError(f"num_returns must be >= 0, but got {num_returns}.")
|
||||
|
||||
# Validate num_returns='streaming' or 'dynamic' for generator functions
|
||||
if num_returns in ("streaming", "dynamic") and not is_generator_callable:
|
||||
raise ValueError(
|
||||
f"num_returns='{num_returns}' can only be used with generator functions "
|
||||
f"(functions that use 'yield'). "
|
||||
f"The decorated function is not a generator function."
|
||||
)
|
||||
|
||||
|
||||
def update_options(
|
||||
original_options: Dict[str, Any], new_options: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Update original options with new options and return.
|
||||
The returned updated options contain shallow copy of original options.
|
||||
"""
|
||||
|
||||
return {**original_options, **new_options}
|
||||
@@ -0,0 +1,162 @@
|
||||
import functools
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Sequence
|
||||
from typing import Callable, Optional, TypeVar
|
||||
|
||||
try:
|
||||
from typing import ParamSpec
|
||||
except ImportError:
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
R = TypeVar("R")
|
||||
P = ParamSpec("P")
|
||||
|
||||
|
||||
def format_exception(exc: BaseException, include_cause: bool = False) -> str:
|
||||
"""Format ``exc`` as ``"ClassName: message"`` for substring/regex matching.
|
||||
|
||||
Uses `traceback.format_exception_only` so the class name is preserved
|
||||
and callers can match on either the class name or the message.
|
||||
|
||||
Args:
|
||||
exc: The exception to format.
|
||||
include_cause: If True and ``exc.__cause__`` is set (``raise X from Y``),
|
||||
append the cause exception after the base exception. This is useful when
|
||||
we want to match on an exception encountered in the UDF (e.g. ``RateLimitError``)
|
||||
which is wrapped in a ``UserCodeException`` by Ray Data.
|
||||
|
||||
Returns:
|
||||
A single-string representation of ``exc`` in the form
|
||||
``"ClassName: message"``. When ``include_cause`` is True and
|
||||
``exc.__cause__`` is set, the cause's formatted form is appended
|
||||
after a single space. See the example below.
|
||||
|
||||
Example:
|
||||
For a ``UserCodeException`` wrapping a ``RateLimitError``, calling ``format_exception(e, include_cause=True)``
|
||||
returns::
|
||||
|
||||
ray.exceptions.UserCodeException: UDF failed to process a data block. RateLimitError: Error code: 429 - rate limited
|
||||
"""
|
||||
s = "".join(traceback.format_exception_only(type(exc), exc)).rstrip("\n")
|
||||
if include_cause and exc.__cause__:
|
||||
cause = exc.__cause__
|
||||
s += " " + "".join(traceback.format_exception_only(type(cause), cause)).rstrip(
|
||||
"\n"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def matches_error(pattern: str, error_str: str) -> bool:
|
||||
"""True if ``pattern`` matches ``error_str`` as a substring or as a regex.
|
||||
|
||||
Substring is tried first so literal patterns are not interpreted as regex.
|
||||
Invalid regex patterns return False instead of raising.
|
||||
|
||||
Args:
|
||||
pattern: Pattern to match, tried first as a substring then as a regex.
|
||||
error_str: Formatted exception string.
|
||||
|
||||
Returns:
|
||||
True if ``pattern`` matches ``error_str`` as a substring or as a regex.
|
||||
"""
|
||||
if pattern in error_str:
|
||||
return True
|
||||
try:
|
||||
return bool(re.search(pattern, error_str))
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
def call_with_retry(
|
||||
f: Callable[P, R],
|
||||
description: str,
|
||||
match: Optional[Sequence[str]] = None,
|
||||
max_attempts: int = 10,
|
||||
max_backoff_s: int = 32,
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> R:
|
||||
"""Retry a function with exponential backoff.
|
||||
|
||||
Args:
|
||||
f: The function to retry.
|
||||
description: An imperative description of the function being retried. For
|
||||
example, "open the file".
|
||||
match: A sequence of patterns to match in the exception message. Each
|
||||
pattern is first checked as a substring, then as a regex. If
|
||||
``None``, any error is retried.
|
||||
max_attempts: The maximum number of attempts to retry.
|
||||
max_backoff_s: The maximum number of seconds to backoff.
|
||||
*args: Arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result of the function.
|
||||
"""
|
||||
# TODO: consider inverse match and matching exception type
|
||||
assert max_attempts >= 1, f"`max_attempts` must be positive. Got {max_attempts}."
|
||||
|
||||
for i in range(max_attempts):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
exception_str = format_exception(e)
|
||||
is_retryable = match is None or any(
|
||||
matches_error(pattern, exception_str) for pattern in match
|
||||
)
|
||||
if is_retryable and i + 1 < max_attempts:
|
||||
# Retry with binary exponential backoff with 20% random jitter.
|
||||
backoff = min(2**i, max_backoff_s) * (random.uniform(0.8, 1.2))
|
||||
logger.debug(
|
||||
f"Retrying {i+1} attempts to {description} after {backoff} seconds."
|
||||
)
|
||||
time.sleep(backoff)
|
||||
else:
|
||||
if is_retryable:
|
||||
logger.debug(
|
||||
f"Failed to {description} after {max_attempts} attempts. Raising."
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Did not find a match for {exception_str}. Raising after {i+1} attempts."
|
||||
)
|
||||
raise e from None
|
||||
|
||||
|
||||
def retry(
|
||||
description: str,
|
||||
match: Optional[Sequence[str]] = None,
|
||||
max_attempts: int = 10,
|
||||
max_backoff_s: int = 32,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Decorator-based version of call_with_retry.
|
||||
|
||||
Args:
|
||||
description: An imperative description of the function being retried. For
|
||||
example, "open the file".
|
||||
match: A sequence of patterns to match in the exception message. Each
|
||||
pattern is first checked as a substring, then as a regex. If
|
||||
``None``, any error is retried.
|
||||
max_attempts: The maximum number of attempts to retry.
|
||||
max_backoff_s: The maximum number of seconds to backoff.
|
||||
|
||||
Returns:
|
||||
A Callable that can be applied in a normal decorator fashion.
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@functools.wraps(func)
|
||||
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return call_with_retry(
|
||||
func, description, match, max_attempts, max_backoff_s, *args, **kwargs
|
||||
)
|
||||
|
||||
return inner
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,34 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
import ray._private.utils
|
||||
import ray.cloudpickle as pickle
|
||||
import ray.exceptions
|
||||
from ray._private import ray_constants
|
||||
from ray.util import inspect_serializability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ALLOW_OUT_OF_BAND_OBJECT_REF_SERIALIZATION = ray_constants.env_bool(
|
||||
"RAY_allow_out_of_band_object_ref_serialization", True
|
||||
)
|
||||
|
||||
|
||||
def pickle_dumps(obj: Any, error_msg: str):
|
||||
"""Wrap cloudpickle.dumps to provide better error message
|
||||
when the object is not serializable.
|
||||
"""
|
||||
try:
|
||||
return pickle.dumps(obj)
|
||||
except (TypeError, ray.exceptions.OufOfBandObjectRefSerializationException) as e:
|
||||
sio = io.StringIO()
|
||||
inspect_serializability(obj, print_file=sio)
|
||||
msg = f"{error_msg}:\n{sio.getvalue()}"
|
||||
if isinstance(e, TypeError):
|
||||
raise TypeError(msg) from e
|
||||
else:
|
||||
raise ray.exceptions.OufOfBandObjectRefSerializationException(msg)
|
||||
@@ -0,0 +1,163 @@
|
||||
import inspect
|
||||
import logging
|
||||
from inspect import Parameter
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from ray._private.inspect_util import is_cython
|
||||
|
||||
# Logger for this module. It should be configured at the entry point
|
||||
# into the program using Ray. Ray provides a default configuration at
|
||||
# entry/init points.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This dummy type is also defined in ArgumentsBuilder.java. Please keep it
|
||||
# synced.
|
||||
DUMMY_TYPE = b"__RAY_DUMMY__"
|
||||
|
||||
|
||||
def get_signature(func: Any) -> inspect.Signature:
|
||||
"""Get signature parameters.
|
||||
|
||||
Support Cython functions by grabbing relevant attributes from the Cython
|
||||
function and attaching to a no-op function. This is somewhat brittle, since
|
||||
inspect may change, but given that inspect is written to a PEP, we hope
|
||||
it is relatively stable. Future versions of Python may allow overloading
|
||||
the inspect 'isfunction' and 'ismethod' functions / create ABC for Python
|
||||
functions. Until then, it appears that Cython won't do anything about
|
||||
compatability with the inspect module.
|
||||
|
||||
Args:
|
||||
func: The function whose signature should be checked.
|
||||
|
||||
Returns:
|
||||
A function signature object, which includes the names of the keyword
|
||||
arguments as well as their default values.
|
||||
|
||||
Raises:
|
||||
TypeError: A type error if the signature is not supported
|
||||
"""
|
||||
# The first condition for Cython functions, the latter for Cython instance
|
||||
# methods
|
||||
if is_cython(func):
|
||||
attrs = ["__code__", "__annotations__", "__defaults__", "__kwdefaults__"]
|
||||
|
||||
if all(hasattr(func, attr) for attr in attrs):
|
||||
original_func = func
|
||||
|
||||
def func():
|
||||
return
|
||||
|
||||
for attr in attrs:
|
||||
setattr(func, attr, getattr(original_func, attr))
|
||||
else:
|
||||
raise TypeError(f"{func!r} is not a Python function we can process")
|
||||
|
||||
return inspect.signature(func)
|
||||
|
||||
|
||||
def extract_signature(func: Any, ignore_first: bool = False) -> List[Parameter]:
|
||||
"""Extract the function signature from the function.
|
||||
|
||||
Args:
|
||||
func: The function whose signature should be extracted.
|
||||
ignore_first: True if the first argument should be ignored. This should
|
||||
be used when func is a method of a class.
|
||||
|
||||
Returns:
|
||||
List of Parameter objects representing the function signature.
|
||||
"""
|
||||
signature_parameters = list(get_signature(func).parameters.values())
|
||||
|
||||
if ignore_first:
|
||||
if len(signature_parameters) == 0:
|
||||
raise ValueError(
|
||||
"Methods must take a 'self' argument, but the "
|
||||
f"method '{func.__name__}' does not have one."
|
||||
)
|
||||
signature_parameters = signature_parameters[1:]
|
||||
|
||||
return signature_parameters
|
||||
|
||||
|
||||
def validate_args(
|
||||
signature_parameters: List[Parameter], args: Tuple[Any, ...], kwargs: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Validates the arguments against the signature.
|
||||
|
||||
Args:
|
||||
signature_parameters: The list of Parameter objects
|
||||
representing the function signature, obtained from
|
||||
`extract_signature`.
|
||||
args: The positional arguments passed into the function.
|
||||
kwargs: The keyword arguments passed into the function.
|
||||
|
||||
Raises:
|
||||
TypeError: Raised if arguments do not fit in the function signature.
|
||||
"""
|
||||
reconstructed_signature = inspect.Signature(parameters=signature_parameters)
|
||||
try:
|
||||
reconstructed_signature.bind(*args, **kwargs)
|
||||
except TypeError as exc: # capture a friendlier stacktrace
|
||||
raise TypeError(str(exc)) from None
|
||||
|
||||
|
||||
def flatten_args(
|
||||
signature_parameters: List[Parameter], args: Tuple[Any, ...], kwargs: Dict[str, Any]
|
||||
) -> List[Any]:
|
||||
"""Validates the arguments against the signature and flattens them.
|
||||
|
||||
The flat list representation is a serializable format for arguments.
|
||||
Since the flatbuffer representation of function arguments is a list, we
|
||||
combine both keyword arguments and positional arguments. We represent
|
||||
this with two entries per argument value - [DUMMY_TYPE, x] for positional
|
||||
arguments and [KEY, VALUE] for keyword arguments. See the below example.
|
||||
See `recover_args` for logic restoring the flat list back to args/kwargs.
|
||||
|
||||
Args:
|
||||
signature_parameters: The list of Parameter objects
|
||||
representing the function signature, obtained from
|
||||
`extract_signature`.
|
||||
args: The positional arguments passed into the function.
|
||||
kwargs: The keyword arguments passed into the function.
|
||||
|
||||
Returns:
|
||||
List of args and kwargs. Non-keyword arguments are prefixed
|
||||
by internal enum DUMMY_TYPE.
|
||||
|
||||
Raises:
|
||||
TypeError: Raised if arguments do not fit in the function signature.
|
||||
"""
|
||||
validate_args(signature_parameters, args, kwargs)
|
||||
list_args = []
|
||||
for arg in args:
|
||||
list_args += [DUMMY_TYPE, arg]
|
||||
|
||||
for keyword, arg in kwargs.items():
|
||||
list_args += [keyword, arg]
|
||||
return list_args
|
||||
|
||||
|
||||
def recover_args(flattened_args: List[Any]) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
"""Recreates `args` and `kwargs` from the flattened arg list.
|
||||
|
||||
Args:
|
||||
flattened_args: List of args and kwargs. This should be the output of
|
||||
`flatten_args`.
|
||||
|
||||
Returns:
|
||||
args: The non-keyword arguments passed into the function.
|
||||
kwargs: The keyword arguments passed into the function.
|
||||
"""
|
||||
assert (
|
||||
len(flattened_args) % 2 == 0
|
||||
), "Flattened arguments need to be even-numbered. See `flatten_args`."
|
||||
args = []
|
||||
kwargs = {}
|
||||
for name_index in range(0, len(flattened_args), 2):
|
||||
name, arg = flattened_args[name_index], flattened_args[name_index + 1]
|
||||
if name == DUMMY_TYPE:
|
||||
args.append(arg)
|
||||
else:
|
||||
kwargs[name] = arg
|
||||
|
||||
return args, kwargs
|
||||
@@ -0,0 +1,530 @@
|
||||
"""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
|
||||
@@ -0,0 +1,53 @@
|
||||
load("@rules_python//python:defs.bzl", "py_library")
|
||||
load("//bazel:python.bzl", "py_test_module_list")
|
||||
|
||||
py_library(
|
||||
name = "conftest",
|
||||
srcs = glob(["**/conftest.py"]),
|
||||
visibility = [
|
||||
"//python/ray/_common/tests:__subpackages__",
|
||||
],
|
||||
deps = ["//python/ray/tests:conftest"],
|
||||
)
|
||||
|
||||
# Small tests.
|
||||
py_test_module_list(
|
||||
size = "small",
|
||||
files = [
|
||||
"test_deprecation.py",
|
||||
"test_filters.py",
|
||||
"test_formatters.py",
|
||||
"test_logging_constants.py",
|
||||
"test_network_utils.py",
|
||||
"test_ray_option_utils.py",
|
||||
"test_retry.py",
|
||||
"test_signal_semaphore_utils.py",
|
||||
"test_signature.py",
|
||||
"test_tls_utils.py",
|
||||
"test_utils.py",
|
||||
"test_wait_for_condition.py",
|
||||
],
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:core",
|
||||
],
|
||||
deps = [
|
||||
":conftest",
|
||||
"//:ray_lib",
|
||||
],
|
||||
)
|
||||
|
||||
py_test_module_list(
|
||||
size = "large",
|
||||
files = [
|
||||
"test_usage_stats.py",
|
||||
],
|
||||
tags = [
|
||||
"exclusive",
|
||||
"team:core",
|
||||
],
|
||||
deps = [
|
||||
":conftest",
|
||||
"//:ray_lib",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
# Imports for filters and formatters tests
|
||||
pytest_plugins = ["ray.tests.conftest"]
|
||||
@@ -0,0 +1,97 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.deprecation import (
|
||||
DEPRECATED_VALUE,
|
||||
Deprecated,
|
||||
deprecation_warning,
|
||||
)
|
||||
|
||||
|
||||
def test_deprecation_warning_warn():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning:
|
||||
deprecation_warning("old_feature", "new_feature")
|
||||
|
||||
mock_warning.assert_called_once()
|
||||
args, _ = mock_warning.call_args
|
||||
assert (
|
||||
"DeprecationWarning: `old_feature` has been deprecated. Use `new_feature` instead."
|
||||
in args[0]
|
||||
)
|
||||
|
||||
|
||||
def test_deprecation_warning_error():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
deprecation_warning("old_feature", error=True)
|
||||
assert "`old_feature` has been deprecated." in str(excinfo.value)
|
||||
|
||||
|
||||
def test_deprecated_decorator_function():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
|
||||
"ray._common.deprecation.log_once"
|
||||
) as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
@Deprecated(old="old_func", new="new_func", error=False)
|
||||
def old_func():
|
||||
return "result"
|
||||
|
||||
result = old_func()
|
||||
assert result == "result"
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_deprecated_decorator_class():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
|
||||
"ray._common.deprecation.log_once"
|
||||
) as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
@Deprecated(old="OldClass", new="NewClass", error=False)
|
||||
class OldClass:
|
||||
pass
|
||||
|
||||
instance = OldClass()
|
||||
assert isinstance(instance, OldClass)
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_deprecated_decorator_method():
|
||||
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
|
||||
"ray._common.deprecation.log_once"
|
||||
) as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
class MyClass:
|
||||
@Deprecated(old="old_method", new="new_method", error=False)
|
||||
def old_method(self):
|
||||
return "method_result"
|
||||
|
||||
instance = MyClass()
|
||||
result = instance.old_method()
|
||||
assert result == "method_result"
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_deprecated_decorator_error():
|
||||
with patch("ray._common.deprecation.log_once") as mock_log_once:
|
||||
mock_log_once.return_value = True
|
||||
|
||||
@Deprecated(old="old_func", error=True)
|
||||
def old_func():
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
old_func()
|
||||
|
||||
|
||||
def test_deprecated_value_constant():
|
||||
assert (
|
||||
DEPRECATED_VALUE == -1
|
||||
), f"DEPRECATED_VALUE should be -1, but got {DEPRECATED_VALUE}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,160 @@
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.filters import CoreContextFilter
|
||||
|
||||
|
||||
class TestCoreContextFilter:
|
||||
def test_driver_process(self, shutdown_only):
|
||||
log_context = ["job_id", "worker_id", "node_id"]
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
# Ray is not initialized so no context except PID which should be available
|
||||
for attr in log_context:
|
||||
assert not hasattr(record, attr)
|
||||
# PID should be available even when Ray is not initialized
|
||||
assert hasattr(record, "process")
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
ray.init()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in log_context:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
# This is not a worker process, so actor_id and task_id should not exist.
|
||||
for attr in ["actor_id", "task_id"]:
|
||||
assert not hasattr(record, attr)
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
def test_task_process(self, shutdown_only):
|
||||
@ray.remote
|
||||
def f():
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
should_exist = ["job_id", "worker_id", "node_id", "task_id", "process"]
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"task_id": runtime_context.get_task_id(),
|
||||
"task_name": runtime_context.get_task_name(),
|
||||
"task_func_name": runtime_context.get_task_function_name(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in should_exist:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
assert not hasattr(record, "actor_id")
|
||||
assert not hasattr(record, "actor_name")
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
obj_ref = f.remote()
|
||||
ray.get(obj_ref)
|
||||
|
||||
def test_actor_process(self, shutdown_only):
|
||||
@ray.remote
|
||||
class A:
|
||||
def f(self):
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
assert filter.filter(record)
|
||||
should_exist = [
|
||||
"job_id",
|
||||
"worker_id",
|
||||
"node_id",
|
||||
"actor_id",
|
||||
"task_id",
|
||||
"process",
|
||||
]
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"actor_id": runtime_context.get_actor_id(),
|
||||
"actor_name": runtime_context.get_actor_name(),
|
||||
"task_id": runtime_context.get_task_id(),
|
||||
"task_name": runtime_context.get_task_name(),
|
||||
"task_func_name": runtime_context.get_task_function_name(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in should_exist:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
# Record should not have the attribute with a value of an empty string.
|
||||
assert runtime_context.get_actor_name() == ""
|
||||
assert not hasattr(record, "actor_name")
|
||||
|
||||
actor = A.remote()
|
||||
ray.get(actor.f.remote())
|
||||
|
||||
def test_actor_process_with_thread(self, shutdown_only):
|
||||
@ray.remote
|
||||
class MockedRayDataWorker:
|
||||
def _check_log_record_in_thread(self):
|
||||
filter = CoreContextFilter()
|
||||
record = logging.makeLogRecord({})
|
||||
|
||||
assert filter.filter(record)
|
||||
should_exist = [
|
||||
"job_id",
|
||||
"worker_id",
|
||||
"node_id",
|
||||
"actor_id",
|
||||
"task_id",
|
||||
"process",
|
||||
]
|
||||
runtime_context = ray.get_runtime_context()
|
||||
expected_values = {
|
||||
"job_id": runtime_context.get_job_id(),
|
||||
"worker_id": runtime_context.get_worker_id(),
|
||||
"node_id": runtime_context.get_node_id(),
|
||||
"actor_id": runtime_context.get_actor_id(),
|
||||
"task_id": runtime_context.get_task_id(),
|
||||
"process": record.process,
|
||||
}
|
||||
for attr in should_exist:
|
||||
assert hasattr(record, attr)
|
||||
assert getattr(record, attr) == expected_values[attr]
|
||||
assert hasattr(record, "_ray_timestamp_ns")
|
||||
|
||||
# Record should not have the attribute with a value of an empty string.
|
||||
assert runtime_context.get_actor_name() == ""
|
||||
assert not hasattr(record, "actor_name")
|
||||
|
||||
assert runtime_context.get_task_name() == ""
|
||||
assert not hasattr(record, "task_name")
|
||||
|
||||
assert runtime_context.get_task_function_name() == ""
|
||||
assert not hasattr(record, "task_function_name")
|
||||
|
||||
return record
|
||||
|
||||
def map(self):
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self._check_log_record_in_thread).result()
|
||||
|
||||
actor = MockedRayDataWorker.remote()
|
||||
ray.get(actor.map.remote())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.formatters import JSONFormatter, TextFormatter
|
||||
|
||||
|
||||
class TestJSONFormatter:
|
||||
def test_empty_record(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({})
|
||||
formatted = formatter.format(record)
|
||||
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert len(record_dict) == len(should_exist)
|
||||
assert "exc_text" not in record_dict
|
||||
|
||||
def test_record_with_exception(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({})
|
||||
try:
|
||||
raise ValueError("test")
|
||||
except ValueError:
|
||||
record.exc_info = sys.exc_info()
|
||||
formatted = formatter.format(record)
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"exc_text",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert "Traceback (most recent call last):" in record_dict["exc_text"]
|
||||
assert len(record_dict) == len(should_exist)
|
||||
|
||||
def test_record_with_user_provided_context(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({"user": "ray"})
|
||||
formatted = formatter.format(record)
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"user",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert record_dict["user"] == "ray"
|
||||
assert len(record_dict) == len(should_exist)
|
||||
assert "exc_text" not in record_dict
|
||||
|
||||
def test_record_with_flatten_keys_invalid_value(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord({"ray_serve_extra_fields": "not_a_dict"})
|
||||
with pytest.raises(ValueError):
|
||||
formatter.format(record)
|
||||
|
||||
def test_record_with_flatten_keys_valid_dict(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
record = logging.makeLogRecord(
|
||||
{"ray_serve_extra_fields": {"key1": "value1", "key2": 2}}
|
||||
)
|
||||
formatted = formatter.format(record)
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"key1",
|
||||
"key2",
|
||||
"timestamp_ns",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert record_dict["key1"] == "value1", record_dict
|
||||
assert record_dict["key2"] == 2
|
||||
assert "ray_serve_extra_fields" not in record_dict
|
||||
assert len(record_dict) == len(should_exist)
|
||||
assert "exc_text" not in record_dict
|
||||
|
||||
def test_record_with_valid_additional_log_standard_attrs(self, shutdown_only):
|
||||
formatter = JSONFormatter()
|
||||
formatter.set_additional_log_standard_attrs(["name"])
|
||||
record = logging.makeLogRecord({})
|
||||
formatted = formatter.format(record)
|
||||
|
||||
record_dict = json.loads(formatted)
|
||||
should_exist = [
|
||||
"process",
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"filename",
|
||||
"lineno",
|
||||
"timestamp_ns",
|
||||
"name",
|
||||
]
|
||||
for key in should_exist:
|
||||
assert key in record_dict
|
||||
assert len(record_dict) == len(should_exist)
|
||||
|
||||
|
||||
class TestTextFormatter:
|
||||
def test_record_with_user_provided_context(self):
|
||||
formatter = TextFormatter()
|
||||
record = logging.makeLogRecord({"user": "ray"})
|
||||
formatted = formatter.format(record)
|
||||
assert "user=ray" in formatted
|
||||
|
||||
def test_record_with_exception(self):
|
||||
formatter = TextFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=1000,
|
||||
msg="Test message",
|
||||
args=None,
|
||||
exc_info=None,
|
||||
)
|
||||
formatted = formatter.format(record)
|
||||
for s in ["INFO", "Test message", "test.py:1000", "--"]:
|
||||
assert s in formatted
|
||||
|
||||
def test_record_with_valid_additional_log_standard_attrs(self, shutdown_only):
|
||||
formatter = TextFormatter()
|
||||
formatter.set_additional_log_standard_attrs(["name"])
|
||||
record = logging.makeLogRecord({})
|
||||
formatted = formatter.format(record)
|
||||
assert "name=" in formatted
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,83 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.logging_constants import (
|
||||
LOGGER_FLATTEN_KEYS,
|
||||
LOGRECORD_STANDARD_ATTRS,
|
||||
LogKey,
|
||||
)
|
||||
|
||||
|
||||
def test_logrecord_standard_attrs_is_frozenset():
|
||||
assert isinstance(LOGRECORD_STANDARD_ATTRS, frozenset)
|
||||
|
||||
|
||||
def test_logrecord_standard_attrs_contains_standard_names():
|
||||
expected = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"message",
|
||||
"module",
|
||||
"msecs",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
)
|
||||
assert LOGRECORD_STANDARD_ATTRS == expected
|
||||
|
||||
|
||||
def test_logrecord_standard_attrs_has_expected_size():
|
||||
assert len(LOGRECORD_STANDARD_ATTRS) == 23
|
||||
|
||||
|
||||
def test_logger_flatten_keys_is_set():
|
||||
assert isinstance(LOGGER_FLATTEN_KEYS, set)
|
||||
assert "ray_serve_extra_fields" in LOGGER_FLATTEN_KEYS
|
||||
|
||||
|
||||
def test_logkey_is_enum():
|
||||
from enum import Enum
|
||||
|
||||
assert issubclass(LogKey, Enum)
|
||||
expected = {
|
||||
"JOB_ID": "job_id",
|
||||
"WORKER_ID": "worker_id",
|
||||
"NODE_ID": "node_id",
|
||||
"ACTOR_ID": "actor_id",
|
||||
"TASK_ID": "task_id",
|
||||
"ACTOR_NAME": "actor_name",
|
||||
"TASK_NAME": "task_name",
|
||||
"TASK_FUNCTION_NAME": "task_func_name",
|
||||
"ASCTIME": "asctime",
|
||||
"LEVELNAME": "levelname",
|
||||
"MESSAGE": "message",
|
||||
"FILENAME": "filename",
|
||||
"LINENO": "lineno",
|
||||
"EXC_TEXT": "exc_text",
|
||||
"PROCESS": "process",
|
||||
"TIMESTAMP_NS": "timestamp_ns",
|
||||
}
|
||||
actual = {member.name: member.value for member in LogKey}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.network_utils import is_localhost
|
||||
|
||||
|
||||
def test_is_localhost():
|
||||
assert is_localhost("localhost")
|
||||
assert is_localhost("127.0.0.1")
|
||||
assert is_localhost("::1")
|
||||
assert not is_localhost("8.8.8.8")
|
||||
assert not is_localhost("2001:db8::1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,197 @@
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.ray_option_utils import (
|
||||
Option,
|
||||
_check_deprecate_placement_group,
|
||||
_counting_option,
|
||||
_resource_option,
|
||||
_validate_resource_quantity,
|
||||
_validate_resources,
|
||||
update_options,
|
||||
validate_actor_options,
|
||||
validate_task_options,
|
||||
)
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
|
||||
|
||||
class TestOptionValidation:
|
||||
def test_option_validate(self):
|
||||
opt = Option(
|
||||
type_constraint=int, value_constraint=lambda v: "error" if v < 0 else None
|
||||
)
|
||||
opt.validate("test", 1)
|
||||
with pytest.raises(TypeError):
|
||||
opt.validate("test", "a")
|
||||
with pytest.raises(ValueError, match="error"):
|
||||
opt.validate("test", -1)
|
||||
|
||||
def test_counting_option(self):
|
||||
# Test infinite counting option
|
||||
opt_inf = _counting_option("test_inf", infinite=True)
|
||||
opt_inf.validate("test_inf", 5)
|
||||
opt_inf.validate("test_inf", 0)
|
||||
opt_inf.validate("test_inf", -1) # Represents infinity
|
||||
opt_inf.validate("test_inf", None)
|
||||
with pytest.raises(ValueError):
|
||||
opt_inf.validate("test_inf", -2)
|
||||
with pytest.raises(TypeError):
|
||||
opt_inf.validate("test_inf", 1.5)
|
||||
|
||||
# Test non-infinite counting option
|
||||
opt_non_inf = _counting_option("test_non_inf", infinite=False)
|
||||
opt_non_inf.validate("test_non_inf", 5)
|
||||
opt_non_inf.validate("test_non_inf", 0)
|
||||
opt_non_inf.validate("test_non_inf", None)
|
||||
with pytest.raises(ValueError):
|
||||
opt_non_inf.validate("test_non_inf", -1)
|
||||
|
||||
@patch("ray._raylet.RESOURCE_UNIT_SCALING", 10000)
|
||||
@patch(
|
||||
"ray._private.accelerators.get_all_accelerator_resource_names",
|
||||
return_value={"GPU", "TPU"},
|
||||
)
|
||||
@patch("ray._private.accelerators.get_accelerator_manager_for_resource")
|
||||
def test_validate_resource_quantity(self, mock_get_manager, mock_get_all_names):
|
||||
# Valid cases
|
||||
assert _validate_resource_quantity("CPU", 1) is None
|
||||
assert _validate_resource_quantity("memory", 0) is None
|
||||
assert _validate_resource_quantity("custom", 0.5) is None
|
||||
|
||||
# Invalid cases
|
||||
err = _validate_resource_quantity("CPU", -1)
|
||||
assert isinstance(err, str)
|
||||
assert "cannot be negative" in err
|
||||
err = _validate_resource_quantity("CPU", 0.00001)
|
||||
assert isinstance(err, str)
|
||||
assert "cannot go beyond 0.0001" in err
|
||||
|
||||
# Accelerator validation
|
||||
mock_manager_instance = mock_get_manager.return_value
|
||||
mock_manager_instance.validate_resource_request_quantity.return_value = (
|
||||
False,
|
||||
"mock error",
|
||||
)
|
||||
err = _validate_resource_quantity("GPU", 1.5)
|
||||
assert isinstance(err, str)
|
||||
assert "mock error" in err
|
||||
mock_get_manager.assert_called_with("GPU")
|
||||
mock_manager_instance.validate_resource_request_quantity.assert_called_with(1.5)
|
||||
|
||||
mock_manager_instance.validate_resource_request_quantity.return_value = (
|
||||
True,
|
||||
"",
|
||||
)
|
||||
assert _validate_resource_quantity("TPU", 1) is None
|
||||
|
||||
def test_resource_option(self):
|
||||
opt = _resource_option("CPU")
|
||||
opt.validate("CPU", 1)
|
||||
opt.validate("CPU", 0.5)
|
||||
opt.validate("CPU", None)
|
||||
with pytest.raises(TypeError):
|
||||
opt.validate("CPU", "1")
|
||||
with pytest.raises(ValueError):
|
||||
opt.validate("CPU", -1.0)
|
||||
|
||||
def test_validate_resources(self):
|
||||
assert _validate_resources(None) is None
|
||||
assert _validate_resources({"custom": 1}) is None
|
||||
err = _validate_resources({"CPU": 1, "GPU": 1})
|
||||
assert isinstance(err, str)
|
||||
assert "Use the 'num_cpus' and 'num_gpus' keyword" in err
|
||||
err = _validate_resources({"custom": -1})
|
||||
assert isinstance(err, str)
|
||||
assert "cannot be negative" in err
|
||||
|
||||
|
||||
class TestTaskActorOptionValidation:
|
||||
def test_validate_task_options_valid(self):
|
||||
validate_task_options({"num_cpus": 2, "max_retries": 3}, in_options=False)
|
||||
|
||||
def test_validate_task_options_invalid_keyword(self):
|
||||
with pytest.raises(ValueError, match="Invalid option keyword"):
|
||||
validate_task_options({"invalid_option": 1}, in_options=False)
|
||||
|
||||
def test_validate_task_options_in_options_invalid(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("Setting 'max_calls' is not supported in '.options()'."),
|
||||
):
|
||||
validate_task_options({"max_calls": 5}, in_options=True)
|
||||
|
||||
def test_validate_actor_options_valid(self):
|
||||
validate_actor_options({"max_concurrency": 2, "name": "abc"}, in_options=False)
|
||||
|
||||
def test_validate_actor_options_invalid_keyword(self):
|
||||
with pytest.raises(ValueError, match="Invalid option keyword"):
|
||||
validate_actor_options({"invalid_option": 1}, in_options=False)
|
||||
|
||||
def test_validate_actor_options_in_options_invalid(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"Setting 'concurrency_groups' is not supported in '.options()'."
|
||||
),
|
||||
):
|
||||
validate_actor_options({"concurrency_groups": {}}, in_options=True)
|
||||
|
||||
def test_validate_actor_get_if_exists_no_name(self):
|
||||
with pytest.raises(
|
||||
ValueError, match="must be specified to use `get_if_exists`"
|
||||
):
|
||||
validate_actor_options({"get_if_exists": True}, in_options=False)
|
||||
|
||||
def test_validate_actor_object_store_memory_warning(self):
|
||||
with pytest.warns(
|
||||
DeprecationWarning,
|
||||
match="Setting 'object_store_memory' for actors is deprecated",
|
||||
):
|
||||
validate_actor_options({"object_store_memory": 100}, in_options=False)
|
||||
|
||||
def test_check_deprecate_placement_group(self):
|
||||
pg = PlacementGroup.empty()
|
||||
# No error if only one is specified
|
||||
_check_deprecate_placement_group({"placement_group": pg})
|
||||
_check_deprecate_placement_group({"scheduling_strategy": "SPREAD"})
|
||||
|
||||
# Error if both are specified
|
||||
with pytest.raises(
|
||||
ValueError, match="Placement groups should be specified via"
|
||||
):
|
||||
_check_deprecate_placement_group(
|
||||
{"placement_group": pg, "scheduling_strategy": "SPREAD"}
|
||||
)
|
||||
|
||||
# Check no error with default or None placement_group
|
||||
_check_deprecate_placement_group(
|
||||
{"placement_group": "default", "scheduling_strategy": "SPREAD"}
|
||||
)
|
||||
_check_deprecate_placement_group(
|
||||
{"placement_group": None, "scheduling_strategy": "SPREAD"}
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateOptions:
|
||||
def test_simple_update(self):
|
||||
original = {"num_cpus": 1, "name": "a"}
|
||||
new = {"num_cpus": 2, "num_gpus": 1}
|
||||
updated = update_options(original, new)
|
||||
assert updated == {"num_cpus": 2, "name": "a", "num_gpus": 1}
|
||||
|
||||
def test_update_with_empty_new(self):
|
||||
original = {"num_cpus": 1}
|
||||
updated = update_options(original, {})
|
||||
assert updated == original
|
||||
|
||||
def test_update_empty_original(self):
|
||||
new = {"num_cpus": 1}
|
||||
updated = update_options({}, new)
|
||||
assert updated == new
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,136 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.retry import (
|
||||
call_with_retry,
|
||||
retry,
|
||||
)
|
||||
|
||||
|
||||
def test_call_with_retry_immediate_success_with_args():
|
||||
def func(a, b):
|
||||
return [a, b]
|
||||
|
||||
assert call_with_retry(func, "func", [], 1, 0, "a", "b") == ["a", "b"]
|
||||
|
||||
|
||||
def test_retry_immediate_success_with_object_args():
|
||||
class MyClass:
|
||||
@retry("func", [], 1, 0)
|
||||
def func(self, a, b):
|
||||
return [a, b]
|
||||
|
||||
assert MyClass().func("a", "b") == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_decorator", [True, False])
|
||||
def test_retry_last_attempt_successful_with_appropriate_wait_time(
|
||||
monkeypatch, use_decorator
|
||||
):
|
||||
sleep_total = 0
|
||||
|
||||
def sleep(x):
|
||||
nonlocal sleep_total
|
||||
sleep_total += x
|
||||
|
||||
monkeypatch.setattr("time.sleep", sleep)
|
||||
monkeypatch.setattr("random.uniform", lambda a, b: 1)
|
||||
|
||||
pattern = "have not reached 4th attempt"
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 4:
|
||||
return "success"
|
||||
raise ValueError(pattern)
|
||||
|
||||
args = ["func", [pattern], 4, 3]
|
||||
if use_decorator:
|
||||
assert retry(*args)(func)() == "success"
|
||||
else:
|
||||
assert call_with_retry(func, *args) == "success"
|
||||
assert sleep_total == 6 # 1 + 2 + 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_decorator", [True, False])
|
||||
def test_retry_unretryable_error(use_decorator):
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("unretryable error")
|
||||
|
||||
args = ["func", ["only retryable error"], 10, 0]
|
||||
with pytest.raises(ValueError, match="unretryable error"):
|
||||
if use_decorator:
|
||||
retry(*args)(func)()
|
||||
else:
|
||||
call_with_retry(func, *args)
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_decorator", [True, False])
|
||||
def test_retry_fail_all_attempts_retry_all_errors(use_decorator):
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError(str(call_count))
|
||||
|
||||
args = ["func", None, 3, 0]
|
||||
with pytest.raises(ValueError):
|
||||
if use_decorator:
|
||||
retry(*args)(func)()
|
||||
else:
|
||||
call_with_retry(func, *args)
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
def test_call_with_retry_matches_class_name():
|
||||
"""Patterns can match the exception class name (e.g., 'RateLimit')."""
|
||||
|
||||
class RateLimitError(Exception):
|
||||
pass
|
||||
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise RateLimitError("Error code: 429")
|
||||
|
||||
with pytest.raises(RateLimitError):
|
||||
call_with_retry(func, "func", ["RateLimit"], 3, 0)
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pattern,should_retry",
|
||||
[
|
||||
# Valid regex that is not a literal substring, that matches via regex search
|
||||
(r"\d{3}", True),
|
||||
# Invalid regex, re.error is handled by returning False and the error is not retried.
|
||||
(r"[unclosed", False),
|
||||
],
|
||||
)
|
||||
def test_call_with_retry_regex_matching(pattern, should_retry):
|
||||
call_count = 0
|
||||
|
||||
def func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("Error code: 429")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
call_with_retry(func, "func", [pattern], 3, 0)
|
||||
|
||||
assert call_count == (3 if should_retry else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for Ray test utility classes.
|
||||
|
||||
This module contains pytest-based tests for SignalActor and Semaphore classes
|
||||
from ray._common.test_utils. These test utility classes are used for coordination
|
||||
and synchronization in Ray tests.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import Semaphore, SignalActor, wait_for_condition
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ray_init():
|
||||
"""Initialize Ray for the test module."""
|
||||
ray.init(num_cpus=4)
|
||||
yield
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
def test_signal_actor_basic(ray_init):
|
||||
"""Test basic SignalActor functionality - send and wait operations."""
|
||||
signal = SignalActor.remote()
|
||||
|
||||
# Test initial state
|
||||
assert ray.get(signal.cur_num_waiters.remote()) == 0
|
||||
|
||||
# Test send and wait
|
||||
ray.get(signal.send.remote())
|
||||
signal.wait.remote()
|
||||
assert ray.get(signal.cur_num_waiters.remote()) == 0
|
||||
|
||||
|
||||
def test_signal_actor_multiple_waiters(ray_init):
|
||||
"""Test SignalActor with multiple waiters and signal clearing."""
|
||||
signal = SignalActor.remote()
|
||||
|
||||
# Create multiple waiters
|
||||
for _ in range(3):
|
||||
signal.wait.remote()
|
||||
|
||||
# Check number of waiters
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 3)
|
||||
|
||||
# Send signal and wait for all waiters
|
||||
ray.get(signal.send.remote())
|
||||
|
||||
# Verify all waiters are done
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 0)
|
||||
|
||||
# check that .wait() doesn't block if the signal is already sent
|
||||
ray.get(signal.wait.remote())
|
||||
|
||||
assert ray.get(signal.cur_num_waiters.remote()) == 0
|
||||
|
||||
# clear the signal
|
||||
ray.get(signal.send.remote(clear=True))
|
||||
signal.wait.remote()
|
||||
# Verify all waiters are done
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
|
||||
|
||||
ray.get(signal.send.remote())
|
||||
|
||||
|
||||
def test_semaphore_basic(ray_init):
|
||||
"""Test basic Semaphore functionality - acquire, release, and lock status."""
|
||||
sema = Semaphore.remote(value=2)
|
||||
|
||||
# Test initial state
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
|
||||
|
||||
# Test acquire and release
|
||||
ray.get(sema.acquire.remote())
|
||||
ray.get(sema.acquire.remote())
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is True)
|
||||
|
||||
ray.get(sema.release.remote())
|
||||
ray.get(sema.release.remote())
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
|
||||
|
||||
|
||||
def test_semaphore_concurrent(ray_init):
|
||||
"""Test Semaphore with concurrent workers to verify resource limiting."""
|
||||
sema = Semaphore.remote(value=2)
|
||||
|
||||
def worker():
|
||||
ray.get(sema.acquire.remote())
|
||||
time.sleep(0.1)
|
||||
ray.get(sema.release.remote())
|
||||
|
||||
# Create multiple workers
|
||||
_ = [worker() for _ in range(4)]
|
||||
|
||||
# Verify semaphore is not locked
|
||||
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,515 @@
|
||||
"""Tests for Ray signature utility functions.
|
||||
|
||||
This module contains pytest-based tests for signature-related functions in
|
||||
ray._common.signature. These functions are used for extracting, validating,
|
||||
and flattening function signatures for serialization.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.signature import (
|
||||
DUMMY_TYPE,
|
||||
extract_signature,
|
||||
flatten_args,
|
||||
get_signature,
|
||||
recover_args,
|
||||
validate_args,
|
||||
)
|
||||
|
||||
|
||||
class TestGetSignature:
|
||||
"""Tests for the get_signature utility function."""
|
||||
|
||||
def test_regular_function(self):
|
||||
"""Test getting signature from a regular Python function."""
|
||||
|
||||
def test_func(a, b=10, *args, **kwargs):
|
||||
return a + b
|
||||
|
||||
sig = get_signature(test_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 4
|
||||
assert "a" in sig.parameters
|
||||
assert "b" in sig.parameters
|
||||
assert sig.parameters["b"].default == 10
|
||||
|
||||
def test_function_with_annotations(self):
|
||||
"""Test getting signature from a function with type annotations."""
|
||||
|
||||
def test_func(a: int, b: str = "default") -> str:
|
||||
return f"{a}{b}"
|
||||
|
||||
sig = get_signature(test_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 2
|
||||
assert sig.parameters["a"].annotation is int
|
||||
assert sig.parameters["b"].annotation is str
|
||||
assert sig.parameters["b"].default == "default"
|
||||
|
||||
def test_function_no_parameters(self):
|
||||
"""Test getting signature from a function with no parameters."""
|
||||
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
sig = get_signature(test_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 0
|
||||
|
||||
def test_lambda_function(self):
|
||||
"""Test getting signature from a lambda function."""
|
||||
sig = get_signature(lambda x, y=5: x + y)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 2 # x, y
|
||||
assert sig.parameters["y"].default == 5
|
||||
|
||||
@patch("ray._common.signature.is_cython")
|
||||
def test_cython_function_with_attributes(self, mock_is_cython):
|
||||
"""Test getting signature from a Cython function with required attributes."""
|
||||
mock_is_cython.return_value = True
|
||||
|
||||
def original_func(x=10):
|
||||
return x
|
||||
|
||||
mock_func = Mock()
|
||||
mock_func.__code__ = original_func.__code__
|
||||
mock_func.__annotations__ = original_func.__annotations__
|
||||
mock_func.__defaults__ = original_func.__defaults__
|
||||
mock_func.__kwdefaults__ = original_func.__kwdefaults__
|
||||
|
||||
sig = get_signature(mock_func)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 1
|
||||
assert "x" in sig.parameters
|
||||
|
||||
@patch("ray._common.signature.is_cython")
|
||||
def test_cython_function_missing_attributes(self, mock_is_cython):
|
||||
"""Test error handling for Cython function missing required attributes."""
|
||||
mock_is_cython.return_value = True
|
||||
|
||||
# Create a mock Cython function missing required attributes
|
||||
mock_func = Mock()
|
||||
del mock_func.__code__ # Remove required attribute
|
||||
|
||||
with pytest.raises(TypeError, match="is not a Python function we can process"):
|
||||
get_signature(mock_func)
|
||||
|
||||
def test_method_signature(self):
|
||||
"""Test getting signature from a class method."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self, a, b=20):
|
||||
return a + b
|
||||
|
||||
sig = get_signature(TestClass.test_method)
|
||||
assert sig is not None
|
||||
assert len(sig.parameters) == 3 # self, a, b
|
||||
assert "self" in sig.parameters
|
||||
assert "a" in sig.parameters
|
||||
assert "b" in sig.parameters
|
||||
assert sig.parameters["b"].default == 20
|
||||
|
||||
|
||||
class TestExtractSignature:
|
||||
"""Tests for the extract_signature utility function."""
|
||||
|
||||
def test_function_without_ignore_first(self):
|
||||
"""Test extracting signature from function without ignoring first parameter."""
|
||||
|
||||
def test_func(a, b=10, c=None):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func, ignore_first=False)
|
||||
assert len(params) == 3
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
assert params[1].default == 10
|
||||
assert params[2].name == "c"
|
||||
assert params[2].default is None
|
||||
|
||||
def test_method_with_ignore_first(self):
|
||||
"""Test extracting signature from method ignoring 'self' parameter."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self, a, b=20):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(TestClass.test_method, ignore_first=True)
|
||||
assert len(params) == 2
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
assert params[1].default == 20
|
||||
|
||||
def test_function_with_ignore_first(self):
|
||||
"""Test extracting signature from regular function with ignore_first=True."""
|
||||
|
||||
def test_func(x, y, z=30):
|
||||
return x + y + z
|
||||
|
||||
params = extract_signature(test_func, ignore_first=True)
|
||||
assert len(params) == 2
|
||||
assert params[0].name == "y"
|
||||
assert params[1].name == "z"
|
||||
assert params[1].default == 30
|
||||
|
||||
def test_empty_parameters_with_ignore_first(self):
|
||||
"""Test error handling when method has no parameters but ignore_first=True."""
|
||||
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
with pytest.raises(ValueError, match="Methods must take a 'self' argument"):
|
||||
extract_signature(test_func, ignore_first=True)
|
||||
|
||||
def test_single_parameter_with_ignore_first(self):
|
||||
"""Test extracting signature from method with only 'self' parameter."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self):
|
||||
return "hello"
|
||||
|
||||
params = extract_signature(TestClass.test_method, ignore_first=True)
|
||||
assert len(params) == 0
|
||||
|
||||
def test_varargs_and_kwargs(self):
|
||||
"""Test extracting signature with *args and **kwargs."""
|
||||
|
||||
def test_func(a, b=10, *args, **kwargs):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func, ignore_first=False)
|
||||
assert len(params) == 4
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
assert params[2].name == "args"
|
||||
assert params[2].kind == inspect.Parameter.VAR_POSITIONAL
|
||||
assert params[3].name == "kwargs"
|
||||
assert params[3].kind == inspect.Parameter.VAR_KEYWORD
|
||||
|
||||
|
||||
class TestValidateArgs:
|
||||
"""Tests for the validate_args utility function."""
|
||||
|
||||
def test_valid_positional_args(self):
|
||||
"""Test validation with valid positional arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1, 2), {})
|
||||
validate_args(params, (1, 2, 3), {})
|
||||
|
||||
def test_valid_keyword_args(self):
|
||||
"""Test validation with valid keyword arguments."""
|
||||
|
||||
def test_func(a, b=20, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1,), {"b": 2})
|
||||
validate_args(params, (1,), {"b": 2, "c": 3})
|
||||
validate_args(params, (), {"a": 1, "b": 2, "c": 3})
|
||||
|
||||
def test_valid_mixed_args(self):
|
||||
"""Test validation with mixed positional and keyword arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1,), {"b": 2})
|
||||
validate_args(params, (1, 2), {"c": 3})
|
||||
|
||||
def test_too_many_positional_args(self):
|
||||
"""Test error handling for too many positional arguments."""
|
||||
|
||||
def test_func(a, b):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1, 2, 3), {})
|
||||
|
||||
def test_missing_required_args(self):
|
||||
"""Test error handling for missing required arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1,), {}) # Missing 'b'
|
||||
|
||||
def test_unexpected_keyword_args(self):
|
||||
"""Test error handling for unexpected keyword arguments."""
|
||||
|
||||
def test_func(a, b):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1, 2), {"c": 3})
|
||||
|
||||
def test_duplicate_args(self):
|
||||
"""Test error handling for duplicate arguments (positional and keyword)."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
validate_args(params, (1, 2), {"b": 3}) # 'b' specified twice
|
||||
|
||||
def test_varargs_validation(self):
|
||||
"""Test validation with *args and **kwargs."""
|
||||
|
||||
def test_func(a, b=20, *args, **kwargs):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
# Should not raise an exception
|
||||
validate_args(params, (1, 2, 3, 4), {"extra": 5})
|
||||
validate_args(params, (1,), {"b": 2, "extra": 3})
|
||||
|
||||
|
||||
class TestFlattenArgs:
|
||||
"""Tests for the flatten_args utility function."""
|
||||
|
||||
def test_only_positional_args(self):
|
||||
"""Test flattening with only positional arguments."""
|
||||
|
||||
def test_func(a, b, c):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (1, 2, 3), {})
|
||||
|
||||
expected = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, DUMMY_TYPE, 3]
|
||||
assert flattened == expected
|
||||
|
||||
def test_only_keyword_args(self):
|
||||
"""Test flattening with only keyword arguments."""
|
||||
|
||||
def test_func(a=1, b=2, c=3):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (), {"a": 10, "b": 20, "c": 30})
|
||||
|
||||
expected = ["a", 10, "b", 20, "c", 30]
|
||||
assert flattened == expected
|
||||
|
||||
def test_mixed_args(self):
|
||||
"""Test flattening with mixed positional and keyword arguments."""
|
||||
|
||||
def test_func(a, b, c=30):
|
||||
return a + b + c
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (1, 2), {"c": 3})
|
||||
|
||||
expected = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, "c", 3]
|
||||
assert flattened == expected
|
||||
|
||||
def test_empty_args(self):
|
||||
"""Test flattening with no arguments."""
|
||||
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
params = extract_signature(test_func)
|
||||
flattened = flatten_args(params, (), {})
|
||||
|
||||
assert flattened == []
|
||||
|
||||
def test_complex_types(self):
|
||||
"""Test flattening with complex argument types."""
|
||||
|
||||
def test_func(a, b, c=None):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
complex_args = ([1, 2, 3], {"key": "value"})
|
||||
complex_kwargs = {"c": {"nested": "dict"}}
|
||||
|
||||
flattened = flatten_args(params, complex_args, complex_kwargs)
|
||||
|
||||
expected = [
|
||||
DUMMY_TYPE,
|
||||
[1, 2, 3],
|
||||
DUMMY_TYPE,
|
||||
{"key": "value"},
|
||||
"c",
|
||||
{"nested": "dict"},
|
||||
]
|
||||
assert flattened == expected
|
||||
|
||||
def test_invalid_args_raises_error(self):
|
||||
"""Test that invalid arguments raise TypeError during flattening."""
|
||||
|
||||
def test_func(a, b):
|
||||
return a + b
|
||||
|
||||
params = extract_signature(test_func)
|
||||
with pytest.raises(TypeError):
|
||||
flatten_args(params, (1, 2, 3), {}) # Too many args
|
||||
|
||||
|
||||
class TestRecoverArgs:
|
||||
"""Tests for the recover_args utility function."""
|
||||
|
||||
def test_only_positional_args(self):
|
||||
"""Test recovering only positional arguments."""
|
||||
flattened = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, DUMMY_TYPE, 3]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == [1, 2, 3]
|
||||
assert kwargs == {}
|
||||
|
||||
def test_only_keyword_args(self):
|
||||
"""Test recovering only keyword arguments."""
|
||||
flattened = ["a", 10, "b", 20, "c", 30]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == []
|
||||
assert kwargs == {"a": 10, "b": 20, "c": 30}
|
||||
|
||||
def test_mixed_args(self):
|
||||
"""Test recovering mixed positional and keyword arguments."""
|
||||
flattened = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, "c", 3]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == [1, 2]
|
||||
assert kwargs == {"c": 3}
|
||||
|
||||
def test_empty_flattened(self):
|
||||
"""Test recovering from empty flattened list."""
|
||||
flattened = []
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == []
|
||||
assert kwargs == {}
|
||||
|
||||
def test_complex_types(self):
|
||||
"""Test recovering complex argument types."""
|
||||
flattened = [
|
||||
DUMMY_TYPE,
|
||||
[1, 2, 3],
|
||||
DUMMY_TYPE,
|
||||
{"key": "value"},
|
||||
"c",
|
||||
{"nested": "dict"},
|
||||
]
|
||||
args, kwargs = recover_args(flattened)
|
||||
|
||||
assert args == [[1, 2, 3], {"key": "value"}]
|
||||
assert kwargs == {"c": {"nested": "dict"}}
|
||||
|
||||
def test_invalid_odd_length(self):
|
||||
"""Test error handling for odd-length flattened list."""
|
||||
flattened = [DUMMY_TYPE, 1, "key"] # Odd length
|
||||
with pytest.raises(
|
||||
AssertionError, match="Flattened arguments need to be even-numbered"
|
||||
):
|
||||
recover_args(flattened)
|
||||
|
||||
def test_preserve_order(self):
|
||||
"""Test that argument order is preserved during flatten/recover."""
|
||||
|
||||
def test_func(a, b, c, d, e):
|
||||
return a + b + c + d + e
|
||||
|
||||
params = extract_signature(test_func)
|
||||
original_args = (1, 2, 3)
|
||||
original_kwargs = {"d": 4, "e": 5}
|
||||
|
||||
flattened = flatten_args(params, original_args, original_kwargs)
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
|
||||
assert recovered_args == [1, 2, 3]
|
||||
assert recovered_kwargs == {"d": 4, "e": 5}
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for signature utilities working together."""
|
||||
|
||||
def test_complete_workflow(self):
|
||||
"""Test complete workflow from function to flatten/recover."""
|
||||
|
||||
def test_func(x: int, y: str = "default", z: Optional[Any] = None):
|
||||
return f"{x}_{y}_{z}"
|
||||
|
||||
# Extract signature
|
||||
params = extract_signature(test_func)
|
||||
assert len(params) == 3
|
||||
|
||||
# Validate arguments
|
||||
args = (42, "hello")
|
||||
kwargs = {"z": [1, 2, 3]}
|
||||
validate_args(params, args, kwargs)
|
||||
|
||||
# Flatten arguments
|
||||
flattened = flatten_args(params, args, kwargs)
|
||||
expected = [DUMMY_TYPE, 42, DUMMY_TYPE, "hello", "z", [1, 2, 3]]
|
||||
assert flattened == expected
|
||||
|
||||
# Recover arguments
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
assert recovered_args == list(args)
|
||||
assert recovered_kwargs == kwargs
|
||||
|
||||
def test_method_workflow_with_ignore_first(self):
|
||||
"""Test complete workflow for class methods with ignore_first=True."""
|
||||
|
||||
class TestClass:
|
||||
def test_method(self, a: int, b: str = "test"):
|
||||
return f"{a}_{b}"
|
||||
|
||||
# Extract signature ignoring 'self'
|
||||
params = extract_signature(TestClass.test_method, ignore_first=True)
|
||||
assert len(params) == 2
|
||||
assert params[0].name == "a"
|
||||
assert params[1].name == "b"
|
||||
|
||||
# Validate and flatten
|
||||
args = (100,)
|
||||
kwargs = {"b": "custom"}
|
||||
validate_args(params, args, kwargs)
|
||||
flattened = flatten_args(params, args, kwargs)
|
||||
|
||||
# Recover and verify
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
assert recovered_args == list(args)
|
||||
assert recovered_kwargs == kwargs
|
||||
|
||||
def test_varargs_kwargs_workflow(self):
|
||||
"""Test workflow with functions that have *args and **kwargs."""
|
||||
|
||||
def test_func(a, b=10, *args, **kwargs):
|
||||
return a + b + sum(args) + sum(kwargs.values())
|
||||
|
||||
params = extract_signature(test_func)
|
||||
|
||||
# Test with extra positional and keyword arguments
|
||||
args = (1, 2, 3, 4, 5)
|
||||
kwargs = {"extra1": 10, "extra2": 20}
|
||||
|
||||
validate_args(params, args, kwargs)
|
||||
flattened = flatten_args(params, args, kwargs)
|
||||
recovered_args, recovered_kwargs = recover_args(flattened)
|
||||
|
||||
assert recovered_args == list(args)
|
||||
assert recovered_kwargs == kwargs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-s", __file__]))
|
||||
@@ -0,0 +1,41 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.tls_utils import generate_self_signed_tls_certs
|
||||
|
||||
|
||||
def test_generate_self_signed_tls_certs_returns_tuple():
|
||||
cert_contents, key_contents = generate_self_signed_tls_certs()
|
||||
assert isinstance(cert_contents, str)
|
||||
assert isinstance(key_contents, str)
|
||||
|
||||
|
||||
def test_generate_self_signed_tls_certs_pem_format():
|
||||
cert_contents, key_contents = generate_self_signed_tls_certs()
|
||||
assert cert_contents.strip().startswith("-----BEGIN CERTIFICATE-----")
|
||||
assert cert_contents.strip().endswith("-----END CERTIFICATE-----")
|
||||
assert key_contents.strip().startswith("-----BEGIN")
|
||||
assert "PRIVATE KEY" in key_contents
|
||||
|
||||
|
||||
def test_generate_self_signed_tls_certs_usable_for_ssl():
|
||||
import ssl
|
||||
import tempfile
|
||||
|
||||
cert_contents, key_contents = generate_self_signed_tls_certs()
|
||||
with (
|
||||
tempfile.NamedTemporaryFile(mode="w", suffix=".crt") as cf,
|
||||
tempfile.NamedTemporaryFile(mode="w", suffix=".key") as kf,
|
||||
):
|
||||
cf.write(cert_contents)
|
||||
cf.flush()
|
||||
kf.write(key_contents)
|
||||
kf.flush()
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(cf.name, kf.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
"""Tests for Ray utility functions.
|
||||
|
||||
This module contains pytest-based tests for utility functions in ray._common.utils.
|
||||
Test utility classes (SignalActor, Semaphore) are in ray._common.test_utils to
|
||||
ensure they're included in the Ray package distribution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.utils import (
|
||||
_BACKGROUND_TASKS,
|
||||
env_bool,
|
||||
get_or_create_event_loop,
|
||||
get_system_memory,
|
||||
load_class,
|
||||
run_background_task,
|
||||
try_to_create_directory,
|
||||
)
|
||||
|
||||
# Optional imports for testing
|
||||
try:
|
||||
import psutil
|
||||
|
||||
PSUTIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PSUTIL_AVAILABLE = False
|
||||
|
||||
|
||||
class TestGetOrCreateEventLoop:
|
||||
"""Tests for the get_or_create_event_loop utility function."""
|
||||
|
||||
def test_existing_event_loop(self):
|
||||
# With running event loop
|
||||
expect_loop = asyncio.new_event_loop()
|
||||
expect_loop.set_debug(True)
|
||||
asyncio.set_event_loop(expect_loop)
|
||||
with warnings.catch_warnings():
|
||||
# Assert no deprecating warnings raised for python>=3.10.
|
||||
warnings.simplefilter("error")
|
||||
actual_loop = get_or_create_event_loop()
|
||||
|
||||
assert actual_loop == expect_loop, "Loop should not be recreated."
|
||||
|
||||
def test_new_event_loop(self):
|
||||
with warnings.catch_warnings():
|
||||
# Assert no deprecating warnings raised for python>=3.10.
|
||||
warnings.simplefilter("error")
|
||||
loop = get_or_create_event_loop()
|
||||
assert loop is not None, "new event loop should be created."
|
||||
|
||||
|
||||
class TestEnvBool:
|
||||
"""Tests for the env_bool utility function."""
|
||||
|
||||
_KEY = "_RAY_TEST_ENV_BOOL"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_value, expected",
|
||||
[
|
||||
("1", True),
|
||||
("true", True),
|
||||
("True", True),
|
||||
("TRUE", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("FALSE", False),
|
||||
("yes", False),
|
||||
("no", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_env_bool_values(self, env_value, expected, monkeypatch):
|
||||
monkeypatch.setenv(self._KEY, env_value)
|
||||
assert env_bool(self._KEY, False) is expected
|
||||
|
||||
def test_env_bool_default_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv(self._KEY, raising=False)
|
||||
assert env_bool(self._KEY, False) is False
|
||||
assert env_bool(self._KEY, True) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_background_task():
|
||||
"""Test the run_background_task utility function."""
|
||||
result = {}
|
||||
|
||||
async def co():
|
||||
result["start"] = 1
|
||||
await asyncio.sleep(0)
|
||||
result["end"] = 1
|
||||
|
||||
run_background_task(co())
|
||||
|
||||
# Background task is running.
|
||||
assert len(_BACKGROUND_TASKS) == 1
|
||||
# co executed.
|
||||
await asyncio.sleep(0)
|
||||
# await asyncio.sleep(0) from co is reached.
|
||||
await asyncio.sleep(0)
|
||||
# co finished and callback called.
|
||||
await asyncio.sleep(0)
|
||||
# The task should be removed from the set once it finishes.
|
||||
assert len(_BACKGROUND_TASKS) == 0
|
||||
|
||||
assert result.get("start") == 1
|
||||
assert result.get("end") == 1
|
||||
|
||||
|
||||
class TestTryToCreateDirectory:
|
||||
"""Tests for the try_to_create_directory utility function."""
|
||||
|
||||
def test_create_new_directory(self):
|
||||
"""Test creating a new directory."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_dir = os.path.join(temp_dir, "test_dir")
|
||||
try_to_create_directory(test_dir)
|
||||
assert os.path.exists(test_dir), "Directory should be created"
|
||||
assert os.path.isdir(test_dir), "Path should be a directory"
|
||||
|
||||
def test_existing_directory(self):
|
||||
"""Test creating a directory that already exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_dir = os.path.join(temp_dir, "existing_dir")
|
||||
# Create directory first
|
||||
os.makedirs(test_dir)
|
||||
# Should work without error
|
||||
try_to_create_directory(test_dir)
|
||||
assert os.path.exists(test_dir), "Directory should still exist"
|
||||
|
||||
def test_nested_directory_creation(self):
|
||||
"""Test creating nested directory structure."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
nested_dir = os.path.join(temp_dir, "nested", "deep", "structure")
|
||||
try_to_create_directory(nested_dir)
|
||||
assert os.path.exists(nested_dir), "Nested directory should be created"
|
||||
|
||||
def test_tilde_expansion(self):
|
||||
"""Test directory creation with tilde expansion."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fake_home = os.path.join(temp_dir, "fake_home")
|
||||
os.makedirs(fake_home, exist_ok=True)
|
||||
|
||||
# Mock the expanduser for this test
|
||||
original_expanduser = os.path.expanduser
|
||||
os.path.expanduser = (
|
||||
lambda path: path.replace("~", fake_home)
|
||||
if path.startswith("~")
|
||||
else path
|
||||
)
|
||||
|
||||
try:
|
||||
tilde_dir = "~/test_tilde_dir"
|
||||
try_to_create_directory(tilde_dir)
|
||||
expected_path = os.path.join(fake_home, "test_tilde_dir")
|
||||
assert os.path.exists(
|
||||
expected_path
|
||||
), "Tilde-expanded directory should be created"
|
||||
finally:
|
||||
# Restore original expanduser
|
||||
os.path.expanduser = original_expanduser
|
||||
|
||||
|
||||
class TestLoadClass:
|
||||
"""Tests for the load_class utility function."""
|
||||
|
||||
def test_load_builtin_class(self):
|
||||
"""Test loading a builtin class."""
|
||||
list_class = load_class("builtins.list")
|
||||
assert list_class is list, "Should load the builtin list class"
|
||||
|
||||
def test_load_module(self):
|
||||
"""Test loading a module."""
|
||||
path_module = load_class("os.path")
|
||||
import os.path
|
||||
|
||||
assert path_module is os.path, "Should load os.path module"
|
||||
|
||||
def test_load_function(self):
|
||||
"""Test loading a function from a module."""
|
||||
makedirs_func = load_class("os.makedirs")
|
||||
assert makedirs_func is os.makedirs, "Should load os.makedirs function"
|
||||
|
||||
def test_load_standard_library_class(self):
|
||||
"""Test loading a standard library class."""
|
||||
temp_dir_class = load_class("tempfile.TemporaryDirectory")
|
||||
assert (
|
||||
temp_dir_class is tempfile.TemporaryDirectory
|
||||
), "Should load TemporaryDirectory class"
|
||||
|
||||
def test_load_nested_module_class(self):
|
||||
"""Test loading a class from a nested module."""
|
||||
datetime_class = load_class("datetime.datetime")
|
||||
import datetime
|
||||
|
||||
assert (
|
||||
datetime_class is datetime.datetime
|
||||
), "Should load datetime.datetime class"
|
||||
|
||||
def test_invalid_path_error(self):
|
||||
"""Test error handling for invalid paths."""
|
||||
with pytest.raises(ValueError, match="valid path like mymodule.provider_class"):
|
||||
load_class("invalid")
|
||||
|
||||
def test_nonexistent_module_error(self):
|
||||
"""Test error handling for nonexistent modules."""
|
||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
||||
load_class("nonexistent_module.SomeClass")
|
||||
|
||||
def test_nonexistent_attribute_error(self):
|
||||
"""Test error handling for nonexistent attributes."""
|
||||
with pytest.raises(AttributeError):
|
||||
load_class("os.NonexistentClass")
|
||||
|
||||
|
||||
class TestGetSystemMemory:
|
||||
"""Tests for the get_system_memory utility function."""
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v1_with_low_limit(self):
|
||||
"""Test cgroups v1 with low memory limit."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_limit_file:
|
||||
memory_limit_file.write("1073741824") # 1GB
|
||||
memory_limit_file.flush()
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename=memory_limit_file.name,
|
||||
memory_limit_filename_v2="__does_not_exist__",
|
||||
)
|
||||
assert memory == 1073741824, "Should return cgroup limit when low"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v1_with_high_limit(self):
|
||||
"""Test cgroups v1 with high memory limit (should fallback to psutil)."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_limit_file:
|
||||
memory_limit_file.write(str(2**63)) # Very high limit
|
||||
memory_limit_file.flush()
|
||||
psutil_memory = psutil.virtual_memory().total
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename=memory_limit_file.name,
|
||||
memory_limit_filename_v2="__does_not_exist__",
|
||||
)
|
||||
assert (
|
||||
memory == psutil_memory
|
||||
), "Should fallback to psutil when cgroup limit is very high"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v2_with_limit(self):
|
||||
"""Test cgroups v2 with memory limit set."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_max_file:
|
||||
memory_max_file.write("2147483648\n") # 2GB with newline
|
||||
memory_max_file.flush()
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename="__does_not_exist__",
|
||||
memory_limit_filename_v2=memory_max_file.name,
|
||||
)
|
||||
assert memory == 2147483648, "Should return cgroups v2 limit"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_cgroups_v2_unlimited(self):
|
||||
"""Test cgroups v2 with unlimited memory (max)."""
|
||||
with tempfile.NamedTemporaryFile("w") as memory_max_file:
|
||||
memory_max_file.write("max")
|
||||
memory_max_file.flush()
|
||||
psutil_memory = psutil.virtual_memory().total
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename="__does_not_exist__",
|
||||
memory_limit_filename_v2=memory_max_file.name,
|
||||
)
|
||||
assert (
|
||||
memory == psutil_memory
|
||||
), "Should fallback to psutil when cgroups v2 is unlimited"
|
||||
|
||||
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
|
||||
def test_no_cgroup_files(self):
|
||||
"""Test fallback to psutil when no cgroup files exist."""
|
||||
psutil_memory = psutil.virtual_memory().total
|
||||
memory = get_system_memory(
|
||||
memory_limit_filename="__does_not_exist__",
|
||||
memory_limit_filename_v2="__also_does_not_exist__",
|
||||
)
|
||||
assert memory == psutil_memory, "Should use psutil when no cgroup files exist"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,320 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
|
||||
|
||||
|
||||
class TestWaitForCondition:
|
||||
"""Tests for the synchronous wait_for_condition function."""
|
||||
|
||||
def test_immediate_true_condition(self):
|
||||
"""Test that function returns immediately when condition is already true."""
|
||||
|
||||
def always_true():
|
||||
return True
|
||||
|
||||
wait_for_condition(always_true, timeout=5)
|
||||
|
||||
def test_condition_becomes_true(self):
|
||||
"""Test waiting for a condition that becomes true after some time."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 3
|
||||
|
||||
wait_for_condition(condition, timeout=5, retry_interval_ms=50)
|
||||
|
||||
assert counter["value"] >= 3
|
||||
|
||||
def test_timeout_raises_runtime_error(self):
|
||||
"""Test that timeout raises RuntimeError with appropriate message."""
|
||||
|
||||
def always_false():
|
||||
return False
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
wait_for_condition(always_false, timeout=0.2, retry_interval_ms=50)
|
||||
|
||||
assert "condition wasn't met before the timeout expired" in str(exc_info.value)
|
||||
|
||||
def test_condition_with_kwargs(self):
|
||||
"""Test passing kwargs to the condition predictor."""
|
||||
|
||||
def condition_with_args(target, current=0):
|
||||
return current >= target
|
||||
|
||||
wait_for_condition(condition_with_args, timeout=1, target=5, current=10)
|
||||
|
||||
# Should not raise an exception since current >= target
|
||||
|
||||
def test_exception_handling_default(self):
|
||||
"""Test that exceptions are caught by default and timeout occurs."""
|
||||
|
||||
def failing_condition():
|
||||
raise ValueError("Test exception")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
wait_for_condition(failing_condition, timeout=0.2, retry_interval_ms=50)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "condition wasn't met before the timeout expired" in error_msg
|
||||
assert "Last exception:" in error_msg
|
||||
assert "ValueError: Test exception" in error_msg
|
||||
|
||||
def test_exception_handling_raise_true(self):
|
||||
"""Test that exceptions are raised when raise_exceptions=True."""
|
||||
|
||||
def failing_condition():
|
||||
raise ValueError("Test exception")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
wait_for_condition(failing_condition, timeout=1, raise_exceptions=True)
|
||||
|
||||
assert "Test exception" in str(exc_info.value)
|
||||
|
||||
def test_custom_retry_interval(self):
|
||||
"""Test that custom retry intervals are respected."""
|
||||
call_times = []
|
||||
|
||||
def condition():
|
||||
call_times.append(time.time())
|
||||
return len(call_times) >= 3
|
||||
|
||||
wait_for_condition(condition, timeout=5, retry_interval_ms=200)
|
||||
|
||||
# Verify that calls were spaced approximately 200ms apart
|
||||
if len(call_times) >= 2:
|
||||
interval = call_times[1] - call_times[0]
|
||||
assert 0.15 <= interval <= 0.25 # Allow some tolerance
|
||||
|
||||
def test_condition_with_mixed_results(self):
|
||||
"""Test condition that fails initially then succeeds."""
|
||||
attempts = {"count": 0}
|
||||
|
||||
def intermittent_condition():
|
||||
attempts["count"] += 1
|
||||
# Succeed on the 4th attempt
|
||||
return attempts["count"] >= 4
|
||||
|
||||
wait_for_condition(intermittent_condition, timeout=2, retry_interval_ms=100)
|
||||
assert attempts["count"] >= 4
|
||||
|
||||
|
||||
class TestAsyncWaitForCondition:
|
||||
"""Tests for the asynchronous async_wait_for_condition function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_immediate_true_condition(self):
|
||||
"""Test that function returns immediately when condition is already true."""
|
||||
|
||||
def always_true():
|
||||
return True
|
||||
|
||||
await async_wait_for_condition(always_true, timeout=5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_condition_becomes_true(self):
|
||||
"""Test waiting for an async condition that becomes true after some time."""
|
||||
counter = {"value": 0}
|
||||
|
||||
async def async_condition():
|
||||
counter["value"] += 1
|
||||
await asyncio.sleep(0.01) # Small async operation
|
||||
return counter["value"] >= 3
|
||||
|
||||
await async_wait_for_condition(async_condition, timeout=5, retry_interval_ms=50)
|
||||
|
||||
assert counter["value"] >= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_condition_becomes_true(self):
|
||||
"""Test waiting for a sync condition in async context."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def sync_condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 3
|
||||
|
||||
await async_wait_for_condition(sync_condition, timeout=5, retry_interval_ms=50)
|
||||
assert counter["value"] >= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_raises_runtime_error(self):
|
||||
"""Test that timeout raises RuntimeError with appropriate message."""
|
||||
|
||||
def always_false():
|
||||
return False
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await async_wait_for_condition(
|
||||
always_false, timeout=0.2, retry_interval_ms=50
|
||||
)
|
||||
|
||||
assert "condition wasn't met before the timeout expired" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_condition_with_kwargs(self):
|
||||
"""Test passing kwargs to the condition predictor."""
|
||||
|
||||
def condition_with_args(target, current=0):
|
||||
return current >= target
|
||||
|
||||
await async_wait_for_condition(
|
||||
condition_with_args, timeout=1, target=5, current=10
|
||||
)
|
||||
|
||||
# Should not raise an exception since current >= target
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_condition_with_kwargs(self):
|
||||
"""Test passing kwargs to an async condition predictor."""
|
||||
|
||||
async def async_condition_with_args(target, current=0):
|
||||
await asyncio.sleep(0.01)
|
||||
return current >= target
|
||||
|
||||
await async_wait_for_condition(
|
||||
async_condition_with_args, timeout=1, target=5, current=10
|
||||
)
|
||||
|
||||
# Should not raise an exception since current >= target
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_handling(self):
|
||||
"""Test that exceptions are caught and timeout occurs."""
|
||||
|
||||
def failing_condition():
|
||||
raise ValueError("Test exception")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await async_wait_for_condition(
|
||||
failing_condition, timeout=0.2, retry_interval_ms=50
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "condition wasn't met before the timeout expired" in error_msg
|
||||
assert "Last exception:" in error_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception_handling(self):
|
||||
"""Test that exceptions from async conditions are caught."""
|
||||
|
||||
async def async_failing_condition():
|
||||
await asyncio.sleep(0.01)
|
||||
raise ValueError("Async test exception")
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await async_wait_for_condition(
|
||||
async_failing_condition, timeout=0.2, retry_interval_ms=50
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "condition wasn't met before the timeout expired" in error_msg
|
||||
assert "Last exception:" in error_msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_retry_interval(self):
|
||||
"""Test that custom retry intervals are respected."""
|
||||
call_times = []
|
||||
|
||||
def condition():
|
||||
call_times.append(time.time())
|
||||
return len(call_times) >= 3
|
||||
|
||||
await async_wait_for_condition(condition, timeout=5, retry_interval_ms=200)
|
||||
|
||||
# Verify that calls were spaced approximately 200ms apart
|
||||
if len(call_times) >= 2:
|
||||
interval = call_times[1] - call_times[0]
|
||||
assert 0.15 <= interval <= 0.25 # Allow some tolerance
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_sync_async_conditions(self):
|
||||
"""Test that both sync and async conditions work in the same test."""
|
||||
sync_counter = {"value": 0}
|
||||
async_counter = {"value": 0}
|
||||
|
||||
def sync_condition():
|
||||
sync_counter["value"] += 1
|
||||
return sync_counter["value"] >= 2
|
||||
|
||||
async def async_condition():
|
||||
async_counter["value"] += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return async_counter["value"] >= 2
|
||||
|
||||
# Test sync condition
|
||||
await async_wait_for_condition(sync_condition, timeout=2, retry_interval_ms=50)
|
||||
assert sync_counter["value"] >= 2
|
||||
|
||||
# Test async condition
|
||||
await async_wait_for_condition(async_condition, timeout=2, retry_interval_ms=50)
|
||||
assert async_counter["value"] >= 2
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_zero_timeout(self):
|
||||
"""Test behavior with zero timeout."""
|
||||
|
||||
def slow_condition():
|
||||
time.sleep(0.1)
|
||||
return True
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
wait_for_condition(slow_condition, timeout=0, retry_interval_ms=50)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_zero_timeout(self):
|
||||
"""Test async behavior with zero timeout."""
|
||||
|
||||
async def slow_condition():
|
||||
await asyncio.sleep(0.1)
|
||||
return True
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await async_wait_for_condition(
|
||||
slow_condition, timeout=0, retry_interval_ms=50
|
||||
)
|
||||
|
||||
def test_very_small_retry_interval(self):
|
||||
"""Test with very small retry interval."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 5
|
||||
|
||||
start_time = time.time()
|
||||
wait_for_condition(condition, timeout=1, retry_interval_ms=1)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should complete quickly due to small retry interval
|
||||
assert elapsed < 0.5
|
||||
assert counter["value"] >= 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_very_small_retry_interval(self):
|
||||
"""Test async version with very small retry interval."""
|
||||
counter = {"value": 0}
|
||||
|
||||
def condition():
|
||||
counter["value"] += 1
|
||||
return counter["value"] >= 5
|
||||
|
||||
start_time = time.time()
|
||||
await async_wait_for_condition(condition, timeout=1, retry_interval_ms=1)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should complete quickly due to small retry interval
|
||||
assert elapsed < 0.5
|
||||
assert counter["value"] >= 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""TLS utilities shared across Ray libraries (e.g. Serve)."""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import socket
|
||||
from typing import Tuple
|
||||
|
||||
from ray._common.network_utils import (
|
||||
get_localhost_ip,
|
||||
node_ip_address_from_perspective,
|
||||
)
|
||||
|
||||
|
||||
def generate_self_signed_tls_certs() -> Tuple[str, str]:
|
||||
"""Create self-signed key/cert pair for testing.
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_pem_contents, key_pem_contents).
|
||||
|
||||
Raises:
|
||||
ImportError: If the ``cryptography`` library is not installed.
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Using self-signed TLS certs requires `cryptography`. "
|
||||
"Install it with: pip install cryptography"
|
||||
) from e
|
||||
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048, backend=default_backend()
|
||||
)
|
||||
key_contents = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "ray-internal")])
|
||||
altnames = x509.SubjectAlternativeName(
|
||||
[
|
||||
x509.DNSName(socket.gethostbyname(socket.gethostname())),
|
||||
x509.DNSName(get_localhost_ip()),
|
||||
x509.DNSName(node_ip_address_from_perspective()),
|
||||
x509.DNSName("localhost"),
|
||||
]
|
||||
)
|
||||
now = datetime.datetime.utcnow()
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.add_extension(altnames, critical=False)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.sign(key, hashes.SHA256(), default_backend())
|
||||
)
|
||||
|
||||
cert_contents = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
return cert_contents, key_contents
|
||||
|
||||
|
||||
def add_port_to_grpc_server(server, address):
|
||||
import grpc
|
||||
|
||||
if os.environ.get("RAY_USE_TLS", "0").lower() in ("1", "true"):
|
||||
server_cert_chain, private_key, ca_cert = load_certs_from_env()
|
||||
credentials = grpc.ssl_server_credentials(
|
||||
[(private_key, server_cert_chain)],
|
||||
root_certificates=ca_cert,
|
||||
require_client_auth=ca_cert is not None,
|
||||
)
|
||||
return server.add_secure_port(address, credentials)
|
||||
else:
|
||||
return server.add_insecure_port(address)
|
||||
|
||||
|
||||
def load_certs_from_env():
|
||||
tls_env_vars = ["RAY_TLS_SERVER_CERT", "RAY_TLS_SERVER_KEY", "RAY_TLS_CA_CERT"]
|
||||
if any(v not in os.environ for v in tls_env_vars):
|
||||
raise RuntimeError(
|
||||
"If the environment variable RAY_USE_TLS is set to true "
|
||||
"then RAY_TLS_SERVER_CERT, RAY_TLS_SERVER_KEY and "
|
||||
"RAY_TLS_CA_CERT must also be set."
|
||||
)
|
||||
|
||||
with open(os.environ["RAY_TLS_SERVER_CERT"], "rb") as f:
|
||||
server_cert_chain = f.read()
|
||||
with open(os.environ["RAY_TLS_SERVER_KEY"], "rb") as f:
|
||||
private_key = f.read()
|
||||
with open(os.environ["RAY_TLS_CA_CERT"], "rb") as f:
|
||||
ca_cert = f.read()
|
||||
|
||||
return server_cert_chain, private_key, ca_cert
|
||||
@@ -0,0 +1,63 @@
|
||||
SCHEMA_VERSION = "0.1"
|
||||
|
||||
# The key to store / obtain cluster metadata.
|
||||
CLUSTER_METADATA_KEY = b"CLUSTER_METADATA"
|
||||
|
||||
# The name of a json file where usage stats will be written.
|
||||
USAGE_STATS_FILE = "usage_stats.json"
|
||||
|
||||
USAGE_STATS_ENABLED_ENV_VAR = "RAY_USAGE_STATS_ENABLED"
|
||||
|
||||
USAGE_STATS_SOURCE_ENV_VAR = "RAY_USAGE_STATS_SOURCE"
|
||||
|
||||
USAGE_STATS_SOURCE_OSS = "OSS"
|
||||
|
||||
USAGE_STATS_ENABLED_FOR_CLI_MESSAGE = (
|
||||
"Usage stats collection is enabled. To disable this, add `--disable-usage-stats` "
|
||||
"to the command that starts the cluster, or run the following command:"
|
||||
" `ray disable-usage-stats` before starting the cluster. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_ENABLED_FOR_RAY_INIT_MESSAGE = (
|
||||
"Usage stats collection is enabled. To disable this, run the following command:"
|
||||
" `ray disable-usage-stats` before starting Ray. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_DISABLED_MESSAGE = "Usage stats collection is disabled."
|
||||
|
||||
USAGE_STATS_ENABLED_BY_DEFAULT_FOR_CLI_MESSAGE = (
|
||||
"Usage stats collection is enabled by default without user confirmation "
|
||||
"because this terminal is detected to be non-interactive. "
|
||||
"To disable this, add `--disable-usage-stats` to the command that starts "
|
||||
"the cluster, or run the following command:"
|
||||
" `ray disable-usage-stats` before starting the cluster. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_ENABLED_BY_DEFAULT_FOR_RAY_INIT_MESSAGE = (
|
||||
"Usage stats collection is enabled by default for nightly wheels. "
|
||||
"To disable this, run the following command:"
|
||||
" `ray disable-usage-stats` before starting Ray. "
|
||||
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
|
||||
)
|
||||
|
||||
USAGE_STATS_CONFIRMATION_MESSAGE = (
|
||||
"Enable usage stats collection? "
|
||||
"This prompt will auto-proceed in 10 seconds to avoid blocking cluster startup."
|
||||
)
|
||||
|
||||
LIBRARY_USAGE_SET_NAME = "library_usage_"
|
||||
|
||||
HARDWARE_USAGE_SET_NAME = "hardware_usage_"
|
||||
|
||||
# Keep in-sync with the same constants defined in usage_stats_client.h
|
||||
EXTRA_USAGE_TAG_PREFIX = "extra_usage_tag_"
|
||||
USAGE_STATS_NAMESPACE = "usage_stats"
|
||||
|
||||
KUBERNETES_SERVICE_HOST_ENV = "KUBERNETES_SERVICE_HOST"
|
||||
KUBERAY_ENV = "RAY_USAGE_STATS_KUBERAY_IN_USE"
|
||||
|
||||
PROVIDER_KUBERNETES_GENERIC = "kubernetes"
|
||||
PROVIDER_KUBERAY = "kuberay"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import errno
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from inspect import signature
|
||||
from types import ModuleType
|
||||
from typing import Any, Coroutine, Dict, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray._raylet import GcsClient, NodeID
|
||||
from ray.core.generated.gcs_pb2 import GcsNodeInfo
|
||||
from ray.core.generated.gcs_service_pb2 import GetAllNodeInfoRequest
|
||||
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def env_integer(key, default):
|
||||
if key in os.environ:
|
||||
value = os.environ[key]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
f"Found {key} in environment, but value must "
|
||||
f"be an integer. Got: {value}. Returning "
|
||||
f"provided default {default}."
|
||||
)
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def env_float(key, default):
|
||||
if key in os.environ:
|
||||
value = os.environ[key]
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
f"Found {key} in environment, but value must "
|
||||
f"be a float. Got: {value}. Returning "
|
||||
f"provided default {default}."
|
||||
)
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def env_bool(key, default):
|
||||
if key in os.environ:
|
||||
val = os.environ[key].lower()
|
||||
return val == "true" or val == "1"
|
||||
return default
|
||||
|
||||
|
||||
def import_module_and_attr(
|
||||
full_path: str, *, reload_module: bool = False
|
||||
) -> Tuple[ModuleType, Any]:
|
||||
"""Given a full import path to a module attr, return the imported module and attr.
|
||||
|
||||
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
|
||||
|
||||
Args:
|
||||
full_path: The full import path to the module and attr.
|
||||
reload_module: Whether to reload the module.
|
||||
|
||||
Returns:
|
||||
A tuple of the imported module and attr.
|
||||
"""
|
||||
if ":" in full_path:
|
||||
if full_path.count(":") > 1:
|
||||
raise ValueError(
|
||||
f'Got invalid import path "{full_path}". An '
|
||||
"import path may have at most one colon."
|
||||
)
|
||||
module_name, attr_name = full_path.split(":")
|
||||
else:
|
||||
last_period_idx = full_path.rfind(".")
|
||||
module_name = full_path[:last_period_idx]
|
||||
attr_name = full_path[last_period_idx + 1 :]
|
||||
module = importlib.import_module(module_name)
|
||||
if reload_module:
|
||||
importlib.reload(module)
|
||||
return module, getattr(module, attr_name)
|
||||
|
||||
|
||||
def import_attr(full_path: str, *, reload_module: bool = False) -> Any:
|
||||
"""Given a full import path to a module attr, return the imported attr.
|
||||
|
||||
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
|
||||
|
||||
For example, the following are equivalent:
|
||||
MyClass = import_attr("module.submodule:MyClass")
|
||||
MyClass = import_attr("module.submodule.MyClass")
|
||||
from module.submodule import MyClass
|
||||
|
||||
Args:
|
||||
full_path: The full import path to the module and attr.
|
||||
reload_module: Whether to reload the module.
|
||||
|
||||
Returns:
|
||||
Imported attr
|
||||
"""
|
||||
return import_module_and_attr(full_path, reload_module=reload_module)[1]
|
||||
|
||||
|
||||
def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Get a running async event loop if one exists, otherwise create one.
|
||||
|
||||
This function serves as a proxy for the deprecating get_event_loop().
|
||||
It tries to get the running loop first, and if no running loop
|
||||
could be retrieved:
|
||||
- For python version <3.10: it falls back to the get_event_loop
|
||||
call.
|
||||
- For python version >= 3.10: it uses the same python implementation
|
||||
of _get_event_loop() at asyncio/events.py.
|
||||
|
||||
Ideally, one should use high level APIs like asyncio.run() with python
|
||||
version >= 3.7, if not possible, one should create and manage the event
|
||||
loops explicitly.
|
||||
"""
|
||||
vers_info = sys.version_info
|
||||
if vers_info.major >= 3 and vers_info.minor >= 10:
|
||||
# This follows the implementation of the deprecating `get_event_loop`
|
||||
# in python3.10's asyncio. See python3.10/asyncio/events.py
|
||||
# _get_event_loop()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
assert loop is not None
|
||||
return loop
|
||||
except RuntimeError as e:
|
||||
# No running loop, relying on the error message as for now to
|
||||
# differentiate runtime errors.
|
||||
assert "no running event loop" in str(e)
|
||||
try:
|
||||
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||
return loop
|
||||
except RuntimeError:
|
||||
# Python 3.14+: get_event_loop() no longer creates a loop automatically
|
||||
# See: https://docs.python.org/3.14/library/asyncio-eventloop.html
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop
|
||||
|
||||
return asyncio.get_event_loop()
|
||||
|
||||
|
||||
_BACKGROUND_TASKS = set()
|
||||
|
||||
|
||||
def run_background_task(coroutine: Coroutine) -> asyncio.Task:
|
||||
"""Schedule a task reliably to the event loop.
|
||||
|
||||
This API is used when you don't want to cache the reference of `asyncio.Task`.
|
||||
For example,
|
||||
|
||||
```
|
||||
get_event_loop().create_task(coroutine(*args))
|
||||
```
|
||||
|
||||
The above code doesn't guarantee to schedule the coroutine to the event loops
|
||||
|
||||
When using create_task in a "fire and forget" way, we should keep the references
|
||||
alive for the reliable execution. This API is used to fire and forget
|
||||
asynchronous execution.
|
||||
|
||||
https://docs.python.org/3/library/asyncio-task.html#creating-tasks
|
||||
"""
|
||||
task = get_or_create_event_loop().create_task(coroutine)
|
||||
# Add task to the set. This creates a strong reference.
|
||||
_BACKGROUND_TASKS.add(task)
|
||||
|
||||
# To prevent keeping references to finished tasks forever,
|
||||
# make each task remove its own reference from the set after
|
||||
# completion:
|
||||
task.add_done_callback(_BACKGROUND_TASKS.discard)
|
||||
return task
|
||||
|
||||
|
||||
# Used in gpu detection
|
||||
RESOURCE_CONSTRAINT_PREFIX = "accelerator_type:"
|
||||
PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME = "bundle"
|
||||
|
||||
|
||||
def resources_from_ray_options(options_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Determine a task's resource requirements.
|
||||
|
||||
Args:
|
||||
options_dict: The dictionary that contains resources requirements.
|
||||
|
||||
Returns:
|
||||
A dictionary of the resource requirements for the task.
|
||||
"""
|
||||
resources = (options_dict.get("resources") or {}).copy()
|
||||
|
||||
if "CPU" in resources or "GPU" in resources:
|
||||
raise ValueError(
|
||||
"The resources dictionary must not contain the key 'CPU' or 'GPU'"
|
||||
)
|
||||
elif "memory" in resources or "object_store_memory" in resources:
|
||||
raise ValueError(
|
||||
"The resources dictionary must not "
|
||||
"contain the key 'memory' or 'object_store_memory'"
|
||||
)
|
||||
elif PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME in resources:
|
||||
raise ValueError(
|
||||
"The resource should not include `bundle` which "
|
||||
f"is reserved for Ray. resources: {resources}"
|
||||
)
|
||||
|
||||
num_cpus = options_dict.get("num_cpus")
|
||||
num_gpus = options_dict.get("num_gpus")
|
||||
memory = options_dict.get("memory")
|
||||
object_store_memory = options_dict.get("object_store_memory")
|
||||
accelerator_type = options_dict.get("accelerator_type")
|
||||
|
||||
if num_cpus is not None:
|
||||
resources["CPU"] = num_cpus
|
||||
if num_gpus is not None:
|
||||
resources["GPU"] = num_gpus
|
||||
if memory is not None:
|
||||
resources["memory"] = int(memory)
|
||||
if object_store_memory is not None:
|
||||
resources["object_store_memory"] = object_store_memory
|
||||
if accelerator_type is not None:
|
||||
resources[f"{RESOURCE_CONSTRAINT_PREFIX}{accelerator_type}"] = 0.001
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
# Match the standard alphabet used for UUIDs.
|
||||
RANDOM_STRING_ALPHABET = string.ascii_lowercase + string.digits
|
||||
|
||||
|
||||
def get_random_alphanumeric_string(length: int):
|
||||
"""Generates random string of length consisting exclusively of
|
||||
- Lower-case ASCII chars
|
||||
- Digits
|
||||
"""
|
||||
return "".join(random.choices(RANDOM_STRING_ALPHABET, k=length))
|
||||
|
||||
|
||||
_PRINTED_WARNING = set()
|
||||
|
||||
|
||||
def get_call_location(back: int = 1):
|
||||
"""
|
||||
Get the location (filename and line number) of a function caller, `back`
|
||||
frames up the stack.
|
||||
|
||||
Args:
|
||||
back: The number of frames to go up the stack, not including this
|
||||
function.
|
||||
|
||||
Returns:
|
||||
A string with the filename and line number of the caller.
|
||||
For example, "myfile.py:123".
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
try:
|
||||
frame = stack[back + 1]
|
||||
return f"{frame.filename}:{frame.lineno}"
|
||||
except IndexError:
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def resolve_user_ray_temp_dir(gcs_client: GcsClient, node_id: str):
|
||||
"""
|
||||
Get the ray temp directory.
|
||||
|
||||
If a temp dir was specified for this node, this function will
|
||||
retrieve the information from GCS. Otherwise, it will fallback to the
|
||||
default ray temp directory.
|
||||
|
||||
Args:
|
||||
gcs_client: The GCS client.
|
||||
node_id: The ID of the node to fetch the temp dir for.
|
||||
E.g.: "1a9904d8aa3de65367830e2aef6313a5b2e9d4b0e3725e0dceeacb1b"
|
||||
(hex string representation of the node ID)
|
||||
|
||||
Returns:
|
||||
The path to the ray temp directory.
|
||||
"""
|
||||
# check if temp dir is available from runtime context
|
||||
if ray.is_initialized() and ray.get_runtime_context().get_node_id() == node_id:
|
||||
return ray.get_runtime_context().get_temp_dir()
|
||||
|
||||
# Fetch temp dir as specified by --temp-dir at creation time.
|
||||
try:
|
||||
# Create node selector for node_id filter
|
||||
node_selector = GetAllNodeInfoRequest.NodeSelector()
|
||||
node_selector.node_id = NodeID.from_hex(node_id).binary()
|
||||
|
||||
node_infos = gcs_client.get_all_node_info(
|
||||
node_selectors=[node_selector],
|
||||
state_filter=GcsNodeInfo.GcsNodeState.ALIVE,
|
||||
).values()
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Failed to get node info from GCS when fetching tempdir for node {node_id}: {e}"
|
||||
)
|
||||
if not node_infos:
|
||||
raise Exception(
|
||||
f"No node info associated with ALIVE state found for node {node_id} in GCS"
|
||||
)
|
||||
|
||||
node_info = next(iter(node_infos))
|
||||
if node_info is not None:
|
||||
temp_dir = getattr(node_info, "temp_dir", None)
|
||||
if temp_dir is not None:
|
||||
return temp_dir
|
||||
else:
|
||||
raise Exception(
|
||||
"Node temp_dir was not found in NodeInfo. did the node's raylet start successfully?"
|
||||
)
|
||||
|
||||
|
||||
def get_default_system_temp_dir():
|
||||
if "RAY_TMPDIR" in os.environ:
|
||||
return os.environ["RAY_TMPDIR"]
|
||||
elif sys.platform.startswith("linux") and "TMPDIR" in os.environ:
|
||||
return os.environ["TMPDIR"]
|
||||
elif sys.platform.startswith("darwin") or sys.platform.startswith("linux"):
|
||||
# Ideally we wouldn't need this fallback, but keep it for now for
|
||||
# for compatibility
|
||||
tempdir = os.path.join(os.sep, "tmp")
|
||||
else:
|
||||
tempdir = tempfile.gettempdir()
|
||||
|
||||
return tempdir
|
||||
|
||||
|
||||
def get_default_ray_temp_dir():
|
||||
return os.path.join(get_default_system_temp_dir(), "ray")
|
||||
|
||||
|
||||
def get_ray_address_file(temp_dir: Optional[str]):
|
||||
if temp_dir is None:
|
||||
temp_dir = get_default_ray_temp_dir()
|
||||
return os.path.join(temp_dir, "ray_current_cluster")
|
||||
|
||||
|
||||
def reset_ray_address(temp_dir: Optional[str] = None):
|
||||
address_file = get_ray_address_file(temp_dir)
|
||||
if os.path.exists(address_file):
|
||||
try:
|
||||
os.remove(address_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_class(path):
|
||||
"""Load a class at runtime given a full path.
|
||||
|
||||
Example of the path: mypkg.mysubpkg.myclass
|
||||
"""
|
||||
class_data = path.split(".")
|
||||
if len(class_data) < 2:
|
||||
raise ValueError("You need to pass a valid path like mymodule.provider_class")
|
||||
module_path = ".".join(class_data[:-1])
|
||||
class_str = class_data[-1]
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, class_str)
|
||||
|
||||
|
||||
def get_system_memory(
|
||||
# For cgroups v1:
|
||||
memory_limit_filename: str = "/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
# For cgroups v2:
|
||||
memory_limit_filename_v2: str = "/sys/fs/cgroup/memory.max",
|
||||
):
|
||||
"""Return the total amount of system memory in bytes.
|
||||
|
||||
Args:
|
||||
memory_limit_filename: The path to the file that contains the memory
|
||||
limit for the Docker container. Defaults to
|
||||
/sys/fs/cgroup/memory/memory.limit_in_bytes.
|
||||
memory_limit_filename_v2: The path to the file that contains the memory
|
||||
limit for the Docker container in cgroups v2. Defaults to
|
||||
/sys/fs/cgroup/memory.max.
|
||||
|
||||
Returns:
|
||||
The total amount of system memory in bytes.
|
||||
"""
|
||||
# Try to accurately figure out the memory limit if we are in a docker
|
||||
# container. Note that this file is not specific to Docker and its value is
|
||||
# often much larger than the actual amount of memory.
|
||||
docker_limit = None
|
||||
if os.path.exists(memory_limit_filename):
|
||||
with open(memory_limit_filename, "r") as f:
|
||||
docker_limit = int(f.read().strip())
|
||||
elif os.path.exists(memory_limit_filename_v2):
|
||||
with open(memory_limit_filename_v2, "r") as f:
|
||||
# Don't forget to strip() the newline:
|
||||
max_file = f.read().strip()
|
||||
if max_file.isnumeric():
|
||||
docker_limit = int(max_file)
|
||||
else:
|
||||
# max_file is "max", i.e. is unset.
|
||||
docker_limit = None
|
||||
|
||||
# Use psutil if it is available.
|
||||
psutil_memory_in_bytes = psutil.virtual_memory().total
|
||||
|
||||
if docker_limit is not None:
|
||||
# We take the min because the cgroup limit is very large if we aren't
|
||||
# in Docker.
|
||||
return min(docker_limit, psutil_memory_in_bytes)
|
||||
|
||||
return psutil_memory_in_bytes
|
||||
|
||||
|
||||
def binary_to_hex(identifier):
|
||||
hex_identifier = binascii.hexlify(identifier)
|
||||
hex_identifier = hex_identifier.decode()
|
||||
return hex_identifier
|
||||
|
||||
|
||||
def hex_to_binary(hex_identifier):
|
||||
return binascii.unhexlify(hex_identifier)
|
||||
|
||||
|
||||
def try_make_directory_shared(directory_path):
|
||||
try:
|
||||
os.chmod(directory_path, 0o0777)
|
||||
except OSError as e:
|
||||
# Silently suppress the PermissionError that is thrown by the chmod.
|
||||
# This is done because the user attempting to change the permissions
|
||||
# on a directory may not own it. The chmod is attempted whether the
|
||||
# directory is new or not to avoid race conditions.
|
||||
# ray-project/ray/#3591
|
||||
if e.errno in [errno.EACCES, errno.EPERM]:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def try_to_create_directory(directory_path):
|
||||
# Attempt to create a directory that is globally readable/writable.
|
||||
directory_path = os.path.expanduser(directory_path)
|
||||
os.makedirs(directory_path, exist_ok=True)
|
||||
# Change the log directory permissions so others can use it. This is
|
||||
# important when multiple people are using the same machine.
|
||||
try_make_directory_shared(directory_path)
|
||||
|
||||
|
||||
def get_function_args(callable):
|
||||
all_parameters = frozenset(signature(callable).parameters)
|
||||
return list(all_parameters)
|
||||
|
||||
|
||||
def decode(byte_str: str, allow_none: bool = False, encode_type: str = "utf-8"):
|
||||
"""Make this unicode in Python 3, otherwise leave it as bytes.
|
||||
|
||||
Args:
|
||||
byte_str: The byte string to decode.
|
||||
allow_none: If true, then we will allow byte_str to be None in which
|
||||
case we will return an empty string. TODO(rkn): Remove this flag.
|
||||
This is only here to simplify upgrading to flatbuffers 1.10.0.
|
||||
encode_type: The encoding type to use for decoding. Defaults to "utf-8".
|
||||
|
||||
Returns:
|
||||
A byte string in Python 2 and a unicode string in Python 3.
|
||||
"""
|
||||
if byte_str is None and allow_none:
|
||||
return ""
|
||||
|
||||
if not isinstance(byte_str, bytes):
|
||||
raise ValueError(f"The argument {byte_str} must be a bytes object.")
|
||||
return byte_str.decode(encode_type)
|
||||
|
||||
|
||||
class TimerBase(ABC):
|
||||
@abstractmethod
|
||||
def time(self) -> float:
|
||||
"""Return the current time."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Timer(TimerBase):
|
||||
def time(self) -> float:
|
||||
return time.time()
|
||||
Reference in New Issue
Block a user