960 lines
31 KiB
Python
960 lines
31 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import List
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.utils import get_or_create_event_loop
|
|
from ray.serve._private.common import DeploymentID, ReplicaID
|
|
from ray.serve._private.config import DeploymentConfig
|
|
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
|
from ray.serve.batching import _BatchQueue
|
|
from ray.serve.exceptions import RayServeException
|
|
|
|
# Setup the global replica context for the test.
|
|
default_deployment_config = DeploymentConfig()
|
|
ray.serve.context._set_internal_replica_context(
|
|
replica_id=ReplicaID(unique_id="test", deployment_id=DeploymentID(name="test")),
|
|
servable_object=None,
|
|
_deployment_config=default_deployment_config,
|
|
rank=0,
|
|
world_size=1,
|
|
)
|
|
|
|
|
|
class FakeStream:
|
|
def __init__(self):
|
|
self.messages = []
|
|
|
|
def write(self, buf):
|
|
self.messages.append(buf)
|
|
|
|
def reset_message(self):
|
|
self.messages = []
|
|
|
|
|
|
# We use a single event loop for the entire test session. Without this
|
|
# fixture, the event loop is sometimes prematurely terminated by pytest.
|
|
@pytest.fixture(scope="session")
|
|
def event_loop():
|
|
loop = get_or_create_event_loop()
|
|
yield loop
|
|
loop.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decorator_validation():
|
|
@serve.batch
|
|
async def function():
|
|
pass
|
|
|
|
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.5)
|
|
async def function2():
|
|
pass
|
|
|
|
class Class:
|
|
@serve.batch
|
|
async def method(self):
|
|
pass
|
|
|
|
class Class2:
|
|
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.5)
|
|
async def method(self):
|
|
pass
|
|
|
|
with pytest.raises(TypeError, match="async def"):
|
|
|
|
@serve.batch
|
|
def non_async_function():
|
|
pass
|
|
|
|
with pytest.raises(TypeError, match="async def"):
|
|
|
|
class NotAsync:
|
|
@serve.batch
|
|
def method(self, requests):
|
|
pass
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
class ZeroBatch:
|
|
@serve.batch(max_batch_size=0)
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
class FloatNonIntBatch:
|
|
@serve.batch(max_batch_size=1.1)
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
class FloatIntegerBatch:
|
|
@serve.batch(max_batch_size=1.0)
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
class NegativeTimeout:
|
|
@serve.batch(batch_wait_timeout_s=-0.1)
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
class FloatZeroTimeout:
|
|
@serve.batch(batch_wait_timeout_s=0.0)
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
class IntZeroTimeout:
|
|
@serve.batch(batch_wait_timeout_s=0)
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
class NonTimeout:
|
|
@serve.batch(batch_wait_timeout_s="a")
|
|
async def method(self, requests):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
async def test_batch_size_one_long_timeout(use_class):
|
|
@serve.batch(max_batch_size=1, batch_wait_timeout_s=1000)
|
|
async def long_timeout(requests):
|
|
if "raise" in requests:
|
|
_ = 1 / 0
|
|
return requests
|
|
|
|
class LongTimeout:
|
|
@serve.batch(max_batch_size=1, batch_wait_timeout_s=1000)
|
|
async def long_timeout(self, requests):
|
|
if "raise" in requests:
|
|
_ = 1 / 0
|
|
return requests
|
|
|
|
cls = LongTimeout()
|
|
|
|
async def call(arg):
|
|
if use_class:
|
|
return await cls.long_timeout(arg)
|
|
else:
|
|
return await long_timeout(arg)
|
|
|
|
assert await call("hi") == "hi"
|
|
with pytest.raises(ZeroDivisionError):
|
|
await call("raise")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
async def test_batch_size_multiple_zero_timeout(use_class):
|
|
block_execution_event = asyncio.Event()
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=0)
|
|
async def zero_timeout(requests):
|
|
await block_execution_event.wait()
|
|
if "raise" in requests:
|
|
_ = 1 / 0
|
|
return requests
|
|
|
|
class ZeroTimeout:
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=0)
|
|
async def zero_timeout(self, requests):
|
|
await block_execution_event.wait()
|
|
if "raise" in requests:
|
|
_ = 1 / 0
|
|
return requests
|
|
|
|
cls = ZeroTimeout()
|
|
|
|
async def call(arg):
|
|
if use_class:
|
|
return await cls.zero_timeout(arg)
|
|
else:
|
|
return await zero_timeout(arg)
|
|
|
|
block_execution_event.set()
|
|
assert await call("hi") == "hi"
|
|
with pytest.raises(ZeroDivisionError):
|
|
await call("raise")
|
|
block_execution_event.clear()
|
|
|
|
# Check that 2 requests will be executed together if available.
|
|
# The first should cause a size-one batch to be executed, then
|
|
# the next two should be executed together (signaled by both
|
|
# having the exception).
|
|
t1 = get_or_create_event_loop().create_task(call("hi1"))
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(asyncio.shield(t1), timeout=0.001)
|
|
|
|
t2 = get_or_create_event_loop().create_task(call("hi2"))
|
|
t3 = get_or_create_event_loop().create_task(call("raise"))
|
|
|
|
block_execution_event.set()
|
|
assert await t1 == "hi1"
|
|
|
|
with pytest.raises(ZeroDivisionError):
|
|
await t2
|
|
with pytest.raises(ZeroDivisionError):
|
|
await t3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_timeout_empty_queue():
|
|
"""Check that Serve waits when creating batches.
|
|
|
|
Serve should wait a full batch_wait_timeout_s after receiving the first
|
|
request in the next batch before processing the batch.
|
|
"""
|
|
|
|
@serve.batch(max_batch_size=10, batch_wait_timeout_s=0.25)
|
|
async def no_op(requests):
|
|
return ["No-op"] * len(requests)
|
|
|
|
num_iterations = 2
|
|
for iteration in range(num_iterations):
|
|
tasks = [get_or_create_event_loop().create_task(no_op(None)) for _ in range(9)]
|
|
done, _ = await asyncio.wait(tasks, timeout=0.05)
|
|
|
|
# Due to the long timeout, none of the tasks should finish until a tenth
|
|
# request is submitted
|
|
assert len(done) == 0
|
|
|
|
tasks.append(get_or_create_event_loop().create_task(no_op(None)))
|
|
done, _ = await asyncio.wait(tasks, timeout=0.05)
|
|
|
|
# All the timeout tasks should be finished
|
|
assert set(tasks) == set(done)
|
|
assert all(t.result() == "No-op" for t in tasks)
|
|
|
|
if iteration < num_iterations - 1:
|
|
# Leave queue empty for batch_wait_timeout_s between batches
|
|
time.sleep(0.25)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_wait_queue_exceeds_batch_size_race_condition():
|
|
"""Check that the wait queue can exceed the batch size.
|
|
|
|
This test was added to guard against a race condition documented in
|
|
https://github.com/ray-project/ray/pull/42705#discussion_r1466653910.
|
|
"""
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=10000)
|
|
async def no_op(requests):
|
|
return ["No-op"] * len(requests)
|
|
|
|
tasks = [get_or_create_event_loop().create_task(no_op(None)) for _ in range(10)]
|
|
|
|
# All the tasks should finish.
|
|
done, pending = await asyncio.wait(tasks, timeout=0.5)
|
|
assert len(done) == len(tasks)
|
|
assert len(pending) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
async def test_batch_size_multiple_long_timeout(use_class):
|
|
@serve.batch(max_batch_size=3, batch_wait_timeout_s=1000)
|
|
async def long_timeout(requests):
|
|
if "raise" in requests:
|
|
_ = 1 / 0
|
|
return requests
|
|
|
|
class LongTimeout:
|
|
@serve.batch(max_batch_size=3, batch_wait_timeout_s=1000)
|
|
async def long_timeout(self, requests):
|
|
if "raise" in requests:
|
|
_ = 1 / 0
|
|
return requests
|
|
|
|
cls = LongTimeout()
|
|
|
|
async def call(arg):
|
|
if use_class:
|
|
return await cls.long_timeout(arg)
|
|
else:
|
|
return await long_timeout(arg)
|
|
|
|
t1 = get_or_create_event_loop().create_task(call("hi1"))
|
|
t2 = get_or_create_event_loop().create_task(call("hi2"))
|
|
done, pending = await asyncio.wait([t1, t2], timeout=0.1)
|
|
assert len(done) == 0
|
|
t3 = get_or_create_event_loop().create_task(call("hi3"))
|
|
done, pending = await asyncio.wait([t1, t2, t3], timeout=100)
|
|
assert set(done) == {t1, t2, t3}
|
|
assert [t1.result(), t2.result(), t3.result()] == ["hi1", "hi2", "hi3"]
|
|
|
|
t1 = get_or_create_event_loop().create_task(call("hi1"))
|
|
t2 = get_or_create_event_loop().create_task(call("raise"))
|
|
done, pending = await asyncio.wait([t1, t2], timeout=0.1)
|
|
assert len(done) == 0
|
|
t3 = get_or_create_event_loop().create_task(call("hi3"))
|
|
done, pending = await asyncio.wait([t1, t2, t3], timeout=100)
|
|
assert set(done) == {t1, t2, t3}
|
|
assert all(isinstance(t.exception(), ZeroDivisionError) for t in done)
|
|
with pytest.raises(ZeroDivisionError):
|
|
t1.result()
|
|
with pytest.raises(ZeroDivisionError):
|
|
t2.result()
|
|
with pytest.raises(ZeroDivisionError):
|
|
t3.result()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["args", "kwargs", "mixed", "out-of-order"])
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
async def test_batch_args_kwargs(mode, use_class):
|
|
if use_class:
|
|
|
|
class MultipleArgs:
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def method(self, key1, key2):
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
instance = MultipleArgs()
|
|
func = instance.method
|
|
|
|
else:
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def func(key1, key2):
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
if mode == "args":
|
|
coros = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
elif mode == "kwargs":
|
|
coros = [func(key1="hi1", key2="hi2"), func(key1="hi3", key2="hi4")]
|
|
elif mode == "mixed":
|
|
coros = [func("hi1", key2="hi2"), func("hi3", key2="hi4")]
|
|
elif mode == "out-of-order":
|
|
coros = [func(key2="hi2", key1="hi1"), func(key2="hi4", key1="hi3")]
|
|
|
|
result = await asyncio.gather(*coros)
|
|
assert result == [("hi1", "hi2"), ("hi3", "hi4")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
@pytest.mark.parametrize("use_gen", [True, False])
|
|
async def test_batch_cancellation(use_class, use_gen):
|
|
block_requests = asyncio.Event()
|
|
request_was_cancelled = True
|
|
|
|
async def unary_implementation(key1, key2):
|
|
nonlocal block_requests, request_was_cancelled
|
|
await block_requests.wait()
|
|
request_was_cancelled = False
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
async def streaming_implementation(key1, key2):
|
|
nonlocal block_requests, request_was_cancelled
|
|
await block_requests.wait()
|
|
request_was_cancelled = False
|
|
yield [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
if use_class:
|
|
|
|
class MultipleArgs:
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def unary_method(self, key1, key2):
|
|
return await unary_implementation(key1, key2)
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def streaming_method(self, key1, key2):
|
|
async for value in streaming_implementation(key1, key2):
|
|
yield value
|
|
|
|
instance = MultipleArgs()
|
|
|
|
if use_gen:
|
|
func = instance.streaming_method
|
|
else:
|
|
func = instance.unary_method
|
|
|
|
else:
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def unary_func(key1, key2):
|
|
return await unary_implementation(key1, key2)
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def streaming_func(key1, key2):
|
|
async for value in streaming_implementation(key1, key2):
|
|
yield value
|
|
|
|
if use_gen:
|
|
func = streaming_func
|
|
else:
|
|
func = unary_func
|
|
|
|
if use_gen:
|
|
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
|
|
else:
|
|
tasks = [
|
|
asyncio.create_task(func("hi1", "hi2")),
|
|
asyncio.create_task(func("hi3", "hi4")),
|
|
]
|
|
|
|
print("Submitted requests.")
|
|
|
|
# The requests should be blocked on the long request_timeout
|
|
done, pending = await asyncio.wait(tasks, timeout=0.01)
|
|
assert len(done) == 0
|
|
assert len(pending) == 2
|
|
|
|
print("Requests are blocked, as expected.")
|
|
|
|
# Cancel the first request. The second request should still be blocked on
|
|
# the long request_timeout
|
|
tasks[0].cancel()
|
|
pending, done = await asyncio.wait(tasks, timeout=0.01)
|
|
assert len(done) == 1
|
|
assert len(pending) == 1
|
|
|
|
print("Cancelled first request.")
|
|
|
|
# Cancel the second request. Both requests should be done.
|
|
tasks[1].cancel()
|
|
done, pending = await asyncio.wait(tasks, timeout=0.01)
|
|
assert len(done) == 2
|
|
assert len(pending) == 0
|
|
|
|
print("Cancelled second request. Sending new requests with no timeout.")
|
|
|
|
# Sanity check that the request was actually cancelled.
|
|
assert request_was_cancelled
|
|
|
|
# Unblock requests. The requests should succeed.
|
|
block_requests.set()
|
|
|
|
if use_gen:
|
|
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
|
|
else:
|
|
tasks = [
|
|
asyncio.create_task(func("hi1", "hi2")),
|
|
asyncio.create_task(func("hi3", "hi4")),
|
|
]
|
|
|
|
result = await asyncio.gather(*tasks)
|
|
assert result == [("hi1", "hi2"), ("hi3", "hi4")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
@pytest.mark.parametrize("use_gen", [True, False])
|
|
async def test_cancellation_after_error(use_class, use_gen):
|
|
"""Cancelling a request after it errors should be supported."""
|
|
|
|
raise_error = asyncio.Event()
|
|
|
|
async def unary_implementation(key1, key2):
|
|
if not raise_error.is_set():
|
|
raise ValueError()
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
async def streaming_implementation(key1, key2):
|
|
if not raise_error.is_set():
|
|
raise ValueError()
|
|
yield [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
if use_class:
|
|
|
|
class MultipleArgs:
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def unary_method(self, key1, key2):
|
|
return await unary_implementation(key1, key2)
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def streaming_method(self, key1, key2):
|
|
async for value in streaming_implementation(key1, key2):
|
|
yield value
|
|
|
|
instance = MultipleArgs()
|
|
|
|
if use_gen:
|
|
func = instance.streaming_method
|
|
else:
|
|
func = instance.unary_method
|
|
|
|
else:
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def unary_func(key1, key2):
|
|
return await unary_implementation(key1, key2)
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def streaming_func(key1, key2):
|
|
async for value in streaming_implementation(key1, key2):
|
|
yield value
|
|
|
|
if use_gen:
|
|
func = streaming_func
|
|
else:
|
|
func = unary_func
|
|
|
|
# Submit requests and then cancel them.
|
|
if use_gen:
|
|
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
|
|
else:
|
|
tasks = [
|
|
asyncio.create_task(func("hi1", "hi2")),
|
|
asyncio.create_task(func("hi3", "hi4")),
|
|
]
|
|
|
|
print("Submitted initial batch of requests.")
|
|
|
|
for task in tasks:
|
|
task.cancel()
|
|
|
|
print("Closed initial batch of requests.")
|
|
|
|
raise_error.set()
|
|
|
|
# Submit requests and check that they still work.
|
|
if use_gen:
|
|
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
|
|
else:
|
|
tasks = [
|
|
asyncio.create_task(func("hi1", "hi2")),
|
|
asyncio.create_task(func("hi3", "hi4")),
|
|
]
|
|
|
|
print("Submitted new batch of requests.")
|
|
|
|
result = await asyncio.gather(*tasks)
|
|
assert result == [("hi1", "hi2"), ("hi3", "hi4")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
async def test_batch_setters(use_class):
|
|
if use_class:
|
|
|
|
class C:
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def method(self, key1, key2):
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
instance = C()
|
|
func = instance.method
|
|
|
|
else:
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def func(key1, key2):
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
assert func._get_max_batch_size() == 2
|
|
assert func._get_batch_wait_timeout_s() == 1000
|
|
|
|
# @serve.batch should create batches of size 2
|
|
tasks = [
|
|
get_or_create_event_loop().create_task(func("hi1", "hi2")),
|
|
get_or_create_event_loop().create_task(func("hi3", "hi4")),
|
|
]
|
|
done, pending = await asyncio.wait(tasks, timeout=0.1)
|
|
assert len(pending) == 0
|
|
assert {task.result() for task in done} == {("hi1", "hi2"), ("hi3", "hi4")}
|
|
|
|
# Set new values
|
|
func.set_max_batch_size(3)
|
|
func.set_batch_wait_timeout_s(15000)
|
|
|
|
assert func._get_max_batch_size() == 3
|
|
assert func._get_batch_wait_timeout_s() == 15000
|
|
|
|
# @serve.batch should create batches of size 3
|
|
tasks = [
|
|
get_or_create_event_loop().create_task(func("hi1", "hi2")),
|
|
get_or_create_event_loop().create_task(func("hi3", "hi4")),
|
|
get_or_create_event_loop().create_task(func("hi5", "hi6")),
|
|
]
|
|
done, pending = await asyncio.wait(tasks, timeout=0.1)
|
|
assert len(pending) == 0
|
|
assert {task.result() for task in done} == {
|
|
("hi1", "hi2"),
|
|
("hi3", "hi4"),
|
|
("hi5", "hi6"),
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_use_earliest_setters():
|
|
"""@serve.batch should use the right settings when constructing a batch.
|
|
|
|
When the @serve.batch setters get called before a batch has started
|
|
accumulating, the next batch should use the setters' values. When they
|
|
get called while a batch is accumulating, the previous values should be
|
|
used.
|
|
"""
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def func(key1, key2):
|
|
return [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
assert func._get_max_batch_size() == 2
|
|
assert func._get_batch_wait_timeout_s() == 1000
|
|
|
|
# Set new values
|
|
func.set_max_batch_size(3)
|
|
func.set_batch_wait_timeout_s(15000)
|
|
|
|
assert func._get_max_batch_size() == 3
|
|
assert func._get_batch_wait_timeout_s() == 15000
|
|
|
|
# Should create batches of size 3, even if setters are called while
|
|
# batch is accumulated
|
|
tasks = [
|
|
get_or_create_event_loop().create_task(func("hi1", "hi2")),
|
|
get_or_create_event_loop().create_task(func("hi3", "hi4")),
|
|
]
|
|
|
|
# Batch should be waiting for last request
|
|
done, pending = await asyncio.wait(tasks, timeout=0.1)
|
|
assert len(done) == 0 and len(pending) == 2
|
|
|
|
func.set_max_batch_size(1)
|
|
func.set_batch_wait_timeout_s(0)
|
|
|
|
assert func._get_max_batch_size() == 1
|
|
assert func._get_batch_wait_timeout_s() == 0
|
|
|
|
# Batch should still be waiting for last request
|
|
done, pending = await asyncio.wait(pending, timeout=0.1)
|
|
assert len(done) == 0 and len(pending) == 2
|
|
|
|
# Batch should execute after last request
|
|
pending.add(get_or_create_event_loop().create_task(func("hi5", "hi6")))
|
|
done, pending = await asyncio.wait(pending, timeout=0.1)
|
|
assert len(pending) == 0
|
|
assert {task.result() for task in done} == {
|
|
("hi1", "hi2"),
|
|
("hi3", "hi4"),
|
|
("hi5", "hi6"),
|
|
}
|
|
|
|
# Next batch should use updated values
|
|
tasks = [get_or_create_event_loop().create_task(func("hi1", "hi2"))]
|
|
done, pending = await asyncio.wait(tasks, timeout=0.1)
|
|
assert len(done) == 1 and len(pending) == 0
|
|
assert done.pop().result() == ("hi1", "hi2")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["args", "kwargs", "mixed", "out-of-order"])
|
|
@pytest.mark.parametrize("use_class", [True, False])
|
|
@pytest.mark.parametrize("generator_length", [0, 2, 5])
|
|
async def test_batch_generator_basic(mode, use_class, generator_length):
|
|
if use_class:
|
|
|
|
class MultipleArgs:
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def method(self, key1, key2):
|
|
for gen_idx in range(generator_length):
|
|
yield [(gen_idx, key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
instance = MultipleArgs()
|
|
func = instance.method
|
|
|
|
else:
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def func(key1, key2):
|
|
for gen_idx in range(generator_length):
|
|
yield [(gen_idx, key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
if mode == "args":
|
|
generators = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
elif mode == "kwargs":
|
|
generators = [func(key1="hi1", key2="hi2"), func(key1="hi3", key2="hi4")]
|
|
elif mode == "mixed":
|
|
generators = [func("hi1", key2="hi2"), func("hi3", key2="hi4")]
|
|
elif mode == "out-of-order":
|
|
generators = [func(key2="hi2", key1="hi1"), func(key2="hi4", key1="hi3")]
|
|
|
|
results = [
|
|
[result async for result in generators[0]],
|
|
[result async for result in generators[1]],
|
|
]
|
|
|
|
assert results == [
|
|
[(gen_idx, "hi1", "hi2") for gen_idx in range(generator_length)],
|
|
[(gen_idx, "hi3", "hi4") for gen_idx in range(generator_length)],
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("error_type", ["runtime_error", "mismatched_lengths"])
|
|
async def test_batch_generator_exceptions(error_type):
|
|
GENERATOR_LENGTH = 5
|
|
ERROR_IDX = 2
|
|
ERROR_MSG = "Testing error"
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def func(key1, key2):
|
|
for gen_idx in range(GENERATOR_LENGTH):
|
|
results = [(gen_idx, key1[i], key2[i]) for i in range(len(key1))]
|
|
if gen_idx == ERROR_IDX:
|
|
if error_type == "runtime_error":
|
|
raise RuntimeError(ERROR_MSG)
|
|
elif error_type == "mismatched_lengths":
|
|
yield results * 2
|
|
yield results
|
|
|
|
generators = [func("hi1", "hi2"), func("hi3", "hi4")]
|
|
|
|
for generator in generators:
|
|
for _ in range(ERROR_IDX):
|
|
await generator.__anext__()
|
|
|
|
if error_type == "runtime_error":
|
|
with pytest.raises(RuntimeError, match=ERROR_MSG):
|
|
await generator.__anext__()
|
|
elif error_type == "mismatched_lengths":
|
|
with pytest.raises(RayServeException):
|
|
await generator.__anext__()
|
|
|
|
with pytest.raises(StopAsyncIteration):
|
|
await generator.__anext__()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("stop_token", [StopAsyncIteration, StopIteration])
|
|
async def test_batch_generator_early_termination(stop_token):
|
|
NUM_CALLERS = 4
|
|
|
|
event = asyncio.Event()
|
|
|
|
@serve.batch(max_batch_size=NUM_CALLERS, batch_wait_timeout_s=1000)
|
|
async def sequential_terminator(ids: List[int]):
|
|
"""Terminates callers one-after-another in order of call."""
|
|
|
|
for num_finished_callers in range(1, NUM_CALLERS + 1):
|
|
event.clear()
|
|
responses = [stop_token for _ in range(num_finished_callers)]
|
|
responses += [ids[idx] for idx in range(num_finished_callers, NUM_CALLERS)]
|
|
yield responses
|
|
await event.wait()
|
|
|
|
ids = list(range(NUM_CALLERS))
|
|
generators = [sequential_terminator(id) for id in ids]
|
|
for id, generator in zip(ids, generators):
|
|
async for result in generator:
|
|
assert result == id
|
|
|
|
# Each terminated caller frees the sequential_terminator to process
|
|
# another iteration.
|
|
event.set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_generator_setters():
|
|
"""@serve.batch setters should succeed while the current batch streams."""
|
|
|
|
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
|
|
async def yield_three_times(key1, key2):
|
|
for _ in range(3):
|
|
yield [(key1[i], key2[i]) for i in range(len(key1))]
|
|
|
|
assert yield_three_times._get_max_batch_size() == 2
|
|
assert yield_three_times._get_batch_wait_timeout_s() == 1000
|
|
|
|
args_list = [("hi1", "hi2"), ("hi3", "hi4")]
|
|
coros = [yield_three_times(*args) for args in args_list]
|
|
|
|
# Partially consume generators
|
|
for coro, expected_result in zip(coros, args_list):
|
|
for _ in range(2):
|
|
await coro.__anext__() == expected_result
|
|
|
|
# Set new values
|
|
yield_three_times.set_max_batch_size(3)
|
|
yield_three_times.set_batch_wait_timeout_s(15000)
|
|
|
|
assert yield_three_times._get_max_batch_size() == 3
|
|
assert yield_three_times._get_batch_wait_timeout_s() == 15000
|
|
|
|
# Execute three more requests
|
|
args_list_2 = [("hi1", "hi2"), ("hi3", "hi4"), ("hi5", "hi6")]
|
|
coros_2 = [yield_three_times(*args) for args in args_list_2]
|
|
|
|
# Finish consuming original requests
|
|
for coro, expected_result in zip(coros, args_list):
|
|
await coro.__anext__() == expected_result
|
|
with pytest.raises(StopAsyncIteration):
|
|
await coro.__anext__()
|
|
|
|
# Consume new requests
|
|
for coro, expected_result in zip(coros_2, args_list_2):
|
|
for _ in range(3):
|
|
await coro.__anext__() == expected_result
|
|
with pytest.raises(StopAsyncIteration):
|
|
await coro.__anext__()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_size_fn_deferred_item_early_break():
|
|
batches_processed = []
|
|
|
|
@serve.batch(
|
|
max_batch_size=10,
|
|
batch_wait_timeout_s=0.05,
|
|
batch_size_fn=lambda items: sum(item["size"] for item in items),
|
|
)
|
|
async def batch_handler(requests):
|
|
batches_processed.append([req["value"] for req in requests])
|
|
return [req["value"] for req in requests]
|
|
|
|
# Request 1: size=6 (fits in batch)
|
|
# Request 2: size=6 (would make total 12 > 10, should be deferred)
|
|
# Each should be processed in its own batch
|
|
t1 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 6, "value": "first"})
|
|
)
|
|
t2 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 6, "value": "second"})
|
|
)
|
|
|
|
done, pending = await asyncio.wait([t1, t2], timeout=1.0)
|
|
|
|
assert len(done) == 2, "Both tasks should complete"
|
|
assert len(pending) == 0
|
|
|
|
results = {t1.result(), t2.result()}
|
|
assert results == {"first", "second"}
|
|
|
|
# Verify they were processed in separate batches due to size constraint
|
|
assert (
|
|
len(batches_processed) == 2
|
|
), f"Expected 2 separate batches, got {batches_processed}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_size_fn_fail_to_fit():
|
|
batches_processed = []
|
|
|
|
@serve.batch(
|
|
max_batch_size=10,
|
|
batch_wait_timeout_s=0.05,
|
|
batch_size_fn=lambda items: sum(item["size"] for item in items),
|
|
)
|
|
async def batch_handler(requests):
|
|
batches_processed.append([req["value"] for req in requests])
|
|
return [req["value"] for req in requests]
|
|
|
|
# Request 1: size=6 (fits in batch)
|
|
# Request 2: size=6 (would make total 12 > 10, should be deferred)
|
|
# Each should be processed in its own batch
|
|
t1 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 6, "value": "first"})
|
|
)
|
|
t2 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 12, "value": "second"})
|
|
)
|
|
|
|
t1_result = await t1
|
|
assert t1_result == "first"
|
|
with pytest.raises(RuntimeError):
|
|
await t2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_size_fn_multiple_items_fit():
|
|
"""Test that multiple items are batched together when they fit within max_batch_size."""
|
|
batches_seen = []
|
|
|
|
@serve.batch(
|
|
max_batch_size=20,
|
|
batch_wait_timeout_s=0.1,
|
|
batch_size_fn=lambda items: sum(item["size"] for item in items),
|
|
)
|
|
async def batch_handler(requests):
|
|
batch_values = [req["value"] for req in requests]
|
|
batches_seen.append(batch_values)
|
|
return batch_values
|
|
|
|
# All three requests fit: 5 + 6 + 7 = 18 <= 20
|
|
t1 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 5, "value": "a"})
|
|
)
|
|
t2 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 6, "value": "b"})
|
|
)
|
|
t3 = get_or_create_event_loop().create_task(
|
|
batch_handler({"size": 7, "value": "c"})
|
|
)
|
|
|
|
done, pending = await asyncio.wait([t1, t2, t3], timeout=1.0)
|
|
assert len(done) == 3
|
|
assert len(pending) == 0
|
|
|
|
# All three should be in the same batch since they fit
|
|
assert len(batches_seen) == 1, f"Expected 1 batch, got {len(batches_seen)}"
|
|
assert set(batches_seen[0]) == {"a", "b", "c"}
|
|
|
|
|
|
def test_warn_if_max_batch_size_exceeds_max_ongoing_requests():
|
|
"""Test warn_if_max_batch_size_exceeds_max_ongoing_requests() logged the warning
|
|
message correctly.
|
|
|
|
When the queue starts with or updated `max_batch_size` to be larger than
|
|
max_ongoing_requests, log the warning to suggest configuring `max_ongoing_requests`.
|
|
When the queue starts with or updated `max_batch_size` to be smaller or equal than
|
|
max_ongoing_requests, no warning should be logged.
|
|
"""
|
|
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
|
stream = FakeStream()
|
|
stream_handler = logging.StreamHandler(stream)
|
|
logger.addHandler(stream_handler)
|
|
bound = default_deployment_config.max_ongoing_requests
|
|
over_bound = bound + 1
|
|
under_bound = bound - 1
|
|
over_bound_warning_message = (
|
|
f"`max_batch_size` ({over_bound}) * `max_concurrent_batches` "
|
|
f"({1}) is larger than `max_ongoing_requests` "
|
|
f"({bound}). This means the replica will never achieve "
|
|
"the configured `max_batch_size` concurrently. Please update "
|
|
"`max_ongoing_requests` to be >= `max_batch_size` * `max_concurrent_batches`.\n"
|
|
)
|
|
|
|
# Start queue above the bound will log warning. Start at under or at the bound will
|
|
# not log warning
|
|
for max_batch_size in [over_bound, under_bound, bound]:
|
|
queue = _BatchQueue(
|
|
max_batch_size=max_batch_size,
|
|
batch_wait_timeout_s=1000,
|
|
max_concurrent_batches=1,
|
|
)
|
|
if max_batch_size > bound:
|
|
assert over_bound_warning_message in stream.messages
|
|
else:
|
|
assert over_bound_warning_message not in stream.messages
|
|
stream.reset_message()
|
|
|
|
# Update queue above the bound will log warning. Update at under or at the bound
|
|
# will not log warning
|
|
for max_batch_size in [over_bound, under_bound, bound]:
|
|
queue.set_max_batch_size(max_batch_size)
|
|
if max_batch_size > bound:
|
|
assert over_bound_warning_message in stream.messages
|
|
else:
|
|
assert over_bound_warning_message not in stream.messages
|
|
stream.reset_message()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|