mirror of
https://github.com/vacp2p/pod-api-requester.git
synced 2026-08-27 09:41:16 +00:00
Add support for Shadow by enabling DNS resolution and fixing bugs of the batch mode
323 lines
10 KiB
Python
323 lines
10 KiB
Python
"""
|
|
Async HTTP client for load testing.
|
|
|
|
Provides high-throughput message injection with rate limiting,
|
|
burst mode, and parallel worker support.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
import traceback
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import aiohttp
|
|
|
|
from configs import ConfigAction, ConfigEndpoint, ConfigRequest, LoadTestConfig
|
|
from schemas import TargetPodInfo
|
|
from utils import setup_logger
|
|
|
|
logger = setup_logger(__file__)
|
|
|
|
|
|
@dataclass
|
|
class RequestStats:
|
|
"""Thread-safe statistics tracking for load test requests."""
|
|
|
|
success: int = 0
|
|
failure: int = 0
|
|
total: int = 0
|
|
total_latency_ms: float = 0.0
|
|
min_latency_ms: float = float("inf")
|
|
max_latency_ms: float = 0.0
|
|
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
|
|
async def record(self, ok: bool, latency_ms: float) -> None:
|
|
async with self._lock:
|
|
self.total += 1
|
|
self.total_latency_ms += latency_ms
|
|
if latency_ms < self.min_latency_ms:
|
|
self.min_latency_ms = latency_ms
|
|
if latency_ms > self.max_latency_ms:
|
|
self.max_latency_ms = latency_ms
|
|
if ok:
|
|
self.success += 1
|
|
else:
|
|
self.failure += 1
|
|
|
|
async def snapshot(self) -> Dict[str, Any]:
|
|
async with self._lock:
|
|
success_rate = (self.success / self.total * 100.0) if self.total else 0.0
|
|
avg_latency = (self.total_latency_ms / self.total) if self.total else 0.0
|
|
return {
|
|
"success": self.success,
|
|
"failure": self.failure,
|
|
"total": self.total,
|
|
"success_rate": success_rate,
|
|
"avg_latency_ms": avg_latency,
|
|
"min_latency_ms": self.min_latency_ms if self.total else 0.0,
|
|
"max_latency_ms": self.max_latency_ms,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class WorkerContext:
|
|
"""Context for a per-pod worker."""
|
|
worker_id: int
|
|
pod_info: TargetPodInfo
|
|
action: ConfigAction
|
|
session: aiohttp.ClientSession
|
|
stats: RequestStats
|
|
|
|
|
|
def build_request_url(endpoint: ConfigEndpoint, pod_info: TargetPodInfo) -> str:
|
|
"""Build the full URL for a request to a specific pod."""
|
|
return endpoint.url.format(
|
|
node=pod_info.node_address,
|
|
port=pod_info.config_target.port,
|
|
)
|
|
|
|
|
|
async def send_async_request(
|
|
ctx: WorkerContext,
|
|
request: ConfigRequest,
|
|
message_index: int,
|
|
) -> Dict[str, Any]:
|
|
"""Send a single async HTTP request and record stats."""
|
|
endpoint = request.endpoint
|
|
url = build_request_url(endpoint, ctx.pod_info)
|
|
pod_name = ctx.pod_info.pod_name
|
|
load_test = ctx.action.load_test
|
|
|
|
result_data: Dict[str, Any] = {
|
|
"request": {
|
|
"url": url,
|
|
"pod": pod_name,
|
|
"worker_id": ctx.worker_id,
|
|
"message_index": message_index,
|
|
}
|
|
}
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
response_data = await _do_http_request(
|
|
ctx.session, endpoint, url, load_test.request_timeout
|
|
)
|
|
|
|
elapsed_ms = (time.time() - start_time) * 1000.0
|
|
ok = response_data["status_code"] == 200
|
|
|
|
await ctx.stats.record(ok, elapsed_ms)
|
|
snap = await ctx.stats.snapshot()
|
|
|
|
result_data["response"] = response_data
|
|
result_data["elapsed_ms"] = elapsed_ms
|
|
result_data["stats"] = snap
|
|
|
|
logger.info(
|
|
"worker=%d message=%d pod=%s status=%d elapsed_ms=%.2f "
|
|
"success=%d failure=%d total=%d success_rate=%.2f%%",
|
|
ctx.worker_id,
|
|
message_index,
|
|
pod_name,
|
|
response_data["status_code"],
|
|
elapsed_ms,
|
|
snap["success"],
|
|
snap["failure"],
|
|
snap["total"],
|
|
snap["success_rate"],
|
|
)
|
|
|
|
except Exception as e:
|
|
elapsed_ms = (time.time() - start_time) * 1000.0
|
|
await ctx.stats.record(False, elapsed_ms)
|
|
snap = await ctx.stats.snapshot()
|
|
|
|
error_msg = traceback.format_exc()
|
|
result_data["exception"] = str(e)
|
|
result_data["traceback"] = error_msg
|
|
result_data["elapsed_ms"] = elapsed_ms
|
|
result_data["stats"] = snap
|
|
|
|
logger.warning(
|
|
"worker=%d message=%d pod=%s exception=%s elapsed_ms=%.2f "
|
|
"success=%d failure=%d total=%d success_rate=%.2f%%",
|
|
ctx.worker_id,
|
|
message_index,
|
|
pod_name,
|
|
repr(e),
|
|
elapsed_ms,
|
|
snap["success"],
|
|
snap["failure"],
|
|
snap["total"],
|
|
snap["success_rate"],
|
|
)
|
|
|
|
return result_data
|
|
|
|
|
|
async def _do_http_request(
|
|
session: aiohttp.ClientSession,
|
|
endpoint: ConfigEndpoint,
|
|
url: str,
|
|
timeout: float,
|
|
) -> Dict[str, Any]:
|
|
"""Perform the actual HTTP request."""
|
|
timeout_obj = aiohttp.ClientTimeout(total=timeout)
|
|
|
|
if endpoint.type == "POST":
|
|
async with session.post(
|
|
url,
|
|
json=endpoint.params,
|
|
headers=endpoint.headers,
|
|
timeout=timeout_obj,
|
|
) as response:
|
|
text = await response.text()
|
|
return {"status_code": response.status, "text": text[:500]}
|
|
elif endpoint.type == "GET":
|
|
async with session.get(
|
|
url,
|
|
params=endpoint.params,
|
|
headers=endpoint.headers,
|
|
timeout=timeout_obj,
|
|
) as response:
|
|
text = await response.text()
|
|
return {"status_code": response.status, "text": text[:500]}
|
|
else:
|
|
raise ValueError(f"Unsupported request type: {endpoint.type}")
|
|
|
|
|
|
async def run_pod_worker(ctx: WorkerContext) -> Dict[str, Any]:
|
|
"""Worker that sends messages to a single pod at specified rate."""
|
|
load_test = ctx.action.load_test
|
|
requests_list = ctx.action.requests
|
|
pod_name = ctx.pod_info.pod_name
|
|
|
|
delay_seconds = load_test.get_delay_seconds()
|
|
burst_size = load_test.burst_size or 1
|
|
burst_delay = load_test.burst_delay or delay_seconds
|
|
|
|
start_time = time.time()
|
|
message_index = 0
|
|
|
|
logger.info(
|
|
"worker=%d pod=%s starting messages=%s duration=%s rate=%s",
|
|
ctx.worker_id, pod_name, load_test.messages_per_pod,
|
|
load_test.duration_seconds, load_test.rate_per_pod,
|
|
)
|
|
|
|
tasks = []
|
|
|
|
while True:
|
|
if load_test.messages_per_pod is not None and message_index >= load_test.messages_per_pod:
|
|
break
|
|
if load_test.duration_seconds is not None and (time.time() - start_time) >= load_test.duration_seconds:
|
|
break
|
|
|
|
# Send burst
|
|
for _ in range(burst_size):
|
|
if load_test.messages_per_pod is not None and message_index >= load_test.messages_per_pod:
|
|
break
|
|
request = requests_list[message_index % len(requests_list)]
|
|
tasks.append(asyncio.create_task(send_async_request(ctx, request, message_index)))
|
|
message_index += 1
|
|
|
|
await asyncio.sleep(burst_delay if burst_size > 1 else delay_seconds)
|
|
|
|
# Wait for all in-flight requests to complete
|
|
for task in tasks:
|
|
try:
|
|
await task
|
|
except Exception as e:
|
|
logger.error(f"Request failed: {e}")
|
|
|
|
elapsed_total = time.time() - start_time
|
|
logger.info(
|
|
"worker=%d pod=%s finished messages=%d elapsed=%.2f",
|
|
ctx.worker_id, pod_name, message_index, elapsed_total,
|
|
)
|
|
|
|
return {
|
|
"worker_id": ctx.worker_id,
|
|
"pod": pod_name,
|
|
"messages_sent": message_index,
|
|
"elapsed_seconds": elapsed_total,
|
|
}
|
|
|
|
|
|
async def run_load_test(
|
|
action: ConfigAction,
|
|
pods: List[TargetPodInfo],
|
|
) -> Dict[str, Any]:
|
|
"""Execute a load test action against multiple pods."""
|
|
load_test = action.load_test
|
|
load_test.validate_config()
|
|
|
|
if not pods:
|
|
raise ValueError("No target pods for load test")
|
|
|
|
stats = RequestStats()
|
|
worker_results = []
|
|
worker_errors = []
|
|
|
|
start_time = time.time()
|
|
connector = aiohttp.TCPConnector(ttl_dns_cache=300, limit=0)
|
|
timeout = aiohttp.ClientTimeout(total=load_test.request_timeout)
|
|
|
|
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
|
if load_test.parallel_workers:
|
|
workers = []
|
|
for i, pod_info in enumerate(pods):
|
|
ctx = WorkerContext(
|
|
worker_id=i,
|
|
pod_info=pod_info,
|
|
action=action,
|
|
session=session,
|
|
stats=stats,
|
|
)
|
|
workers.append((i, asyncio.create_task(run_pod_worker(ctx))))
|
|
|
|
for worker_id, task in workers:
|
|
try:
|
|
result = await task
|
|
worker_results.append(result)
|
|
except Exception as e:
|
|
worker_errors.append({"worker_id": worker_id, "error": str(e)})
|
|
logger.error(f"Worker {worker_id} failed: {e}")
|
|
else:
|
|
for i, pod_info in enumerate(pods):
|
|
ctx = WorkerContext(
|
|
worker_id=i,
|
|
pod_info=pod_info,
|
|
action=action,
|
|
session=session,
|
|
stats=stats,
|
|
)
|
|
try:
|
|
result = await run_pod_worker(ctx)
|
|
worker_results.append(result)
|
|
except Exception as e:
|
|
worker_errors.append({"worker_id": i, "error": str(e)})
|
|
logger.error(f"Worker {i} failed: {e}")
|
|
|
|
elapsed_total = time.time() - start_time
|
|
final_stats = await stats.snapshot()
|
|
|
|
summary = {
|
|
"action": action.name,
|
|
"pods_count": len(pods),
|
|
"elapsed_seconds": elapsed_total,
|
|
"stats": final_stats,
|
|
"throughput_msg_per_sec": final_stats["total"] / elapsed_total if elapsed_total > 0 else 0,
|
|
"worker_errors": worker_errors,
|
|
}
|
|
|
|
logger.info(
|
|
"load_test_complete action=%s pods=%d total=%d success_rate=%.2f%% throughput=%.2f msg/s",
|
|
action.name, len(pods), final_stats["total"],
|
|
final_stats["success_rate"], summary["throughput_msg_per_sec"],
|
|
)
|
|
|
|
return summary
|