import asyncio import concurrent.futures import inspect import logging import queue import time from contextlib import asynccontextmanager from functools import wraps from typing import ( Any, AsyncIterator, Callable, Coroutine, Dict, List, Optional, Tuple, Union, ) import ray from ray import cloudpickle from ray.serve._private.common import DeploymentID, RequestMetadata from ray.serve._private.constants import ( RAY_SERVE_RUN_SYNC_IN_THREADPOOL, SERVE_LOGGER_NAME, ) from ray.serve._private.replica import UserCallableWrapper from ray.serve._private.replica_result import ReplicaResult from ray.serve._private.request_router.replica_wrapper import ReplicaSelection from ray.serve._private.router import Router from ray.serve._private.utils import GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR from ray.serve.deployment import Deployment from ray.serve.exceptions import RequestCancelledError from ray.serve.handle import ( DeploymentHandle, DeploymentResponse, DeploymentResponseGenerator, ) logger = logging.getLogger(SERVE_LOGGER_NAME) def _validate_deployment_options( deployment: Deployment, deployment_id: DeploymentID, ): if "num_gpus" in deployment.ray_actor_options: logger.warning( f"Deployment {deployment_id} has num_gpus configured. " "CUDA_VISIBLE_DEVICES is not managed automatically in local testing mode. " ) if "runtime_env" in deployment.ray_actor_options: logger.warning( f"Deployment {deployment_id} has runtime_env configured. " "runtime_envs are ignored in local testing mode." ) def make_local_deployment_handle( deployment: Deployment, app_name: str, ) -> DeploymentHandle: """Constructs an in-process DeploymentHandle. This is used in the application build process for local testing mode, where all deployments of an app run in the local process which enables faster dev iterations and use of tooling like PDB. The user callable will be run on an asyncio loop in a separate thread (sharing the same code that's used in the replica). The constructor for the user callable is run eagerly in this function to ensure that any exceptions are raised during `serve.run`. """ deployment_id = DeploymentID(deployment.name, app_name) _validate_deployment_options(deployment, deployment_id) user_callable_wrapper = UserCallableWrapper( deployment.func_or_class, deployment.init_args, deployment.init_kwargs, deployment_id=deployment_id, run_sync_methods_in_threadpool=RAY_SERVE_RUN_SYNC_IN_THREADPOOL, run_user_code_in_separate_thread=True, local_testing_mode=True, deployment_config=deployment._deployment_config, actor_id="local", ray_actor_options=deployment.ray_actor_options, ) try: logger.info(f"Initializing local replica class for {deployment_id}.") user_callable_wrapper.initialize_callable().result() user_callable_wrapper.call_reconfigure(deployment.user_config, rank=0) except Exception: logger.exception(f"Failed to initialize deployment {deployment_id}.") raise def _create_local_router( handle_id: str, deployment_id: DeploymentID, handle_options: Any ) -> Router: return LocalRouter( user_callable_wrapper, deployment_id=deployment_id, handle_options=handle_options, ) return DeploymentHandle( deployment.name, app_name, _create_router=_create_local_router, ) class LocalReplicaResult(ReplicaResult): """ReplicaResult used by in-process Deployment Handles.""" OBJ_REF_NOT_SUPPORTED_ERROR = RuntimeError( "Converting DeploymentResponses to ObjectRefs is not supported " "in local testing mode." ) REJECTION_NOT_SUPPORTED_ERROR = RuntimeError( "Request rejection is not supported in local testing mode." ) def __init__( self, future: concurrent.futures.Future, *, request_id: str, is_streaming: bool = False, generator_result_queue: Optional[queue.Queue] = None, ): self._future = future self._lazy_asyncio_future = None self._request_id = request_id self._is_streaming = is_streaming # For streaming requests, results must be written to this queue. # The queue will be consumed until the future is completed. self._generator_result_queue = generator_result_queue if self._is_streaming: assert ( self._generator_result_queue is not None ), "generator_result_queue must be provided for streaming results." @property def _asyncio_future(self) -> asyncio.Future: if self._lazy_asyncio_future is None: self._lazy_asyncio_future = asyncio.wrap_future(self._future) return self._lazy_asyncio_future def _process_response(f: Union[Callable, Coroutine]): @wraps(f) def wrapper(self, *args, **kwargs): try: return f(self, *args, **kwargs) except (asyncio.CancelledError, concurrent.futures.CancelledError): raise RequestCancelledError(self._request_id) @wraps(f) async def async_wrapper(self, *args, **kwargs): try: return await f(self, *args, **kwargs) except (asyncio.CancelledError, concurrent.futures.CancelledError): raise RequestCancelledError(self._request_id) if inspect.iscoroutinefunction(f): return async_wrapper else: return wrapper @_process_response async def get_rejection_response(self): raise self.REJECTION_NOT_SUPPORTED_ERROR @_process_response def get(self, timeout_s: Optional[float]): assert ( not self._is_streaming ), "get() can only be called on a non-streaming result." try: return self._future.result(timeout=timeout_s) except concurrent.futures.TimeoutError: raise TimeoutError("Timed out waiting for result.") @_process_response async def get_async(self): assert ( not self._is_streaming ), "get_async() can only be called on a non-streaming result." return await self._asyncio_future @_process_response def __next__(self): assert self._is_streaming, "next() can only be called on a streaming result." while True: if self._future.done() and self._generator_result_queue.empty(): if self._future.exception(): raise self._future.exception() else: raise StopIteration try: return self._generator_result_queue.get(timeout=0.01) except queue.Empty: pass @_process_response async def __anext__(self): assert self._is_streaming, "anext() can only be called on a streaming result." # This callback does not pull from the queue, only checks that a result is # available, else there is a race condition where the future finishes and the # queue is empty, but this function hasn't returned the result yet. def _wait_for_result(): while True: if self._future.done() or not self._generator_result_queue.empty(): return time.sleep(0.01) wait_for_result_task = asyncio.get_running_loop().create_task( asyncio.to_thread(_wait_for_result), ) done, _ = await asyncio.wait( [self._asyncio_future, wait_for_result_task], return_when=asyncio.FIRST_COMPLETED, ) if not self._generator_result_queue.empty(): return self._generator_result_queue.get() if self._asyncio_future.exception(): raise self._asyncio_future.exception() raise StopAsyncIteration def add_done_callback(self, callback: Callable): self._future.add_done_callback(callback) def cancel(self): self._future.cancel() def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef: raise self.OBJ_REF_NOT_SUPPORTED_ERROR async def to_object_ref_async(self) -> ray.ObjectRef: raise self.OBJ_REF_NOT_SUPPORTED_ERROR def to_object_ref_gen(self) -> ray.ObjectRefGenerator: raise self.OBJ_REF_NOT_SUPPORTED_ERROR class LocalRouter(Router): def __init__( self, user_callable_wrapper: UserCallableWrapper, deployment_id: DeploymentID, handle_options: Any, ): self._deployment_id = deployment_id self._user_callable_wrapper = user_callable_wrapper assert ( self._user_callable_wrapper._callable is not None ), "User callable must already be initialized." def running_replicas_populated(self) -> bool: return True def _resolve_deployment_responses( self, request_args: Tuple[Any], request_kwargs: Dict[str, Any] ) -> Tuple[Tuple[Any], Dict[str, Any]]: """Replace DeploymentResponse objects with their results. NOTE(edoakes): this currently calls the blocking `.result()` method on the responses to resolve them to their values. This is a divergence from the remote codepath where they're resolved concurrently. """ def _new_arg(arg: Any) -> Any: if isinstance(arg, DeploymentResponse): new_arg = arg.result(_skip_asyncio_check=True) elif isinstance(arg, DeploymentResponseGenerator): raise GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR else: new_arg = arg return new_arg # Serialize and deserialize the arguments to mimic remote call behavior. return cloudpickle.loads( cloudpickle.dumps( ( tuple(_new_arg(arg) for arg in request_args), {k: _new_arg(v) for k, v in request_kwargs.items()}, ) ) ) def assign_request( self, request_meta: RequestMetadata, *request_args, **request_kwargs, ) -> concurrent.futures.Future[LocalReplicaResult]: request_args, request_kwargs = self._resolve_deployment_responses( request_args, request_kwargs ) if request_meta.is_streaming: generator_result_queue = queue.Queue() def generator_result_callback(item: Any): generator_result_queue.put_nowait(item) else: generator_result_queue = None generator_result_callback = None # Conform to the router interface of returning a future to the ReplicaResult. if request_meta.is_http_request: fut = self._user_callable_wrapper._call_http_entrypoint( request_meta, request_args, request_kwargs, generator_result_callback=generator_result_callback, ) elif request_meta.is_streaming: fut = self._user_callable_wrapper._call_user_generator( request_meta, request_args, request_kwargs, enqueue=generator_result_callback, ) else: fut = self._user_callable_wrapper.call_user_method( request_meta, request_args, request_kwargs, ) noop_future = concurrent.futures.Future() noop_future.set_result( LocalReplicaResult( fut, request_id=request_meta.request_id, is_streaming=request_meta.is_streaming, generator_result_queue=generator_result_queue, ) ) return noop_future @asynccontextmanager async def choose_replica( self, request_meta: RequestMetadata, *request_args, **request_kwargs, ) -> AsyncIterator[ReplicaSelection]: """Choose replica is not supported in local testing mode. This is a stub implementation to satisfy the Router ABC interface. """ raise NotImplementedError( "choose_replica is not supported in local testing mode. " "Use assign_request instead." ) yield # Make this a generator for asynccontextmanager def dispatch( self, selection: ReplicaSelection, request_meta: RequestMetadata, *request_args, **request_kwargs, ) -> concurrent.futures.Future[ReplicaResult]: """Dispatch is not supported in local testing mode. This is a stub implementation to satisfy the Router ABC interface. """ raise NotImplementedError( "dispatch is not supported in local testing mode. " "Use assign_request instead." ) async def broadcast( self, request_meta: RequestMetadata, *request_args, **request_kwargs, ) -> List[ReplicaResult]: """Broadcast in local testing mode calls the single local replica.""" result_future = self.assign_request( request_meta, *request_args, **request_kwargs ) # In local testing mode there is only one replica. replica_result = result_future.result() return [replica_result] def shutdown(self): noop_future = concurrent.futures.Future() noop_future.set_result(None) return noop_future