315 lines
9.4 KiB
Python
315 lines
9.4 KiB
Python
import asyncio
|
|
import gc
|
|
import sys
|
|
|
|
import httpx
|
|
import numpy as np
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import JSONResponse
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import SignalActor
|
|
from ray.serve._private.constants import RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD
|
|
from ray.serve._private.test_utils import get_application_url
|
|
from ray.serve.context import _get_global_client
|
|
from ray.serve.handle import DeploymentHandle
|
|
|
|
|
|
@pytest.fixture
|
|
def shutdown_ray():
|
|
if ray.is_initialized():
|
|
serve.shutdown()
|
|
ray.shutdown()
|
|
yield
|
|
serve.shutdown()
|
|
ray.shutdown()
|
|
|
|
|
|
# NOTE(simon): Make sure this test is the first in this file because it should
|
|
# be tested without ray.init/serve.start being ran.
|
|
def test_fastapi_serialization(shutdown_ray):
|
|
# https://github.com/ray-project/ray/issues/15511
|
|
app = FastAPI()
|
|
|
|
@serve.deployment(name="custom_service")
|
|
@serve.ingress(app)
|
|
class CustomService:
|
|
def deduplicate(self, data):
|
|
data.drop_duplicates(inplace=True)
|
|
return data
|
|
|
|
@app.post("/deduplicate")
|
|
def _deduplicate(self, request):
|
|
data = request["data"]
|
|
columns = request["columns"]
|
|
import pandas as pd
|
|
|
|
data = pd.DataFrame(data, columns=columns)
|
|
data.drop_duplicates(inplace=True)
|
|
return data.values.tolist()
|
|
|
|
serve.start()
|
|
serve.run(CustomService.bind())
|
|
|
|
|
|
def test_np_in_composed_model(serve_instance):
|
|
# https://github.com/ray-project/ray/issues/9441
|
|
# AttributeError: 'bytes' object has no attribute 'readonly'
|
|
# in cloudpickle _from_numpy_buffer
|
|
|
|
@serve.deployment
|
|
class Sum:
|
|
def __call__(self, data):
|
|
return np.sum(data)
|
|
|
|
@serve.deployment(name="model")
|
|
class ComposedModel:
|
|
def __init__(self, handle: DeploymentHandle):
|
|
self.model = handle
|
|
|
|
async def __call__(self):
|
|
data = np.ones((10, 10))
|
|
return await self.model.remote(data)
|
|
|
|
sum_d = Sum.bind()
|
|
cm_d = ComposedModel.bind(sum_d)
|
|
serve.run(cm_d)
|
|
|
|
result = httpx.get(get_application_url())
|
|
assert result.status_code == 200
|
|
assert float(result.text) == 100.0
|
|
|
|
|
|
# https://github.com/ray-project/ray/issues/12395
|
|
def test_replica_memory_growth(serve_instance):
|
|
# NOTE(zcin): this test checks that there are no circular references
|
|
# since depending on the size of the objects locked in that cycle,
|
|
# it could cause large memory growth for the replica in the short
|
|
# term. Unfortunately the asyncio Python gRPC implementation has a
|
|
# circular reference between
|
|
# https://github.com/grpc/grpc/blob/04f05a3/src/python/grpcio/grpc/_server.py#L987
|
|
# & https://github.com/grpc/grpc/blob/04f05a3/src/python/grpcio/grpc/_server.py#L993
|
|
# So just by using the asyncio Python gRPC API in the replica, it
|
|
# will violate the checks in this test. However the objects locked
|
|
# in that cycle are metadata objects on the order of tens to
|
|
# hundreds of bytes, which is very small and should be fine to be
|
|
# garbage collected by the slower GC cycle that checks for circular
|
|
# references. Therefore we whitelist those objects in the test.
|
|
def whitelist(phase, info):
|
|
if phase == "start":
|
|
return
|
|
|
|
for item in gc.garbage[:]:
|
|
if getattr(type(item), "__name__", None) == "_Metadatum":
|
|
gc.garbage.remove(item)
|
|
elif isinstance(item, tuple) and all(
|
|
getattr(type(s), "__name__", None) == "_Metadatum" for s in item
|
|
):
|
|
gc.garbage.remove(item)
|
|
elif (
|
|
getattr(type(item), "__name__", None)
|
|
== "__pyx_scope_struct_35__find_method_handler"
|
|
):
|
|
gc.garbage.remove(item)
|
|
elif (
|
|
getattr(item, "__name__", None) == "query_handlers"
|
|
and item.func_globals["_find_method_handler"]
|
|
):
|
|
gc.garbage.remove(item)
|
|
elif getattr(type(item), "__name__", None) == "_HandlerCallDetails":
|
|
gc.garbage.remove(item)
|
|
|
|
@serve.deployment
|
|
def gc_unreachable_objects(*args):
|
|
gc.set_debug(gc.DEBUG_SAVEALL)
|
|
gc.callbacks.append(whitelist)
|
|
gc.collect()
|
|
gc_garbage_len = len(gc.garbage)
|
|
if gc_garbage_len > 0:
|
|
print(gc.garbage)
|
|
return gc_garbage_len
|
|
|
|
handle = serve.run(gc_unreachable_objects.bind())
|
|
|
|
def get_gc_garbage_len_http():
|
|
result = httpx.get(get_application_url())
|
|
assert result.status_code == 200
|
|
return result.json()
|
|
|
|
# We are checking that there's constant number of object in gc.
|
|
known_num_objects_from_http = get_gc_garbage_len_http()
|
|
|
|
for _ in range(10):
|
|
assert get_gc_garbage_len_http() == known_num_objects_from_http
|
|
|
|
known_num_objects_from_handle = handle.remote().result()
|
|
for _ in range(10):
|
|
assert handle.remote().result() == known_num_objects_from_handle
|
|
|
|
|
|
def test_ref_in_handle_input(serve_instance):
|
|
# https://github.com/ray-project/ray/issues/12593
|
|
|
|
unblock_worker_signal = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
async def blocked_by_ref(data):
|
|
assert not isinstance(data, ray.ObjectRef)
|
|
|
|
handle = serve.run(blocked_by_ref.bind())
|
|
|
|
# Pass in a ref that's not ready yet
|
|
ref = unblock_worker_signal.wait.remote()
|
|
worker_result = handle.remote(ref)
|
|
|
|
# Worker shouldn't execute the request
|
|
with pytest.raises(TimeoutError):
|
|
worker_result.result(timeout_s=1)
|
|
|
|
# Now unblock the worker
|
|
unblock_worker_signal.send.remote()
|
|
worker_result.result()
|
|
|
|
|
|
def test_nested_actors(serve_instance):
|
|
signal = SignalActor.remote()
|
|
|
|
@ray.remote(num_cpus=1)
|
|
class CustomActor:
|
|
def __init__(self) -> None:
|
|
signal.send.remote()
|
|
|
|
@serve.deployment
|
|
class A:
|
|
def __init__(self) -> None:
|
|
self.a = CustomActor.remote()
|
|
|
|
serve.run(A.bind())
|
|
|
|
# The nested actor should start successfully.
|
|
ray.get(signal.wait.remote(), timeout=10)
|
|
|
|
|
|
def test_handle_cache_out_of_scope(serve_instance):
|
|
# https://github.com/ray-project/ray/issues/18980
|
|
initial_num_cached = len(_get_global_client().handle_cache)
|
|
|
|
@serve.deployment(name="f")
|
|
def f():
|
|
return "hi"
|
|
|
|
handle = serve.run(f.bind(), name="app")
|
|
|
|
handle_cache = _get_global_client().handle_cache
|
|
assert len(handle_cache) == initial_num_cached + 1
|
|
|
|
def sender_where_handle_goes_out_of_scope():
|
|
f = _get_global_client().get_handle("f", "app", check_exists=False)
|
|
assert f is handle
|
|
assert f.remote().result() == "hi"
|
|
|
|
[sender_where_handle_goes_out_of_scope() for _ in range(30)]
|
|
assert len(handle_cache) == initial_num_cached + 1
|
|
|
|
|
|
def test_out_of_order_chaining(serve_instance):
|
|
# https://discuss.ray.io/t/concurrent-queries-blocking-following-queries/3949
|
|
|
|
@ray.remote(num_cpus=0)
|
|
class Collector:
|
|
def __init__(self):
|
|
self.lst = []
|
|
|
|
def append(self, msg):
|
|
self.lst.append(msg)
|
|
|
|
def get(self):
|
|
return self.lst
|
|
|
|
collector = Collector.remote()
|
|
|
|
@serve.deployment
|
|
class Combine:
|
|
def __init__(self, m1, m2):
|
|
self.m1 = m1
|
|
self.m2 = m2
|
|
|
|
async def run(self, _id):
|
|
return await self.m2.compute.remote(self.m1.compute.remote(_id))
|
|
|
|
@serve.deployment(graceful_shutdown_timeout_s=0.0)
|
|
class FirstModel:
|
|
async def compute(self, _id):
|
|
if _id == 0:
|
|
await asyncio.sleep(1000)
|
|
print(f"First output: {_id}")
|
|
ray.get(collector.append.remote(f"first-{_id}"))
|
|
return _id
|
|
|
|
@serve.deployment
|
|
class SecondModel:
|
|
async def compute(self, _id):
|
|
print(f"Second output: {_id}")
|
|
ray.get(collector.append.remote(f"second-{_id}"))
|
|
return _id
|
|
|
|
m1 = FirstModel.bind()
|
|
m2 = SecondModel.bind()
|
|
combine = Combine.bind(m1, m2)
|
|
handle = serve.run(combine)
|
|
|
|
handle.run.remote(_id=0)
|
|
handle.run.remote(_id=1).result()
|
|
|
|
assert ray.get(collector.get.remote()) == ["first-1", "second-1"]
|
|
|
|
|
|
def test_uvicorn_duplicate_headers(serve_instance):
|
|
# https://github.com/ray-project/ray/issues/21876
|
|
app = FastAPI()
|
|
|
|
@serve.deployment
|
|
@serve.ingress(app)
|
|
class A:
|
|
@app.get("/")
|
|
def func(self):
|
|
return JSONResponse({"a": "b"})
|
|
|
|
serve.run(A.bind())
|
|
resp = httpx.get("http://127.0.0.1:8000")
|
|
# If the header duplicated, it will be "9, 9"
|
|
assert resp.headers["content-length"] == "9"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD,
|
|
reason="Health check will block if user code is running in the main event loop",
|
|
)
|
|
def test_healthcheck_timeout(serve_instance):
|
|
# https://github.com/ray-project/ray/issues/24554
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(
|
|
health_check_timeout_s=2,
|
|
health_check_period_s=1,
|
|
graceful_shutdown_timeout_s=0,
|
|
)
|
|
class A:
|
|
def __call__(self):
|
|
ray.get(signal.wait.remote())
|
|
|
|
handle = serve.run(A.bind())
|
|
response = handle.remote()
|
|
# without the proper fix, the ref will fail with actor died error.
|
|
with pytest.raises(TimeoutError):
|
|
response.result(timeout_s=10)
|
|
signal.send.remote()
|
|
response.result()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|