import asyncio import math from collections.abc import Callable from concurrent.futures.thread import ThreadPoolExecutor from functools import partial from threading import Thread from typing import List, Optional import httpx import pytest from fastapi import FastAPI, Request from starlette.responses import StreamingResponse from ray import serve from ray._common.test_utils import SignalActor, async_wait_for_condition from ray.serve._private.test_utils import get_application_url from ray.serve.batching import _RuntimeSummaryStatistics from ray.serve.context import ( _get_serve_batch_request_context, _get_serve_request_context, ) def test_batching(serve_instance): @serve.deployment class BatchingExample: def __init__(self): self.count = 0 @serve.batch(max_batch_size=5, batch_wait_timeout_s=1) async def handle_batch(self, requests): self.count += 1 batch_size = len(requests) return [self.count] * batch_size async def __call__(self, request): return await self.handle_batch(request) handle = serve.run(BatchingExample.bind()) result_list = [handle.remote(1) for _ in range(20)] # since count is only updated per batch of queries # If there atleast one __call__ fn call with batch size greater than 1 # counter result will always be less than 20 assert max([r.result() for r in result_list]) < 20 def test_concurrent_batching(serve_instance): BATCHES_IN_FLIGHT = 2 MAX_BATCH_SIZE = 5 BATCH_WAIT_TIMEOUT_S = 1 MAX_REQUESTS_IN_FLIGHT = BATCHES_IN_FLIGHT * MAX_BATCH_SIZE @serve.deployment(max_ongoing_requests=MAX_REQUESTS_IN_FLIGHT * 2) class BatchingExample: def __init__(self): self.n_batches_in_flight = 0 self.n_requests_in_flight = 0 @serve.batch( max_batch_size=MAX_BATCH_SIZE, batch_wait_timeout_s=BATCH_WAIT_TIMEOUT_S, max_concurrent_batches=BATCHES_IN_FLIGHT, ) async def handle_batch(self, requests): self.n_batches_in_flight += 1 self.n_requests_in_flight += len(requests) await asyncio.sleep(0.5) out = [ (req_idx, self.n_batches_in_flight, self.n_requests_in_flight) for req_idx in requests ] await asyncio.sleep(0.5) self.n_requests_in_flight -= len(requests) self.n_batches_in_flight -= 1 return out async def __call__(self, request): return await self.handle_batch(request) handle = serve.run(BatchingExample.bind()) idxs = set(range(20)) result_futures = [handle.remote(i) for i in idxs] result_list = [future.result() for future in result_futures] out_idxs = set() for idx, batches_in_flight, requests_in_flight in result_list: out_idxs.add(idx) assert ( batches_in_flight == BATCHES_IN_FLIGHT ), f"Should have been {BATCHES_IN_FLIGHT} batches in flight at all times, got {batches_in_flight}" assert ( requests_in_flight == MAX_REQUESTS_IN_FLIGHT ), f"Should have been {MAX_REQUESTS_IN_FLIGHT} requests in flight at all times, got {requests_in_flight}" assert idxs == out_idxs, "All requests should be processed" def test_batching_exception(serve_instance): @serve.deployment class NoListReturned: def __init__(self): self.count = 0 @serve.batch(max_batch_size=5) async def handle_batch(self, requests): return len(requests) async def __call__(self, request): return await self.handle_batch(request) # Set the max batch size. handle = serve.run(NoListReturned.bind()) with pytest.raises(TypeError): assert handle.remote(1).result() @pytest.mark.asyncio async def test_batch_generator_streaming_response_integration_test(serve_instance): NUM_YIELDS = 10 @serve.deployment class Textgen: @serve.batch(max_batch_size=4, batch_wait_timeout_s=1000) async def batch_handler(self, prompts: List[str]): for _ in range(NUM_YIELDS): # Check that the batch handler can yield unhashable types prompt_responses = [{"value": prompt} for prompt in prompts] yield prompt_responses async def value_extractor(self, prompt_responses): async for prompt_response in prompt_responses: yield prompt_response["value"] async def __call__(self, request): prompt = request.query_params["prompt"] response_values = self.value_extractor(self.batch_handler(prompt)) return StreamingResponse(response_values) serve.run(Textgen.bind()) prompt_prefix = "hola" url = f"{get_application_url()}/?prompt={prompt_prefix}" with ThreadPoolExecutor() as pool: futs = [pool.submit(partial(httpx.get, url + str(idx))) for idx in range(4)] responses = [fut.result() for fut in futs] for idx, response in enumerate(responses): assert response.status_code == 200 assert response.text == "".join([prompt_prefix + str(idx)] * NUM_YIELDS) def test_batching_client_dropped_unary(serve_instance): """Test unary batching with clients that drops the connection. After requests are dropped. The next request should succeed. """ @serve.deployment class ModelUnary: @serve.batch(max_batch_size=5) async def handle_batch(self, requests): await asyncio.sleep(0.05) return ["fake-response" for _ in range(len(requests))] async def __call__(self, request): return await self.handle_batch(request) serve.run(ModelUnary.bind()) url = f"{get_application_url()}/" # Sending requests with clients that drops the connection. for _ in range(3): with pytest.raises(httpx.ReadTimeout): httpx.get(url, timeout=0.005) # The following request should succeed. resp = httpx.get(url, timeout=1) assert resp.status_code == 200 assert resp.text == "fake-response" def test_batching_client_dropped_streaming(serve_instance): """Test streaming batching with clients that drops the connection. After requests are dropped. The next request should succeed. """ @serve.deployment class ModelStreaming: @serve.batch(max_batch_size=3) async def handle_batch(self, requests): await asyncio.sleep(0.05) for i in range(10): yield [str(i) for _ in range(len(requests))] async def __call__(self, request): return StreamingResponse(self.handle_batch(request)) serve.run(ModelStreaming.bind()) url = "http://localhost:8000/" # Sending requests with clients that drops the connection. for _ in range(3): with pytest.raises((httpx.ReadTimeout, httpx.ConnectError)): httpx.get(url, timeout=0.005) # The following request should succeed. resp = httpx.get(url, timeout=1) assert resp.status_code == 200 assert resp.text == "0123456789" @pytest.mark.asyncio @pytest.mark.parametrize("max_concurrent_batches", [1, 10]) @pytest.mark.parametrize("max_batch_size", [1, 10]) @pytest.mark.parametrize("n_requests", [1, 10]) async def test_observability_helpers( serve_instance, n_requests: int, max_batch_size: int, max_concurrent_batches: int ) -> None: """Checks observability helper methods that are used for batching. Tests three observability helper methods: * _get_curr_iteration_start_times: gets the current iteration's start time. * _is_batching_task_alive: returns whether the batch-handler task is alive. * _get_handling_task_stack: returns the stack for the batch-handler task. """ signal_actor = SignalActor.remote() @serve.deployment( name="batcher", max_ongoing_requests=max_concurrent_batches * max_batch_size ) class Batcher: @serve.batch( max_batch_size=max_batch_size, max_concurrent_batches=max_concurrent_batches, batch_wait_timeout_s=0.1, ) async def handle_batch(self, requests): await signal_actor.wait.remote() # wait until the outer signal actor is released return [0] * len(requests) async def __call__(self, request): return await self.handle_batch(request) async def _get_curr_iteration_start_times(self) -> _RuntimeSummaryStatistics: return self.handle_batch._get_curr_iteration_start_times() async def _is_batching_task_alive(self) -> bool: return await self.handle_batch._is_batching_task_alive() async def _get_handling_task_stack(self) -> Optional[str]: return await self.handle_batch._get_handling_task_stack() serve.run(target=Batcher.bind(), name="app_name") handle = serve.get_deployment_handle(deployment_name="batcher", app_name="app_name") assert await handle._is_batching_task_alive.remote() min_num_batches = min( math.ceil(n_requests / max_batch_size), max_concurrent_batches ) async with httpx.AsyncClient() as client: tasks1 = await send_k_requests( signal_actor, n_requests, min_num_batches, app_name="app_name", client=client, ) prev_iter_times = await handle._get_curr_iteration_start_times.remote() await signal_actor.send.remote() # unblock the batch handler now that we have the iter times assert len(prev_iter_times.start_times) >= min_num_batches assert len(await handle._get_handling_task_stack.remote()) is not None assert await handle._is_batching_task_alive.remote() tasks2 = await send_k_requests( signal_actor, n_requests, min_num_batches, app_name="app_name", client=client, ) new_iter_times = await handle._get_curr_iteration_start_times.remote() await signal_actor.send.remote() # unblock the batch handler now that we have the iter times assert len(new_iter_times.start_times) >= min_num_batches assert len(await handle._get_handling_task_stack.remote()) is not None assert await handle._is_batching_task_alive.remote() assert new_iter_times.min_start_time > prev_iter_times.max_start_time # Cancel and await all tasks to avoid "Task exception was never retrieved" warning. # We don't need the HTTP responses, just need to clean up the tasks properly. for task in tasks1 + tasks2: task.cancel() await asyncio.gather(*tasks1, *tasks2, return_exceptions=True) async def send_k_requests( signal_actor: SignalActor, k: int, min_num_batches: float, app_name: str, client: httpx.AsyncClient, ) -> List[asyncio.Task]: """Send k requests and wait until at least min_num_batches are waiting. Returns the list of request tasks so they can be awaited by the caller after unblocking the batch handler. """ await signal_actor.send.remote(True) # type: ignore[attr-defined] tasks = [] for _ in range(k): tasks.append( asyncio.create_task( client.get(f"{get_application_url(app_name=app_name)}/") ) ) await wait_for_n_waiters( signal_actor, lambda num_waiters: num_waiters >= min_num_batches ) return tasks async def wait_for_n_waiters( signal_actor: SignalActor, condition: Callable[[int], bool] ) -> None: async def poll() -> bool: num_waiters: int = await signal_actor.cur_num_waiters.remote() # type: ignore[attr-defined] return condition(num_waiters) return await async_wait_for_condition(poll) def test_batching_request_context(serve_instance): """Test that _get_serve_batch_request_context() works correctly with batching. With 6 requests and max_batch_size=3, Serve should create 2 batches processed in parallel. Each batch should have access to the request contexts of all requests in that batch, and context should be properly unset after processing. """ @serve.deployment(max_ongoing_requests=10) class BatchContextTester: def __init__(self): self.batch_results = [] @serve.batch( max_batch_size=3, batch_wait_timeout_s=1.0, max_concurrent_batches=2 ) async def handle_batch(self, batch): # Store results for verification batch_result = { "batch_size": len(batch), "batch_request_contexts": _get_serve_batch_request_context(), "current_request_context": _get_serve_request_context(), } self.batch_results.append(batch_result) return ["ok" for _ in range(len(batch))] async def __call__(self, request): return await self.handle_batch(1) async def get_results(self): return self.batch_results handle = serve.run(BatchContextTester.bind()) def do_request(): """Make a request with a specific request ID.""" url = get_application_url() r = httpx.post(f"{url}/") r.raise_for_status() # Launch 6 requests. Expect 2 batches of 3 requests each. threads = [Thread(target=do_request) for _ in range(6)] for t in threads: t.start() for t in threads: t.join() # Get results from the deployment batch_results = handle.get_results.remote().result() # Verify each batch has correct size and context total_requests_processed = 0 request_ids_in_batch_context = set() for result in batch_results: # Batch context should contain all 3 request contexts assert ( len(result["batch_request_contexts"]) == 3 ), f"Expected 3 contexts in batch, got {result['batch_request_contexts']}" req_ids_in_batch_context = [ ctx.request_id for ctx in result["batch_request_contexts"] ] assert ( len(req_ids_in_batch_context) == 3 ), f"Expected 3 batch request IDs, got {len(req_ids_in_batch_context)}" request_ids_in_batch_context.update(req_ids_in_batch_context) # Current request context read within the batcher should be a default empty context. current_request_context = result["current_request_context"] assert current_request_context.request_id == "" assert current_request_context.route == "" assert current_request_context.app_name == "" assert current_request_context.multiplexed_model_id == "" total_requests_processed += result["batch_size"] # Verify all 6 requests were processed assert ( total_requests_processed == 6 ), f"Expected 6 total requests processed, got {total_requests_processed}" assert ( len(request_ids_in_batch_context) == 6 ), f"Expected 6 unique request IDs, got {len(request_ids_in_batch_context)}" def test_batch_size_fn_simple(serve_instance): """Test batch_size_fn with a simple custom batch size metric.""" @serve.deployment class BatchSizeFnExample: def __init__(self): self.batches_received = [] @serve.batch( max_batch_size=100, # Set based on total size, not count batch_wait_timeout_s=0.5, batch_size_fn=lambda items: sum(item["size"] for item in items), ) async def handle_batch(self, requests: List): # Record the batch for verification self.batches_received.append(requests) # Return results return [req["value"] * 2 for req in requests] async def __call__(self, request): return await self.handle_batch(request) def get_batches(self): return self.batches_received handle = serve.run(BatchSizeFnExample.bind()) # Send requests with different sizes # Request 1: size=30, value=1 # Request 2: size=40, value=2 # Request 3: size=20, value=3 # Request 4: size=25, value=4 # Total of first 3 = 90 (< 100), but adding 4th would be 115 (> 100) requests = [ {"size": 30, "value": 1}, {"size": 40, "value": 2}, {"size": 20, "value": 3}, {"size": 25, "value": 4}, ] result_futures = [handle.remote(req) for req in requests] results = [future.result() for future in result_futures] # Verify results are correct assert results == [2, 4, 6, 8] # Verify batching behavior batches = handle.get_batches.remote().result() # Should have created at least one batch assert len(batches) > 0 def test_batch_size_fn_graph_nodes(serve_instance): """Test batch_size_fn with a GNN-style use case (batching by total nodes).""" class Graph: def __init__(self, num_nodes: int, graph_id: int): self.num_nodes = num_nodes self.graph_id = graph_id @serve.deployment class GraphBatcher: def __init__(self): self.batch_sizes = [] @serve.batch( max_batch_size=100, # Max 100 nodes per batch batch_wait_timeout_s=0.5, batch_size_fn=lambda graphs: sum(g.num_nodes for g in graphs), ) async def process_graphs(self, graphs: List[Graph]): # Record batch size (total nodes) total_nodes = sum(g.num_nodes for g in graphs) self.batch_sizes.append(total_nodes) # Return graph_id * num_nodes as result return [g.graph_id * g.num_nodes for g in graphs] async def __call__(self, graph): return await self.process_graphs(graph) def get_batch_sizes(self): return self.batch_sizes handle = serve.run(GraphBatcher.bind()) # Create graphs with different node counts # Graph 1: 30 nodes, Graph 2: 40 nodes, Graph 3: 35 nodes, Graph 4: 50 nodes # First 3 total = 105 nodes (> 100), so should be 2 batches graphs = [ Graph(num_nodes=30, graph_id=1), Graph(num_nodes=40, graph_id=2), Graph(num_nodes=35, graph_id=3), Graph(num_nodes=50, graph_id=4), ] result_futures = [handle.remote(g) for g in graphs] results = [future.result() for future in result_futures] # Verify results assert results == [30, 80, 105, 200] # Verify batch sizes respect the limit batch_sizes = handle.get_batch_sizes.remote().result() for batch_size in batch_sizes: # Each batch should have <= 100 nodes assert batch_size <= 100, f"Batch size {batch_size} exceeds limit of 100" def test_batch_size_fn_token_count(serve_instance): """Test batch_size_fn with an NLP-style use case (batching by total tokens).""" @serve.deployment class TokenBatcher: @serve.batch( max_batch_size=1000, # Max 1000 tokens per batch batch_wait_timeout_s=0.5, batch_size_fn=lambda sequences: sum(len(s.split()) for s in sequences), ) async def process_sequences(self, sequences: List[str]): # Return word count for each sequence return [len(s.split()) for s in sequences] async def __call__(self, sequence): return await self.process_sequences(sequence) handle = serve.run(TokenBatcher.bind()) # Create sequences with different token counts sequences = [ "This is a short sequence", # 5 tokens "This is a much longer sequence with many more words in it", # 12 tokens "Short", # 1 token "A B C D E F G H I J", # 10 tokens ] result_futures = [handle.remote(s) for s in sequences] results = [future.result() for future in result_futures] # Verify results are correct assert results == [5, 12, 1, 10] def test_batch_size_fn_validation(): """Test that batch_size_fn validation works correctly.""" from ray.serve.batching import batch # Test with non-callable batch_size_fn with pytest.raises(TypeError, match="batch_size_fn must be a callable or None"): @batch(batch_size_fn="not_a_function") async def my_batch_handler(items): return items def test_batch_size_fn_default_behavior(serve_instance): """Test that default behavior (batch_size_fn=None) still works as expected.""" @serve.deployment class DefaultBatcher: @serve.batch(max_batch_size=5, batch_wait_timeout_s=0.5) async def handle_batch(self, requests): return [r * 2 for r in requests] async def __call__(self, request): return await self.handle_batch(request) handle = serve.run(DefaultBatcher.bind()) # Send 10 requests result_futures = [handle.remote(i) for i in range(10)] results = [future.result() for future in result_futures] # Verify all results are correct assert results == [i * 2 for i in range(10)] def test_batch_size_fn_oversized_item_raises_error(serve_instance): app = FastAPI() @serve.deployment @serve.ingress(app) class OversizedItemBatcher: @serve.batch( max_batch_size=10, batch_wait_timeout_s=0.5, batch_size_fn=lambda items: sum(item["size"] for item in items), ) async def handle_batch(self, requests: List): return [req["value"] for req in requests] @app.post("/") async def f(self, request: Request): body = await request.json() return await self.handle_batch(body) serve.run(OversizedItemBatcher.bind()) # Send a request with size > max_batch_size (15 > 10) # This should return a 500 error with RuntimeError message url = f"{get_application_url(use_localhost=True)}/" response = httpx.post(url, json={"size": 15, "value": "too_large"}, timeout=5) assert response.status_code == 500 def test_batch_size_fn_deferred_item_processed(serve_instance): @serve.deployment(max_ongoing_requests=15) class DeferredItemBatcher: def __init__(self): self.batch_sizes = [] @serve.batch( max_batch_size=10, batch_wait_timeout_s=0.5, batch_size_fn=lambda items: sum(item["size"] for item in items), ) async def handle_batch(self, requests: List): # Record actual batch sizes for verification total_size = sum(req["size"] for req in requests) self.batch_sizes.append(total_size) return [req["value"] for req in requests] async def __call__(self, request): return await self.handle_batch(request) def get_batch_sizes(self): return self.batch_sizes handle = serve.run(DeferredItemBatcher.bind()) # Send requests where some will need to be deferred: # Request 1: size=6 (fits) # Request 2: size=6 (would make total 12 > 10, deferred) # Request 3: size=3 (fits with request 1, total 9) # Request 4: size=4 (would make total 13 > 10, deferred) requests = [ {"size": 6, "value": "a"}, {"size": 6, "value": "b"}, {"size": 3, "value": "c"}, {"size": 4, "value": "d"}, ] result_futures = [handle.remote(req) for req in requests] results = [future.result() for future in result_futures] # All requests should be processed successfully assert set(results) == {"a", "b", "c", "d"} # Verify total size processed equals sum of all request sizes batch_sizes = handle.get_batch_sizes.remote().result() total_processed = sum(batch_sizes) expected_total = sum(req["size"] for req in requests) # 6 + 6 + 3 + 4 = 19 assert ( total_processed == expected_total ), f"Total processed {total_processed} != expected {expected_total}" def test_batch_size_fn_mixed_normal_and_large_items(serve_instance): @serve.deployment class MixedSizeBatcher: def __init__(self): self.batches_processed = [] @serve.batch( max_batch_size=100, batch_wait_timeout_s=0.5, batch_size_fn=lambda items: sum(item["tokens"] for item in items), ) async def handle_batch(self, requests: List): batch_info = { "total_tokens": sum(req["tokens"] for req in requests), "num_items": len(requests), } self.batches_processed.append(batch_info) return [f"processed_{req['id']}" for req in requests] async def __call__(self, request): return await self.handle_batch(request) def get_batches(self): return self.batches_processed handle = serve.run(MixedSizeBatcher.bind()) # Mix of small and larger items requests = [ {"id": 1, "tokens": 10}, # Small {"id": 2, "tokens": 20}, # Small {"id": 3, "tokens": 50}, # Medium {"id": 4, "tokens": 15}, # Small {"id": 5, "tokens": 90}, # Large (near limit) {"id": 6, "tokens": 5}, # Small ] result_futures = [handle.remote(req) for req in requests] results = [future.result() for future in result_futures] # All requests should be processed expected_results = [f"processed_{i}" for i in range(1, 7)] assert set(results) == set(expected_results) # Verify total tokens processed equals sum of all request tokens batches = handle.get_batches.remote().result() total_tokens_processed = sum(batch["total_tokens"] for batch in batches) expected_total = sum(req["tokens"] for req in requests) # 10+20+50+15+90+5 = 190 assert ( total_tokens_processed == expected_total ), f"Total tokens {total_tokens_processed} != expected {expected_total}" # Verify total items processed equals number of requests total_items = sum(batch["num_items"] for batch in batches) assert total_items == len( requests ), f"Total items {total_items} != expected {len(requests)}" if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", "-s", __file__]))