#!/usr/bin/env python3 """ Load testing script for the Ray Serve video API. Usage: python -m client.load_test --video s3://bucket/path/to/video.mp4 --concurrency 4 python -m client.load_test --video s3://bucket/video.mp4 --concurrency 8 --url http://localhost:8000 python -m client.load_test --video s3://bucket/video.mp4 --concurrency 4 --token YOUR_TOKEN Press Ctrl+C to stop and save results to CSV. """ import argparse import asyncio import csv import signal import sys import time import uuid from dataclasses import dataclass, field, asdict from datetime import datetime from pathlib import Path from typing import Optional import httpx @dataclass class RequestResult: """Result of a single request.""" request_id: str stream_id: str start_time: float end_time: float latency_ms: float success: bool status_code: int error: Optional[str] = None # Server-side timing (if successful) s3_download_ms: Optional[float] = None decode_video_ms: Optional[float] = None encode_ms: Optional[float] = None decode_ms: Optional[float] = None server_total_ms: Optional[float] = None # Response data num_chunks: Optional[int] = None video_duration: Optional[float] = None num_scene_changes: Optional[int] = None @dataclass class LoadTestStats: """Aggregated statistics for the load test.""" total_requests: int = 0 successful_requests: int = 0 failed_requests: int = 0 total_latency_ms: float = 0.0 min_latency_ms: float = float('inf') max_latency_ms: float = 0.0 latencies: list = field(default_factory=list) def add_result(self, result: RequestResult): self.total_requests += 1 if result.success: self.successful_requests += 1 self.total_latency_ms += result.latency_ms self.min_latency_ms = min(self.min_latency_ms, result.latency_ms) self.max_latency_ms = max(self.max_latency_ms, result.latency_ms) self.latencies.append(result.latency_ms) else: self.failed_requests += 1 @property def avg_latency_ms(self) -> float: if not self.latencies: return 0.0 return self.total_latency_ms / len(self.latencies) @property def p50_latency_ms(self) -> float: return self._percentile(50) @property def p95_latency_ms(self) -> float: return self._percentile(95) @property def p99_latency_ms(self) -> float: return self._percentile(99) def _percentile(self, p: float) -> float: if not self.latencies: return 0.0 sorted_latencies = sorted(self.latencies) idx = int(len(sorted_latencies) * p / 100) idx = min(idx, len(sorted_latencies) - 1) return sorted_latencies[idx] class LoadTester: def __init__( self, video_path: str, url: str, concurrency: int, num_frames: int = 16, chunk_duration: float = 10.0, timeout: float = 300.0, token: Optional[str] = None, ): self.video_path = video_path self.url = url self.concurrency = concurrency self.num_frames = num_frames self.chunk_duration = chunk_duration self.timeout = timeout self.token = token self.results: list[RequestResult] = [] self.stats = LoadTestStats() self.running = True self.start_time: Optional[float] = None self.request_counter = 0 self._lock = asyncio.Lock() self._semaphore: asyncio.Semaphore = None def _build_payload(self) -> dict: """Build the request payload.""" stream_id = uuid.uuid4().hex[:8] return { "stream_id": stream_id, "video_path": self.video_path, "num_frames": self.num_frames, "chunk_duration": self.chunk_duration, "use_batching": False, } async def _make_request(self, client: httpx.AsyncClient, request_id: str) -> RequestResult: """Make a single request and return the result.""" payload = self._build_payload() stream_id = payload["stream_id"] start = time.perf_counter() start_timestamp = time.time() headers = {} if self.token: headers["Authorization"] = f"Bearer {self.token}" try: response = await client.post(f"{self.url}/analyze", json=payload, headers=headers) end = time.perf_counter() latency_ms = (end - start) * 1000 if response.status_code == 200: data = response.json() timing = data.get("timing_ms", {}) return RequestResult( request_id=request_id, stream_id=stream_id, start_time=start_timestamp, end_time=time.time(), latency_ms=latency_ms, success=True, status_code=response.status_code, s3_download_ms=timing.get("s3_download_ms"), decode_video_ms=timing.get("decode_video_ms"), encode_ms=timing.get("encode_ms"), decode_ms=timing.get("decode_ms"), server_total_ms=timing.get("total_ms"), num_chunks=data.get("num_chunks"), video_duration=data.get("video_duration"), num_scene_changes=data.get("num_scene_changes"), ) else: return RequestResult( request_id=request_id, stream_id=stream_id, start_time=start_timestamp, end_time=time.time(), latency_ms=latency_ms, success=False, status_code=response.status_code, error=response.text[:200], ) except Exception as e: end = time.perf_counter() latency_ms = (end - start) * 1000 return RequestResult( request_id=request_id, stream_id=stream_id, start_time=start_timestamp, end_time=time.time(), latency_ms=latency_ms, success=False, status_code=0, error=str(e)[:200], ) async def _request_task(self, client: httpx.AsyncClient, request_id: str): """Execute a single request and record the result. Releases semaphore when done.""" try: result = await self._make_request(client, request_id) async with self._lock: self.results.append(result) self.stats.add_result(result) # Print progress status = "āœ“" if result.success else "āœ—" print( f"{status} {result.request_id} | " f"{result.latency_ms:7.1f}ms | " f"Total: {self.stats.total_requests} " f"(OK: {self.stats.successful_requests}, Fail: {self.stats.failed_requests})" ) finally: self._semaphore.release() async def run(self): """Run the load test until interrupted.""" self.start_time = time.time() self._semaphore = asyncio.Semaphore(self.concurrency) pending_tasks: set[asyncio.Task] = set() print("=" * 70) print("šŸš€ Starting Load Test") print("=" * 70) print(f" Video: {self.video_path}") print(f" URL: {self.url}") print(f" Concurrency: {self.concurrency}") print(f" Chunk dur: {self.chunk_duration}s") print(f" Frames/chunk:{self.num_frames}") print() print("Press Ctrl+C to stop and save results...") print("-" * 70) async with httpx.AsyncClient(timeout=self.timeout) as client: while self.running: # Block until a slot is available await self._semaphore.acquire() if not self.running: self._semaphore.release() break # Spawn task (it will release semaphore when done) self.request_counter += 1 request_id = f"req_{self.request_counter:06d}" task = asyncio.create_task(self._request_task(client, request_id)) pending_tasks.add(task) task.add_done_callback(pending_tasks.discard) # Wait for in-flight requests if pending_tasks: print(f"\nā³ Waiting for {len(pending_tasks)} in-flight requests...") await asyncio.gather(*pending_tasks, return_exceptions=True) def stop(self): """Stop the load test.""" self.running = False def save_results(self, output_path: str): """Save results to CSV.""" if not self.results: print("No results to save.") return fieldnames = [ "request_id", "stream_id", "start_time", "end_time", "latency_ms", "success", "status_code", "error", "s3_download_ms", "decode_video_ms", "encode_ms", "decode_ms", "server_total_ms", "num_chunks", "video_duration", "num_scene_changes" ] with open(output_path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for result in self.results: writer.writerow(asdict(result)) print(f"šŸ“ Results saved to: {output_path}") def print_summary(self): """Print summary statistics.""" if not self.results: print("No results to summarize.") return duration = time.time() - self.start_time if self.start_time else 0 throughput = self.stats.total_requests / duration if duration > 0 else 0 print() print("=" * 70) print("šŸ“Š Load Test Summary") print("=" * 70) print(f" Duration: {duration:.1f}s") print(f" Total requests: {self.stats.total_requests}") print(f" Successful: {self.stats.successful_requests}") print(f" Failed: {self.stats.failed_requests}") print(f" Success rate: {self.stats.successful_requests / self.stats.total_requests * 100:.1f}%") print(f" Throughput: {throughput:.2f} req/s") print() print("ā±ļø Latency (successful requests):") if self.stats.latencies: print(f" Min: {self.stats.min_latency_ms:8.1f} ms") print(f" Max: {self.stats.max_latency_ms:8.1f} ms") print(f" Avg: {self.stats.avg_latency_ms:8.1f} ms") print(f" P50: {self.stats.p50_latency_ms:8.1f} ms") print(f" P95: {self.stats.p95_latency_ms:8.1f} ms") print(f" P99: {self.stats.p99_latency_ms:8.1f} ms") else: print(" (no successful requests)") print("=" * 70) def main(): parser = argparse.ArgumentParser(description="Load test the Ray Serve video API") parser.add_argument("--video", type=str, required=True, help="S3 URI: s3://bucket/key") parser.add_argument("--url", type=str, default="http://127.0.0.1:8000", help="Server URL") parser.add_argument("--concurrency", type=int, default=4, help="Number of concurrent workers") parser.add_argument("--num-frames", type=int, default=16, help="Frames per chunk") parser.add_argument("--chunk-duration", type=float, default=10.0, help="Chunk duration in seconds") parser.add_argument("--timeout", type=float, default=300.0, help="Request timeout in seconds") parser.add_argument("--output", type=str, default=None, help="Output CSV path (default: load_test_.csv)") parser.add_argument("--token", type=str, default=None, help="Bearer token for Authorization header") args = parser.parse_args() # Generate output path if not provided if args.output is None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") args.output = f"load_test_{timestamp}_{args.concurrency}.csv" tester = LoadTester( video_path=args.video, url=args.url, concurrency=args.concurrency, num_frames=args.num_frames, chunk_duration=args.chunk_duration, timeout=args.timeout, token=args.token, ) # Track interrupt count for force exit interrupt_count = 0 # Handle Ctrl+C gracefully def signal_handler(sig, frame): nonlocal interrupt_count interrupt_count += 1 if interrupt_count == 1: print("\n\nšŸ›‘ Stopping load test (press Ctrl+C again to force exit)...") tester.stop() else: print("\n\nāš ļø Force exiting...") tester.print_summary() tester.save_results(args.output) sys.exit(1) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) try: asyncio.run(tester.run()) except KeyboardInterrupt: pass finally: tester.print_summary() tester.save_results(args.output) if __name__ == "__main__": main()