Files
2026-07-13 13:17:40 +08:00

404 lines
12 KiB
Python

import sys
import pytest
import ray
from ray import serve
from ray._common.test_utils import (
SignalActor,
async_wait_for_condition,
wait_for_condition,
)
from ray.serve._private.constants import RAY_SERVE_FORCE_LOCAL_TESTING_MODE
from ray.serve._private.test_utils import send_signal_on_cancellation, tlog
from ray.serve.exceptions import RequestCancelledError
def test_cancel_sync_handle_call_during_execution(serve_instance):
"""Test cancelling handle request during execution (sync context)."""
running_signal_actor = SignalActor.remote()
cancelled_signal_actor = SignalActor.remote()
@serve.deployment
class Ingress:
async def __call__(self, *args):
async with send_signal_on_cancellation(cancelled_signal_actor):
await running_signal_actor.send.remote()
h = serve.run(Ingress.bind())
# Send a request and wait for it to start executing.
r = h.remote()
ray.get(running_signal_actor.wait.remote(), timeout=10)
# Cancel it and verify that it is cancelled via signal.
r.cancel()
ray.get(cancelled_signal_actor.wait.remote(), timeout=10)
with pytest.raises(RequestCancelledError):
r.result()
@pytest.mark.skipif(
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
reason="local_testing_mode doesn't have assignment/execution split",
)
def test_cancel_sync_handle_call_during_assignment(serve_instance):
"""Test cancelling handle request during assignment (sync context)."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class Ingress:
def __init__(self):
self._num_requests = 0
async def __call__(self, *args):
self._num_requests += 1
await signal_actor.wait.remote()
return self._num_requests
h = serve.run(Ingress.bind())
# Send a request and wait for it to be ongoing so we know that further requests
# will block trying to assign a replica.
initial_response = h.remote()
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# Make a second request, cancel it, and verify that it is cancelled.
second_response = h.remote()
second_response.cancel()
with pytest.raises(RequestCancelledError):
second_response.result()
# Now signal the initial request to finish and check that the second request
# never reached the replica.
ray.get(signal_actor.send.remote())
assert initial_response.result() == 1
for i in range(2, 12):
assert h.remote().result() == i
def test_cancel_async_handle_call_during_execution(serve_instance):
"""Test cancelling handle request during execution (async context)."""
running_signal_actor = SignalActor.remote()
cancelled_signal_actor = SignalActor.remote()
@serve.deployment
class Downstream:
async def __call__(self, *args):
async with send_signal_on_cancellation(cancelled_signal_actor):
await running_signal_actor.send.remote()
@serve.deployment
class Ingress:
def __init__(self, handle):
self._h = handle
async def __call__(self, *args):
# Send a request and wait for it to start executing.
r = self._h.remote()
await running_signal_actor.wait.remote()
# Cancel it and verify that it is cancelled via signal.
r.cancel()
await cancelled_signal_actor.wait.remote()
with pytest.raises(RequestCancelledError):
await r
h = serve.run(Ingress.bind(Downstream.bind()))
h.remote().result() # Would raise if test failed.
@pytest.mark.skipif(
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
reason="local_testing_mode doesn't have assignment/execution split",
)
def test_cancel_async_handle_call_during_assignment(serve_instance):
"""Test cancelling handle request during assignment (async context)."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class Downstream:
def __init__(self):
self._num_requests = 0
async def __call__(self, *args):
self._num_requests += 1
await signal_actor.wait.remote()
return self._num_requests
@serve.deployment
class Ingress:
def __init__(self, handle):
self._h = handle
async def __call__(self, *args):
# Send a request and wait for it to be ongoing so we know that further
# requests will block trying to assign a replica.
initial_response = self._h.remote()
async def one_waiter():
return await signal_actor.cur_num_waiters.remote() == 1
await async_wait_for_condition(one_waiter)
# Make a second request, cancel it, and verify that it is cancelled.
second_response = self._h.remote()
second_response.cancel()
with pytest.raises(RequestCancelledError):
await second_response
# Now signal the initial request to finish and check that the second request
# never reached the replica.
await signal_actor.send.remote()
assert await initial_response == 1
for i in range(2, 12):
assert await self._h.remote() == i
h = serve.run(Ingress.bind(Downstream.bind()))
h.remote().result() # Would raise if test failed.
def test_cancel_generator_sync(serve_instance):
"""Test cancelling streaming handle request during execution."""
signal_actor = SignalActor.remote()
@serve.deployment
class Ingress:
async def __call__(self, *args):
yield "hi"
async with send_signal_on_cancellation(signal_actor):
pass
h = serve.run(Ingress.bind()).options(stream=True)
# Send a request and wait for it to start executing.
g = h.remote()
assert next(g) == "hi"
# Cancel it and verify that it is cancelled via signal.
g.cancel()
with pytest.raises(RequestCancelledError):
next(g)
ray.get(signal_actor.wait.remote(), timeout=10)
def test_cancel_generator_async(serve_instance):
"""Test cancelling streaming handle request during execution."""
signal_actor = SignalActor.remote()
@serve.deployment
class Downstream:
async def __call__(self, *args):
yield "hi"
async with send_signal_on_cancellation(signal_actor):
pass
@serve.deployment
class Ingress:
def __init__(self, handle):
self._h = handle.options(stream=True)
async def __call__(self, *args):
# Send a request and wait for it to start executing.
g = self._h.remote()
assert await g.__anext__() == "hi"
# Cancel it and verify that it is cancelled via signal.
g.cancel()
with pytest.raises(RequestCancelledError):
await g.__anext__()
await signal_actor.wait.remote()
h = serve.run(Ingress.bind(Downstream.bind()))
h.remote().result() # Would raise if test failed.
def test_only_relevant_task_is_cancelled(serve_instance):
"""Test cancelling one request doesn't affect others."""
signal_actor = SignalActor.remote()
@serve.deployment
class Ingress:
async def __call__(self, *args):
await signal_actor.wait.remote()
return "ok"
h = serve.run(Ingress.bind())
r1 = h.remote()
r2 = h.remote()
# Wait for both requests to be executing.
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 2)
r1.cancel()
with pytest.raises(RequestCancelledError):
r1.result()
# Now signal r2 to run to completion and check that it wasn't cancelled.
ray.get(signal_actor.send.remote())
assert r2.result() == "ok"
def test_out_of_band_task_is_not_cancelled(serve_instance):
"""
Test cancelling a request doesn't cancel tasks submitted
outside the request's context.
"""
signal_actor = SignalActor.remote()
@serve.deployment
class Downstream:
async def hi(self):
await signal_actor.wait.remote()
return "ok"
@serve.deployment
class Ingress:
def __init__(self, handle):
self._h = handle
self._out_of_band_req = self._h.hi.remote()
async def __call__(self, *args):
await self._h.hi.remote()
async def get_out_of_band_response(self):
return await self._out_of_band_req
h = serve.run(Ingress.bind(Downstream.bind()))
# Send a request, wait for downstream request to start, and cancel it.
r1 = h.remote()
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 2)
r1.cancel()
with pytest.raises(RequestCancelledError):
r1.result()
# Now signal out of band request to run to completion and check that it wasn't
# cancelled.
ray.get(signal_actor.send.remote())
assert h.get_out_of_band_response.remote().result() == "ok"
@pytest.mark.skipif(
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
reason="local_testing_mode doesn't implement recursive cancellation",
)
def test_recursive_cancellation_during_execution(serve_instance):
inner_signal_actor = SignalActor.remote()
outer_signal_actor = SignalActor.remote()
@serve.deployment
async def inner():
async with send_signal_on_cancellation(inner_signal_actor):
pass
@serve.deployment
class Ingress:
def __init__(self, handle):
self._handle = handle
async def __call__(self):
_ = self._handle.remote()
async with send_signal_on_cancellation(outer_signal_actor):
pass
h = serve.run(Ingress.bind(inner.bind()))
resp = h.remote()
with pytest.raises(TimeoutError):
resp.result(timeout_s=0.5)
resp.cancel()
ray.get(inner_signal_actor.wait.remote(), timeout=10)
ray.get(outer_signal_actor.wait.remote(), timeout=10)
@pytest.mark.skipif(
RAY_SERVE_FORCE_LOCAL_TESTING_MODE,
reason="local_testing_mode doesn't implement recursive cancellation",
)
def test_recursive_cancellation_during_assignment(serve_instance):
signal = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class Counter:
def __init__(self):
self._count = 0
async def __call__(self):
self._count += 1
await signal.wait.remote()
def get_count(self):
return self._count
@serve.deployment
class Ingress:
def __init__(self, handle):
self._handle = handle
async def __call__(self):
self._handle.remote()
await signal.wait.remote()
return "hi"
async def get_count(self):
return await self._handle.get_count.remote()
async def check_requests_pending_assignment_cache(self):
requests_pending_assignment = ray.serve.context._requests_pending_assignment
return {k: list(v.keys()) for k, v in requests_pending_assignment.items()}
h = serve.run(Ingress.bind(Counter.bind()))
# Send two requests to Ingress. The second should be queued and
# pending assignment at Ingress because max ongoing requests for
# Counter is only 1.
tlog("Sending two requests to Ingress.")
resp1 = h.remote()
with pytest.raises(TimeoutError):
resp1.result(timeout_s=0.5)
resp2 = h.remote()
with pytest.raises(TimeoutError):
resp2.result(timeout_s=0.5)
# Cancel second request, which should be pending assignment.
tlog("Canceling second request.")
resp2.cancel()
# Release signal so that the first request can complete, and any new
# requests to Counter can be let through
tlog("Releasing signal.")
ray.get(signal.send.remote())
assert resp1.result() == "hi"
# The second request, even though it was pending assignment to a
# Counter replica, should have been properly canceled. Confirm this
# by making sure that no more calls to __call__ were made
for _ in range(10):
assert h.get_count.remote().result() == 1
tlog("Confirmed second request was properly canceled.")
# Check that cache was cleared so there are no memory leaks
requests_pending_assignment = (
h.check_requests_pending_assignment_cache.remote().result()
)
for k, v in requests_pending_assignment.items():
assert len(v) == 0, f"Request {k} has in flight requests in cache: {v}"
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))