Files
ray-project--ray/python/ray/serve/tests/unit/test_batching.py
T
2026-07-13 13:17:40 +08:00

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__]))