407 lines
13 KiB
Python
407 lines
13 KiB
Python
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
|