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

239 lines
8.7 KiB
Python

import sys
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from urllib.parse import urljoin
import httpx
import pytest
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from starlette.requests import Request
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.test_utils import get_application_url
from ray.serve.exceptions import BackPressureError
def test_handle_backpressure(serve_instance):
"""Requests should raise a BackPressureError once the limit is reached."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Deployment:
async def __call__(self, msg: str) -> str:
await signal_actor.wait.remote()
return msg
handle = serve.run(Deployment.bind())
# First response should block. Until the signal is sent, all subsequent requests
# will be queued in the handle.
first_response = handle.remote("hi-1")
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# Check that beyond the 1st queued request, others are dropped due to backpressure.
second_response = handle.remote("hi-2")
# Wait until "hi-2" is actually registered as queued in the router before
# sending more requests. The router processes the request asynchronously on
# its own event loop thread, so without this the requests below can race
# ahead and get queued themselves (blocking forever) instead of being
# rejected with backpressure.
wait_for_condition(
lambda: handle._router._asyncio_router._metrics_manager.num_queued_requests == 1
)
for _ in range(10):
with pytest.raises(BackPressureError):
handle.remote().result()
# Send the signal; the first request will be unblocked and the second should
# subsequently get scheduled and executed.
ray.get(signal_actor.send.remote())
assert first_response.result() == "hi-1"
assert second_response.result() == "hi-2"
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
def test_http_backpressure(serve_instance):
"""Requests should return a 503 once the limit is reached."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Deployment:
async def __call__(self, request: Request) -> str:
msg = (await request.json())["msg"]
await signal_actor.wait.remote()
return msg
serve.run(Deployment.bind())
def send_request(msg: str = "hi"):
application_url = get_application_url()
r = httpx.request("GET", application_url, json={"msg": msg}, timeout=30.0)
return r.status_code, r.text
with ThreadPoolExecutor(max_workers=5) as exc:
# First response should block. Until the signal is sent, all subsequent
# requests will be queued in the handle.
first_fut = exc.submit(send_request, "hi-1")
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
done, _ = wait([first_fut], timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
# Second request should get queued.
second_fut = exc.submit(send_request, "hi-2")
done, _ = wait(
[first_fut, second_fut], timeout=0.1, return_when=FIRST_COMPLETED
)
assert len(done) == 0
# Check that beyond the 1st queued request, others are dropped due to
# backpressure.
for _ in range(10):
rejected_fut = exc.submit(send_request, "hi-err")
status_code, text = rejected_fut.result()
assert status_code == 503
assert text.startswith("Request dropped due to backpressure")
# Send the signal; the first request will be unblocked and the second
# should subsequently get scheduled and executed.
ray.get(signal_actor.send.remote())
assert first_fut.result() == (200, "hi-1")
assert second_fut.result() == (200, "hi-2")
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
def test_model_composition_backpressure(serve_instance):
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Child:
async def __call__(self):
await signal_actor.wait.remote()
return "ok"
@serve.deployment
class Parent:
def __init__(self, child):
self.child = child
async def __call__(self):
return await self.child.remote()
def send_request():
return httpx.get(get_application_url())
serve.run(Parent.bind(child=Child.bind()))
with ThreadPoolExecutor(max_workers=3) as exc:
# Send first request, wait for it to be blocked while executing.
executing_fut = exc.submit(send_request)
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
done, _ = wait([executing_fut], timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
# Send second request, it should get queued.
queued_fut = exc.submit(send_request)
done, _ = wait(
[executing_fut, queued_fut], timeout=0.1, return_when=FIRST_COMPLETED
)
assert len(done) == 0
# Send third request, it should get rejected.
rejected_fut = exc.submit(send_request)
assert rejected_fut.result().status_code == 503
# Send signal, check the two requests succeed.
ray.get(signal_actor.send.remote(clear=False))
assert executing_fut.result().status_code == 200
assert executing_fut.result().text == "ok"
assert queued_fut.result().status_code == 200
assert queued_fut.result().text == "ok"
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
@pytest.mark.parametrize("request_type", ["async_non_gen", "sync_non_gen"])
def test_model_composition_backpressure_with_fastapi(serve_instance, request_type):
"""Tests backpressure behavior with FastAPI model composition.
Tests that when a Child deployment with max_ongoing_requests=1 and max_queued_requests=1
is called through a Parent FastAPI deployment:
1. First request blocks while executing
2. Second request gets queued
3. Third request gets rejected with 503 status code
4. After unblocking, first two requests complete successfully
Tests both async and sync non-generator endpoints.
"""
signal_actor = SignalActor.remote()
app = FastAPI()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Child:
async def __call__(self):
await signal_actor.wait.remote()
return "ok"
@serve.deployment
@serve.ingress(app)
class Parent:
def __init__(self, child):
self.child = child
@app.get("/async_non_gen")
async def async_non_gen(self):
result = await self.child.remote()
return PlainTextResponse(result)
@app.get("/sync_non_gen")
def sync_non_gen(self):
result = self.child.remote().result()
return PlainTextResponse(result)
def send_request():
url_map = {
"async_non_gen": urljoin(get_application_url(), "async_non_gen"),
"sync_non_gen": urljoin(get_application_url(), "sync_non_gen"),
}
resp = httpx.get(url_map[request_type])
return resp
serve.run(Parent.bind(child=Child.bind()))
with ThreadPoolExecutor(max_workers=3) as exc:
executing_fut = exc.submit(send_request)
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
done, _ = wait([executing_fut], timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
queued_fut = exc.submit(send_request)
done, _ = wait(
[executing_fut, queued_fut], timeout=0.1, return_when=FIRST_COMPLETED
)
assert len(done) == 0
rejected_fut = exc.submit(send_request)
assert rejected_fut.result().status_code == 503
# Send signal, let the two requests succeed.
ray.get(signal_actor.send.remote())
assert executing_fut.result().status_code == 200
assert executing_fut.result().text == "ok"
assert queued_fut.result().status_code == 200
assert queued_fut.result().text == "ok"
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))