Files
ray-project--ray/python/ray/serve/tests/unit/test_task_consumer.py
T
2026-07-13 13:17:40 +08:00

320 lines
9.4 KiB
Python

import sys
import uuid
from typing import Any, Dict, List
from unittest.mock import MagicMock, call
import pytest
from ray.serve.api import deployment
from ray.serve.schema import (
CeleryAdapterConfig,
TaskProcessorAdapter,
TaskProcessorConfig,
TaskResult,
)
from ray.serve.task_consumer import task_consumer, task_handler
class MockTaskProcessorAdapter(TaskProcessorAdapter):
"""Mock adapter for testing task processor functionality."""
def __init__(self, config: TaskProcessorConfig):
self._config = config
self._start_consumer_received: bool = False
self._stop_consumer_received: bool = False
self.register_task_handle_mock = MagicMock()
def initialize(self, consumer_concurrency: int = 3):
pass
def register_task_handle(self, func, name=None):
self.register_task_handle_mock(func, name=name)
def enqueue_task_sync(
self, task_name, args=None, kwargs=None, **options
) -> TaskResult:
pass
def get_task_status_sync(self, task_id) -> TaskResult:
pass
def start_consumer(self, **kwargs):
self._start_consumer_received = True
def stop_consumer(self, timeout: float = 10.0):
self._stop_consumer_received = True
def cancel_task_sync(self, task_id) -> bool:
pass
def get_metrics_sync(self) -> Dict[str, Any]:
pass
def health_check_sync(self) -> List[Dict]:
pass
@pytest.fixture
def config():
"""Provides a mock TaskProcessorConfig."""
queue_name = f"test_queue_{uuid.uuid4().hex}"
return TaskProcessorConfig(
queue_name=queue_name,
adapter_config=CeleryAdapterConfig(
broker_url="fake://",
backend_url="fake://",
),
adapter=MockTaskProcessorAdapter,
)
class TestTaskHandlerDecorator:
"""Test the task_handler decorator."""
def _create_and_test_handler(self, decorator_args=None, expected_name=None):
"""Helper to create and test a task handler."""
mock = MagicMock()
if decorator_args is None:
@task_handler
def test_handler():
mock()
else:
@task_handler(**decorator_args)
def test_handler():
mock()
test_handler()
assert mock.call_count == 1
assert test_handler._task_name == expected_name
def test_task_handler_decorator_with_name(self):
self._create_and_test_handler(
decorator_args={"name": "my_task"}, expected_name="my_task"
)
def test_task_handler_decorator_without_name(self):
self._create_and_test_handler(expected_name="test_handler")
@pytest.mark.parametrize("invalid_name", ["", " ", 123])
def test_task_handler_decorator_invalid_name(self, invalid_name):
"""Test various invalid task names."""
with pytest.raises(
ValueError,
match=f"Task name must be a non-empty string, got {invalid_name}",
):
@task_handler(name=invalid_name)
def my_task_handler():
pass
def test_task_handler_on_callable_object_without_name_attr(self):
"""Test that AttributeError is raised for callables with no __name__."""
class MyCallable:
"""A simple callable class without a __name__ attribute on instances."""
def __call__(self):
pass
with pytest.raises(AttributeError):
task_handler(MyCallable())
class TestTaskConsumerDecorator:
"""Test the task_consumer decorator."""
def _verify_and_cleanup(self, instance, expected_calls=None):
"""Verify consumer and cleanup instance."""
instance.initialize_callable(5)
adapter = instance._adapter
assert adapter._start_consumer_received
if expected_calls is not None:
if expected_calls:
calls = [call(method, name=name) for method, name in expected_calls]
adapter.register_task_handle_mock.assert_has_calls(
calls, any_order=False
)
assert adapter.register_task_handle_mock.call_count == len(
expected_calls
)
else:
adapter.register_task_handle_mock.assert_not_called()
del instance
def _run_consumer_test(
self, config, consumer_class_factory, expected_calls_factory=None
):
"""Run a consumer test with factory functions."""
consumer_class = consumer_class_factory(config)
instance = consumer_class()
expected_calls = (
expected_calls_factory(instance) if expected_calls_factory else None
)
self._verify_and_cleanup(instance, expected_calls)
def test_task_consumer_basic(self, config):
"""Test basic functionality of the task_consumer decorator."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
@task_handler
def my_task(self):
pass
return MyConsumer
self._run_consumer_test(
config, make_consumer, lambda inst: [(inst.my_task, "my_task")]
)
def test_task_consumer_multiple_handlers(self, config):
"""Test with multiple task handlers."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
@task_handler
def task1(self):
pass
@task_handler
def task2(self):
pass
return MyConsumer
self._run_consumer_test(
config,
make_consumer,
lambda inst: [(inst.task1, "task1"), (inst.task2, "task2")],
)
def test_task_consumer_custom_names(self, config):
"""Test task handlers with and without custom names."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
@task_handler(name="custom_task")
def task1(self):
pass
@task_handler
def task2(self):
pass
return MyConsumer
self._run_consumer_test(
config,
make_consumer,
lambda inst: [(inst.task1, "custom_task"), (inst.task2, "task2")],
)
def test_task_consumer_init_args(self, config):
"""Test that __init__ arguments are passed correctly."""
@task_consumer(task_processor_config=config)
class MyConsumer:
def __init__(self, value):
self.value = value
instance = MyConsumer(value=42)
assert instance.value == 42
self._verify_and_cleanup(instance)
def test_task_consumer_no_handlers(self, config):
"""Test with a class that has no task handlers."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
def some_method(self):
pass
return MyConsumer
self._run_consumer_test(config, make_consumer, lambda inst: [])
def test_task_consumer_inheritance(self, config):
"""Test that inherited task handlers are registered."""
def make_consumer(cfg):
class BaseConsumer:
@task_handler
def base_task(self):
pass
@task_consumer(task_processor_config=cfg)
class DerivedConsumer(BaseConsumer):
@task_handler
def derived_task(self):
pass
return DerivedConsumer
self._run_consumer_test(
config,
make_consumer,
lambda inst: [
(inst.base_task, "base_task"),
(inst.derived_task, "derived_task"),
],
)
def test_task_consumer_no_args_decorator(self):
"""Test using @task_consumer without arguments raises TypeError."""
with pytest.raises(TypeError):
@task_consumer
class MyConsumer:
pass
def test_default_deployment_name_stays_same_with_task_consumer(config):
"""Test that the default deployment name is the class name when using task_consumer with serve.deployment."""
@deployment
@task_consumer(task_processor_config=config)
class MyTaskConsumer:
@task_handler
def my_task(self):
pass
# The deployment name should default to the class name
assert MyTaskConsumer.name == "MyTaskConsumer"
def test_task_consumer_preserves_metadata(config):
class OriginalConsumer:
"""Docstring for a task consumer."""
value: int
wrapped_cls = task_consumer(task_processor_config=config)(OriginalConsumer)
assert wrapped_cls.__name__ == OriginalConsumer.__name__
assert wrapped_cls.__qualname__ == OriginalConsumer.__qualname__
assert wrapped_cls.__module__ == OriginalConsumer.__module__
assert wrapped_cls.__doc__ == OriginalConsumer.__doc__
assert (
wrapped_cls.__annotations__["value"]
== OriginalConsumer.__annotations__["value"]
)
assert wrapped_cls.__annotations__["_adapter"] is TaskProcessorAdapter
assert getattr(wrapped_cls, "__wrapped__", None) is OriginalConsumer
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))