mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
@@ -0,0 +1,880 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
|
||||
use aether_gateway::tunnel_protocol as protocol;
|
||||
use aether_testkit::{
|
||||
fetch_prometheus_samples, find_metric_value_u64, init_test_runtime_for, run_http_load_probe,
|
||||
BenchmarkRuntimeSnapshot, ExecutionRuntimeHarness, ExecutionRuntimeHarnessConfig,
|
||||
GatewayHarness, GatewayHarnessConfig, HttpLoadProbeConfig, HttpLoadProbeResponseMode,
|
||||
HttpLoadProbeResult, SpawnedServer, TunnelHarness, TunnelHarnessConfig,
|
||||
GATEWAY_HARNESS_API_KEY,
|
||||
};
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Json, Router};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
||||
const TUNNEL_RELAY_PATH_PREFIX: &str = "/api/internal/tunnel/relay";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CapacityCurveBaselineConfig {
|
||||
points: Vec<usize>,
|
||||
requests_per_point_multiplier: usize,
|
||||
sync_delay: Duration,
|
||||
stream_chunk_delay: Duration,
|
||||
tunnel_hold: Duration,
|
||||
timeout: Duration,
|
||||
saturation_latency_multiplier: u64,
|
||||
output_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for CapacityCurveBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
points: vec![8, 16, 32, 64, 128, 256],
|
||||
requests_per_point_multiplier: 8,
|
||||
sync_delay: Duration::from_millis(75),
|
||||
stream_chunk_delay: Duration::from_millis(25),
|
||||
tunnel_hold: Duration::from_millis(75),
|
||||
timeout: Duration::from_secs(10),
|
||||
saturation_latency_multiplier: 4,
|
||||
output_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CapacityCurveBaselineReport {
|
||||
suite: &'static str,
|
||||
gateway_sync: CapacityCurveScenarioReport,
|
||||
gateway_stream: CapacityCurveScenarioReport,
|
||||
execution_runtime_sync: CapacityCurveScenarioReport,
|
||||
execution_runtime_stream: CapacityCurveScenarioReport,
|
||||
gateway_tunnel_stream: CapacityCurveScenarioReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CapacityCurveScenarioReport {
|
||||
name: String,
|
||||
gate: String,
|
||||
latency_budget_ms: u64,
|
||||
points: Vec<CapacityCurvePointResult>,
|
||||
saturation_point: Option<CapacityCurveSaturationPoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CapacityCurvePointResult {
|
||||
limit: usize,
|
||||
concurrency: usize,
|
||||
total_requests: usize,
|
||||
duration_ms: u64,
|
||||
successful_requests: usize,
|
||||
rejected_requests: usize,
|
||||
failed_requests: usize,
|
||||
throughput_rps: u64,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
p99_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
metrics: GateMetricSnapshot,
|
||||
runtime: BenchmarkRuntimeSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CapacityCurveSaturationPoint {
|
||||
limit: usize,
|
||||
concurrency: usize,
|
||||
reason: String,
|
||||
p95_ms: u64,
|
||||
rejected_requests: usize,
|
||||
failed_requests: usize,
|
||||
high_watermark: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GateMetricSnapshot {
|
||||
in_flight: u64,
|
||||
available_permits: u64,
|
||||
high_watermark: u64,
|
||||
rejected_total: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("capacity-curve-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &CapacityCurveBaselineConfig,
|
||||
) -> Result<CapacityCurveBaselineReport, Box<dyn std::error::Error>> {
|
||||
let upstream = SpawnedServer::start(build_delayed_upstream(
|
||||
config.sync_delay,
|
||||
config.stream_chunk_delay,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(CapacityCurveBaselineReport {
|
||||
suite: "capacity_curve_baseline",
|
||||
gateway_sync: run_gateway_curve(
|
||||
"gateway_proxy_sync",
|
||||
"gateway_requests",
|
||||
false,
|
||||
upstream.base_url(),
|
||||
config,
|
||||
)
|
||||
.await?,
|
||||
gateway_stream: run_gateway_curve(
|
||||
"gateway_proxy_stream",
|
||||
"gateway_requests",
|
||||
true,
|
||||
upstream.base_url(),
|
||||
config,
|
||||
)
|
||||
.await?,
|
||||
execution_runtime_sync: run_execution_runtime_curve(
|
||||
"execution_runtime_sync",
|
||||
"execution_runtime_requests",
|
||||
false,
|
||||
upstream.base_url(),
|
||||
config,
|
||||
)
|
||||
.await?,
|
||||
execution_runtime_stream: run_execution_runtime_curve(
|
||||
"execution_runtime_stream",
|
||||
"execution_runtime_requests",
|
||||
true,
|
||||
upstream.base_url(),
|
||||
config,
|
||||
)
|
||||
.await?,
|
||||
gateway_tunnel_stream: run_tunnel_curve("gateway_tunnel_stream", "tunnel_requests", config)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_gateway_curve(
|
||||
scenario_name: &str,
|
||||
gate_name: &str,
|
||||
stream: bool,
|
||||
upstream_base_url: &str,
|
||||
config: &CapacityCurveBaselineConfig,
|
||||
) -> Result<CapacityCurveScenarioReport, Box<dyn std::error::Error>> {
|
||||
let latency_budget_ms = scenario_latency_budget_ms(
|
||||
if stream {
|
||||
config.stream_chunk_delay.saturating_mul(3u32)
|
||||
} else {
|
||||
config.sync_delay
|
||||
},
|
||||
config.saturation_latency_multiplier,
|
||||
);
|
||||
let mut points = Vec::new();
|
||||
for limit in &config.points {
|
||||
let gateway = GatewayHarness::start(GatewayHarnessConfig {
|
||||
upstream_base_url: upstream_base_url.to_string(),
|
||||
data_config: None,
|
||||
max_in_flight_requests: Some(*limit),
|
||||
distributed_request_gate: None,
|
||||
tunnel_instance_id: None,
|
||||
tunnel_relay_base_url: None,
|
||||
})
|
||||
.await?;
|
||||
let total_requests = total_requests_for_limit(*limit, config.requests_per_point_multiplier);
|
||||
let probe = chat_probe_config(
|
||||
format!("{}/v1/chat/completions", gateway.base_url()),
|
||||
stream,
|
||||
total_requests,
|
||||
*limit,
|
||||
config.timeout,
|
||||
);
|
||||
let started_at = Instant::now();
|
||||
let result = run_http_load_probe(&probe)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
let metrics = capture_gate_metrics(
|
||||
&format!("{}/_gateway/metrics", gateway.base_url()),
|
||||
gate_name,
|
||||
)
|
||||
.await?;
|
||||
points.push(capacity_point(
|
||||
*limit,
|
||||
total_requests,
|
||||
duration_ms,
|
||||
result,
|
||||
metrics,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(CapacityCurveScenarioReport {
|
||||
name: scenario_name.to_string(),
|
||||
gate: gate_name.to_string(),
|
||||
latency_budget_ms,
|
||||
saturation_point: detect_saturation_point(&points, latency_budget_ms),
|
||||
points,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_execution_runtime_curve(
|
||||
scenario_name: &str,
|
||||
gate_name: &str,
|
||||
stream: bool,
|
||||
upstream_base_url: &str,
|
||||
config: &CapacityCurveBaselineConfig,
|
||||
) -> Result<CapacityCurveScenarioReport, Box<dyn std::error::Error>> {
|
||||
let latency_budget_ms = scenario_latency_budget_ms(
|
||||
if stream {
|
||||
config.stream_chunk_delay.saturating_mul(3u32)
|
||||
} else {
|
||||
config.sync_delay
|
||||
},
|
||||
config.saturation_latency_multiplier,
|
||||
);
|
||||
let mut points = Vec::new();
|
||||
for limit in &config.points {
|
||||
let runtime = ExecutionRuntimeHarness::start(ExecutionRuntimeHarnessConfig {
|
||||
max_in_flight_requests: Some(*limit),
|
||||
distributed_request_gate: None,
|
||||
})
|
||||
.await?;
|
||||
let total_requests = total_requests_for_limit(*limit, config.requests_per_point_multiplier);
|
||||
let probe = execution_probe_config(
|
||||
format!(
|
||||
"{}/v1/execute/{}",
|
||||
runtime.base_url(),
|
||||
if stream { "stream" } else { "sync" }
|
||||
),
|
||||
execution_plan(format!("{upstream_base_url}/v1/chat/completions"), stream),
|
||||
total_requests,
|
||||
*limit,
|
||||
config.timeout,
|
||||
);
|
||||
let started_at = Instant::now();
|
||||
let result = run_http_load_probe(&probe)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
let metrics =
|
||||
capture_gate_metrics(&format!("{}/metrics", runtime.base_url()), gate_name).await?;
|
||||
points.push(capacity_point(
|
||||
*limit,
|
||||
total_requests,
|
||||
duration_ms,
|
||||
result,
|
||||
metrics,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(CapacityCurveScenarioReport {
|
||||
name: scenario_name.to_string(),
|
||||
gate: gate_name.to_string(),
|
||||
latency_budget_ms,
|
||||
saturation_point: detect_saturation_point(&points, latency_budget_ms),
|
||||
points,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_tunnel_curve(
|
||||
scenario_name: &str,
|
||||
gate_name: &str,
|
||||
config: &CapacityCurveBaselineConfig,
|
||||
) -> Result<CapacityCurveScenarioReport, Box<dyn std::error::Error>> {
|
||||
let latency_budget_ms =
|
||||
scenario_latency_budget_ms(config.tunnel_hold, config.saturation_latency_multiplier);
|
||||
let mut points = Vec::new();
|
||||
for limit in &config.points {
|
||||
let relay_concurrency = (*limit).saturating_sub(1).max(1);
|
||||
let tunnel = TunnelHarness::start(TunnelHarnessConfig {
|
||||
max_streams: (*limit).max(128),
|
||||
ping_interval: Duration::from_secs(15),
|
||||
outbound_queue_capacity: 128,
|
||||
max_in_flight_requests: Some(*limit),
|
||||
distributed_request_gate: None,
|
||||
})
|
||||
.await?;
|
||||
let peer = connect_protocol_peer(tunnel.base_url(), config.tunnel_hold).await?;
|
||||
let total_requests =
|
||||
total_requests_for_limit(relay_concurrency, config.requests_per_point_multiplier);
|
||||
let probe = HttpLoadProbeConfig {
|
||||
url: format!(
|
||||
"{tunnel_base}{TUNNEL_RELAY_PATH_PREFIX}/node-baseline",
|
||||
tunnel_base = tunnel.base_url()
|
||||
),
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/octet-stream".to_string(),
|
||||
)]),
|
||||
body: Some(relay_envelope()),
|
||||
total_requests,
|
||||
concurrency: relay_concurrency,
|
||||
timeout: config.timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
};
|
||||
let started_at = Instant::now();
|
||||
let result = run_http_load_probe(&probe)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
let metrics =
|
||||
capture_gate_metrics(&format!("{}/metrics", tunnel.base_url()), gate_name).await?;
|
||||
points.push(capacity_point(
|
||||
*limit,
|
||||
total_requests,
|
||||
duration_ms,
|
||||
result,
|
||||
metrics,
|
||||
));
|
||||
drop(peer);
|
||||
}
|
||||
|
||||
Ok(CapacityCurveScenarioReport {
|
||||
name: scenario_name.to_string(),
|
||||
gate: gate_name.to_string(),
|
||||
latency_budget_ms,
|
||||
saturation_point: detect_saturation_point(&points, latency_budget_ms),
|
||||
points,
|
||||
})
|
||||
}
|
||||
|
||||
fn capacity_point(
|
||||
limit: usize,
|
||||
total_requests: usize,
|
||||
duration_ms: u64,
|
||||
result: HttpLoadProbeResult,
|
||||
metrics: GateMetricSnapshot,
|
||||
) -> CapacityCurvePointResult {
|
||||
let rejected_requests = result.status_counts.get(&503).copied().unwrap_or_default();
|
||||
let successful_requests = result
|
||||
.status_counts
|
||||
.iter()
|
||||
.filter(|(status, _)| **status >= 200 && **status < 300)
|
||||
.map(|(_, count)| *count)
|
||||
.sum::<usize>();
|
||||
let throughput_rps = if duration_ms == 0 {
|
||||
successful_requests as u64
|
||||
} else {
|
||||
((successful_requests as u64) * 1_000) / duration_ms.max(1)
|
||||
};
|
||||
|
||||
CapacityCurvePointResult {
|
||||
limit,
|
||||
concurrency: result.concurrency,
|
||||
total_requests,
|
||||
duration_ms,
|
||||
successful_requests,
|
||||
rejected_requests,
|
||||
failed_requests: result.failed_requests,
|
||||
throughput_rps,
|
||||
p50_ms: result.p50_ms,
|
||||
p95_ms: result.p95_ms,
|
||||
p99_ms: result.p99_ms,
|
||||
max_ms: result.max_ms,
|
||||
mean_ms: result.mean_ms,
|
||||
metrics,
|
||||
runtime: result.runtime,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_saturation_point(
|
||||
points: &[CapacityCurvePointResult],
|
||||
latency_budget_ms: u64,
|
||||
) -> Option<CapacityCurveSaturationPoint> {
|
||||
points.iter().find_map(|point| {
|
||||
let reason = if point.failed_requests > 0 {
|
||||
Some("failures_observed")
|
||||
} else if point.rejected_requests > 0 {
|
||||
Some("admission_rejections_observed")
|
||||
} else if point.p95_ms > latency_budget_ms {
|
||||
Some("latency_budget_exceeded")
|
||||
} else {
|
||||
None
|
||||
}?;
|
||||
Some(CapacityCurveSaturationPoint {
|
||||
limit: point.limit,
|
||||
concurrency: point.concurrency,
|
||||
reason: reason.to_string(),
|
||||
p95_ms: point.p95_ms,
|
||||
rejected_requests: point.rejected_requests,
|
||||
failed_requests: point.failed_requests,
|
||||
high_watermark: point.metrics.high_watermark,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn capture_gate_metrics(
|
||||
metrics_url: &str,
|
||||
gate_name: &str,
|
||||
) -> Result<GateMetricSnapshot, Box<dyn std::error::Error>> {
|
||||
let samples = fetch_prometheus_samples(metrics_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(GateMetricSnapshot {
|
||||
in_flight: find_metric_value_u64(&samples, "concurrency_in_flight", &[("gate", gate_name)])
|
||||
.unwrap_or_default(),
|
||||
available_permits: find_metric_value_u64(
|
||||
&samples,
|
||||
"concurrency_available_permits",
|
||||
&[("gate", gate_name)],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
high_watermark: find_metric_value_u64(
|
||||
&samples,
|
||||
"concurrency_high_watermark",
|
||||
&[("gate", gate_name)],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
rejected_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"concurrency_rejected_total",
|
||||
&[("gate", gate_name)],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scenario_latency_budget_ms(base: Duration, multiplier: u64) -> u64 {
|
||||
(base.as_millis() as u64).saturating_mul(multiplier.max(1))
|
||||
}
|
||||
|
||||
fn total_requests_for_limit(limit: usize, multiplier: usize) -> usize {
|
||||
limit.saturating_mul(multiplier.max(1))
|
||||
}
|
||||
|
||||
fn execution_probe_config(
|
||||
url: String,
|
||||
plan: ExecutionPlan,
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
timeout: Duration,
|
||||
) -> HttpLoadProbeConfig {
|
||||
HttpLoadProbeConfig {
|
||||
url,
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
body: Some(
|
||||
serde_json::to_vec(&plan).expect("execution plan should serialize for capacity curve"),
|
||||
),
|
||||
total_requests,
|
||||
concurrency,
|
||||
timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_probe_config(
|
||||
url: String,
|
||||
stream: bool,
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
timeout: Duration,
|
||||
) -> HttpLoadProbeConfig {
|
||||
HttpLoadProbeConfig {
|
||||
url,
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
(
|
||||
"authorization".to_string(),
|
||||
format!("Bearer {GATEWAY_HARNESS_API_KEY}"),
|
||||
),
|
||||
]),
|
||||
body: Some(
|
||||
serde_json::to_vec(&json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": stream,
|
||||
}))
|
||||
.expect("chat body should serialize"),
|
||||
),
|
||||
total_requests,
|
||||
concurrency,
|
||||
timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_plan(url: String, stream: bool) -> ExecutionPlan {
|
||||
ExecutionPlan {
|
||||
request_id: if stream {
|
||||
"capacity-curve-stream-request".to_string()
|
||||
} else {
|
||||
"capacity-curve-sync-request".to_string()
|
||||
},
|
||||
candidate_id: Some(if stream {
|
||||
"capacity-curve-stream-candidate".to_string()
|
||||
} else {
|
||||
"capacity-curve-sync-candidate".to_string()
|
||||
}),
|
||||
provider_name: Some("openai".to_string()),
|
||||
provider_id: "provider-capacity".to_string(),
|
||||
endpoint_id: "endpoint-capacity".to_string(),
|
||||
key_id: "key-capacity".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": stream,
|
||||
})),
|
||||
stream,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
model_name: Some("gpt-5".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(2_000),
|
||||
read_ms: Some(10_000),
|
||||
first_byte_ms: Some(5_000),
|
||||
total_ms: Some(10_000),
|
||||
..ExecutionTimeouts::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_delayed_upstream(sync_delay: Duration, stream_chunk_delay: Duration) -> Router {
|
||||
Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let sync_delay = sync_delay;
|
||||
let stream_chunk_delay = stream_chunk_delay;
|
||||
async move {
|
||||
let (_parts, body) = request.into_parts();
|
||||
let raw_body = to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.expect("capacity upstream body should read");
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&raw_body).unwrap_or_else(|_| json!({}));
|
||||
let stream = payload
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
if stream {
|
||||
let body = async_stream::stream! {
|
||||
tokio::time::sleep(stream_chunk_delay).await;
|
||||
yield Ok::<_, Infallible>(Bytes::from_static(
|
||||
b"data: {\"id\":\"chunk-1\",\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n",
|
||||
));
|
||||
tokio::time::sleep(stream_chunk_delay).await;
|
||||
yield Ok::<_, Infallible>(Bytes::from_static(
|
||||
b"data: {\"id\":\"chunk-2\",\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
|
||||
));
|
||||
tokio::time::sleep(stream_chunk_delay).await;
|
||||
yield Ok::<_, Infallible>(Bytes::from_static(b"data: [DONE]\n\n"));
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(http::header::CONTENT_TYPE, "text/event-stream")
|
||||
.body(Body::from_stream(body))
|
||||
.expect("capacity upstream stream response should build")
|
||||
} else {
|
||||
tokio::time::sleep(sync_delay).await;
|
||||
Json(json!({
|
||||
"id": "chatcmpl-capacity",
|
||||
"object": "chat.completion",
|
||||
"model": payload.get("model").and_then(|value| value.as_str()).unwrap_or("gpt-5"),
|
||||
"choices": [{"message": {"role": "assistant", "content": "hello"}}]
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn relay_envelope() -> Vec<u8> {
|
||||
let meta = protocol::RequestMeta {
|
||||
method: "POST".to_string(),
|
||||
url: "https://capacity.example/v1/chat/completions".to_string(),
|
||||
headers: std::collections::HashMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
stream: false,
|
||||
request_timeout_ms: None,
|
||||
stream_first_byte_timeout_ms: None,
|
||||
timeout: 30,
|
||||
follow_redirects: None,
|
||||
http1_only: false,
|
||||
provider_id: None,
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
transport_profile: None,
|
||||
};
|
||||
let meta_json = serde_json::to_vec(&meta).expect("hub relay metadata should serialize");
|
||||
let body = br#"{"model":"gpt-5","messages":[{"role":"user","content":"hello"}]}"#;
|
||||
let mut envelope = Vec::with_capacity(4 + meta_json.len() + body.len());
|
||||
envelope.extend_from_slice(&(meta_json.len() as u32).to_be_bytes());
|
||||
envelope.extend_from_slice(&meta_json);
|
||||
envelope.extend_from_slice(body);
|
||||
envelope
|
||||
}
|
||||
|
||||
async fn connect_protocol_peer(
|
||||
tunnel_base_url: &str,
|
||||
hold: Duration,
|
||||
) -> Result<tokio::task::JoinHandle<()>, Box<dyn std::error::Error>> {
|
||||
let ws_url = format!(
|
||||
"{}{}",
|
||||
tunnel_base_url.replace("http://", "ws://"),
|
||||
PROXY_TUNNEL_PATH
|
||||
);
|
||||
let request = ws_url.into_client_request()?;
|
||||
let mut request = request;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-node-id", http::HeaderValue::from_static("node-baseline"));
|
||||
request.headers_mut().insert(
|
||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||
http::HeaderValue::from_static(
|
||||
aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION_STR,
|
||||
),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-node-name",
|
||||
http::HeaderValue::from_static("proxy-baseline"),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-tunnel-max-streams",
|
||||
http::HeaderValue::from_static("512"),
|
||||
);
|
||||
|
||||
let (socket, _response) = tokio_tungstenite::connect_async(request).await?;
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_hello(&protocol::HelloPayload {
|
||||
protocol_version: aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION,
|
||||
capabilities: vec![
|
||||
"flow-control".to_string(),
|
||||
"reset-stream".to_string(),
|
||||
"graceful-drain".to_string(),
|
||||
],
|
||||
session_id: Some("capacity-curve-session".to_string()),
|
||||
replica_id: Some("capacity-curve-replica".to_string()),
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_settings(&protocol::SettingsPayload {
|
||||
initial_stream_window_bytes: 4 * 1024 * 1024,
|
||||
min_window_update_bytes: 1024 * 1024,
|
||||
drain_deadline_ms: 30_000,
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
Ok(tokio::spawn(async move {
|
||||
while let Some(message) = stream.next().await {
|
||||
let Ok(message) = message else {
|
||||
break;
|
||||
};
|
||||
match message {
|
||||
Message::Binary(data)
|
||||
if handle_binary_frame(&mut sink, data.to_vec(), hold)
|
||||
.await
|
||||
.is_err() =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Message::Ping(payload)
|
||||
if sink.send(Message::Pong(payload.clone())).await.is_err() =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = sink.close().await;
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_binary_frame<S>(
|
||||
sink: &mut S,
|
||||
data: Vec<u8>,
|
||||
hold: Duration,
|
||||
) -> Result<(), tokio_tungstenite::tungstenite::Error>
|
||||
where
|
||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
let Some(header) = protocol::FrameHeader::parse(&data) else {
|
||||
return Ok(());
|
||||
};
|
||||
match header.msg_type {
|
||||
protocol::PING => {
|
||||
let payload = protocol::frame_payload_by_header(&data, &header).unwrap_or(&[]);
|
||||
sink.send(Message::Binary(protocol::encode_pong(payload).into()))
|
||||
.await?;
|
||||
}
|
||||
protocol::REQUEST_HEADERS => {
|
||||
let payload = protocol::decode_payload(&data, &header).unwrap_or_default();
|
||||
let _ = serde_json::from_slice::<protocol::RequestMeta>(&payload);
|
||||
}
|
||||
protocol::REQUEST_BODY => {
|
||||
let payload = protocol::decode_payload(&data, &header).unwrap_or_default();
|
||||
if !payload.is_empty() {
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_window_update(header.stream_id, payload.len() as u32).into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
if header.flags & protocol::FLAG_END_STREAM == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(hold).await;
|
||||
let response_meta = protocol::ResponseMeta {
|
||||
status: 200,
|
||||
headers: vec![(
|
||||
"content-type".to_string(),
|
||||
"text/plain; charset=utf-8".to_string(),
|
||||
)],
|
||||
};
|
||||
let response_meta_json =
|
||||
serde_json::to_vec(&response_meta).expect("response metadata should serialize");
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(
|
||||
header.stream_id,
|
||||
protocol::RESPONSE_HEADERS,
|
||||
0,
|
||||
&response_meta_json,
|
||||
)
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
for chunk in [
|
||||
b"capacity-".as_slice(),
|
||||
b"tunnel-".as_slice(),
|
||||
b"stream".as_slice(),
|
||||
] {
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(header.stream_id, protocol::RESPONSE_BODY, 0, chunk)
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(header.stream_id, protocol::STREAM_END, 0, &[]).into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<CapacityCurveBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = CapacityCurveBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--points" => {
|
||||
config.points = next_value(&mut iter, "--points")?
|
||||
.split(',')
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| value.trim().parse::<usize>())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
}
|
||||
"--requests-per-point-multiplier" => {
|
||||
config.requests_per_point_multiplier =
|
||||
next_value(&mut iter, "--requests-per-point-multiplier")?.parse()?
|
||||
}
|
||||
"--sync-delay-ms" => {
|
||||
config.sync_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--sync-delay-ms")?.parse()?)
|
||||
}
|
||||
"--stream-chunk-delay-ms" => {
|
||||
config.stream_chunk_delay = Duration::from_millis(
|
||||
next_value(&mut iter, "--stream-chunk-delay-ms")?.parse()?,
|
||||
)
|
||||
}
|
||||
"--tunnel-hold-ms" => {
|
||||
config.tunnel_hold =
|
||||
Duration::from_millis(next_value(&mut iter, "--tunnel-hold-ms")?.parse()?)
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout =
|
||||
Duration::from_millis(next_value(&mut iter, "--timeout-ms")?.parse()?)
|
||||
}
|
||||
"--saturation-latency-multiplier" => {
|
||||
config.saturation_latency_multiplier =
|
||||
next_value(&mut iter, "--saturation-latency-multiplier")?.parse()?
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.points.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"capacity curve requires at least one point",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin capacity_curve_baseline -- [--points 8,16,32,64,128,256] [--requests-per-point-multiplier 8] [--sync-delay-ms 75] [--stream-chunk-delay-ms 25] [--tunnel-hold-ms 75] [--timeout-ms 10000] [--saturation-latency-multiplier 4] [--output /tmp/capacity_curve_baseline.json]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_data::driver::postgres::{
|
||||
DatabaseRecordId, PostgresLeaseClaimOptions, PostgresLeaseClaimSpec, PostgresLeaseRunnerConfig,
|
||||
PostgresPoolConfig,
|
||||
};
|
||||
use aether_data::PostgresBackend;
|
||||
use aether_runtime_state::{
|
||||
RedisClientConfig, RedisConsumerGroup, RedisConsumerName, RedisKeyspace, RedisLockLease,
|
||||
RedisLockRunner, RedisLockRunnerConfig, RedisStreamName, RedisStreamReclaimConfig,
|
||||
RedisStreamRunner, RedisStreamRunnerConfig,
|
||||
};
|
||||
use aether_testkit::{init_test_runtime_for, ManagedPostgresServer, ManagedRedisServer};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DependencyPressureBaselineConfig {
|
||||
redis_lock_total: usize,
|
||||
redis_lock_concurrency: usize,
|
||||
redis_stream_total: usize,
|
||||
redis_stream_concurrency: usize,
|
||||
redis_reclaim_total: usize,
|
||||
redis_reclaim_min_idle: Duration,
|
||||
postgres_rows: usize,
|
||||
postgres_lease_cycles: usize,
|
||||
postgres_lease_concurrency: usize,
|
||||
postgres_lease_batch_size: usize,
|
||||
postgres_lease_ms: u64,
|
||||
timeout: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
redis_url: Option<String>,
|
||||
postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DependencyPressureBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
redis_lock_total: 1_000,
|
||||
redis_lock_concurrency: 20,
|
||||
redis_stream_total: 2_000,
|
||||
redis_stream_concurrency: 20,
|
||||
redis_reclaim_total: 256,
|
||||
redis_reclaim_min_idle: Duration::from_millis(100),
|
||||
postgres_rows: 512,
|
||||
postgres_lease_cycles: 128,
|
||||
postgres_lease_concurrency: 16,
|
||||
postgres_lease_batch_size: 16,
|
||||
postgres_lease_ms: 250,
|
||||
timeout: Duration::from_secs(10),
|
||||
output_path: None,
|
||||
redis_url: None,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct OperationSummary {
|
||||
total_calls: usize,
|
||||
total_items: usize,
|
||||
failed_calls: usize,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RedisLockPressureReport {
|
||||
acquire: OperationSummary,
|
||||
renew: OperationSummary,
|
||||
release: OperationSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RedisStreamPressureReport {
|
||||
append: OperationSummary,
|
||||
read_group: OperationSummary,
|
||||
reclaim: OperationSummary,
|
||||
ack: OperationSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PostgresLeasePressureReport {
|
||||
claim: OperationSummary,
|
||||
renew: OperationSummary,
|
||||
release: OperationSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct DependencyPressureBaselineReport {
|
||||
suite: &'static str,
|
||||
redis_url: String,
|
||||
postgres_url: String,
|
||||
redis_lock: RedisLockPressureReport,
|
||||
redis_stream: RedisStreamPressureReport,
|
||||
postgres_lease: PostgresLeasePressureReport,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SummaryCollector {
|
||||
latencies_ms: tokio::sync::Mutex<Vec<u64>>,
|
||||
total_items: std::sync::atomic::AtomicUsize,
|
||||
failed_calls: std::sync::atomic::AtomicUsize,
|
||||
total_calls: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl SummaryCollector {
|
||||
async fn record(&self, elapsed: Duration, items: usize, failed: bool) {
|
||||
self.latencies_ms
|
||||
.lock()
|
||||
.await
|
||||
.push(elapsed.as_millis() as u64);
|
||||
self.total_calls
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
self.total_items
|
||||
.fetch_add(items, std::sync::atomic::Ordering::AcqRel);
|
||||
if failed {
|
||||
self.failed_calls
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
async fn summarize(&self) -> OperationSummary {
|
||||
let mut latencies = self.latencies_ms.lock().await.clone();
|
||||
latencies.sort_unstable();
|
||||
let (p50_ms, p95_ms, max_ms, mean_ms) = summarize_latencies(&latencies);
|
||||
OperationSummary {
|
||||
total_calls: self.total_calls.load(std::sync::atomic::Ordering::Acquire),
|
||||
total_items: self.total_items.load(std::sync::atomic::Ordering::Acquire),
|
||||
failed_calls: self.failed_calls.load(std::sync::atomic::Ordering::Acquire),
|
||||
p50_ms,
|
||||
p95_ms,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("dependency-pressure-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<DependencyPressureBaselineReport, Box<dyn std::error::Error>> {
|
||||
let managed_redis = if config.redis_url.is_none() {
|
||||
Some(ManagedRedisServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let managed_postgres = if config.postgres_url.is_none() {
|
||||
Some(ManagedPostgresServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let redis_url = config
|
||||
.redis_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_redis
|
||||
.as_ref()
|
||||
.map(|server| server.redis_url().to_string())
|
||||
})
|
||||
.expect("redis url should resolve");
|
||||
let postgres_url = config
|
||||
.postgres_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_postgres
|
||||
.as_ref()
|
||||
.map(|server| server.database_url().to_string())
|
||||
})
|
||||
.expect("postgres url should resolve");
|
||||
|
||||
let redis_config = RedisClientConfig {
|
||||
url: redis_url.clone(),
|
||||
key_prefix: Some(format!("aether-dependency-pressure-{}", std::process::id())),
|
||||
};
|
||||
let redis_keyspace = redis_config.keyspace();
|
||||
let postgres_backend = PostgresBackend::from_config(PostgresPoolConfig {
|
||||
database_url: postgres_url.clone(),
|
||||
min_connections: 1,
|
||||
max_connections: (config.postgres_lease_concurrency as u32).saturating_add(8),
|
||||
acquire_timeout_ms: config.timeout.as_millis() as u64,
|
||||
idle_timeout_ms: 60_000,
|
||||
max_lifetime_ms: 10 * 60_000,
|
||||
statement_cache_capacity: 128,
|
||||
require_ssl: false,
|
||||
})?;
|
||||
|
||||
bootstrap_postgres_lease_table(postgres_backend.pool_clone(), config).await?;
|
||||
|
||||
let lock_runner = RedisLockRunner::from_config(
|
||||
redis_config.clone(),
|
||||
RedisLockRunnerConfig {
|
||||
command_timeout_ms: Some(config.timeout.as_millis() as u64),
|
||||
default_ttl_ms: 5_000,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let stream_runner = RedisStreamRunner::from_config(
|
||||
redis_config,
|
||||
RedisStreamRunnerConfig {
|
||||
command_timeout_ms: Some(config.timeout.as_millis() as u64),
|
||||
read_block_ms: Some(10),
|
||||
read_count: 64,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let lease_runner = postgres_backend.lease_runner(PostgresLeaseRunnerConfig {
|
||||
statement_timeout_ms: Some(config.timeout.as_millis() as u64),
|
||||
lock_timeout_ms: Some(1_000),
|
||||
})?;
|
||||
|
||||
let redis_lock = benchmark_redis_lock(&redis_keyspace, &lock_runner, config).await?;
|
||||
let redis_stream = benchmark_redis_stream(&redis_keyspace, &stream_runner, config).await?;
|
||||
let postgres_lease = benchmark_postgres_lease(&lease_runner, config).await?;
|
||||
|
||||
Ok(DependencyPressureBaselineReport {
|
||||
suite: "dependency_pressure_baseline",
|
||||
redis_url,
|
||||
postgres_url,
|
||||
redis_lock,
|
||||
redis_stream,
|
||||
postgres_lease,
|
||||
})
|
||||
}
|
||||
|
||||
async fn bootstrap_postgres_lease_table(
|
||||
pool: sqlx::PgPool,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
sqlx::query("DROP TABLE IF EXISTS baseline_lease_jobs")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE baseline_lease_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lease_owner TEXT,
|
||||
lease_expires_at TIMESTAMPTZ
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let mut builder = sqlx::QueryBuilder::new("INSERT INTO baseline_lease_jobs (id, status) ");
|
||||
builder.push_values(0..config.postgres_rows, |mut row, index| {
|
||||
row.push_bind(format!("job-{index:05}")).push_bind("ready");
|
||||
});
|
||||
builder.build().execute(&pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn benchmark_redis_lock(
|
||||
keyspace: &RedisKeyspace,
|
||||
runner: &RedisLockRunner,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<RedisLockPressureReport, Box<dyn std::error::Error>> {
|
||||
let acquire = Arc::new(SummaryCollector::default());
|
||||
let renew = Arc::new(SummaryCollector::default());
|
||||
let release = Arc::new(SummaryCollector::default());
|
||||
let next = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
stream::iter(0..config.redis_lock_concurrency)
|
||||
.for_each_concurrent(config.redis_lock_concurrency, |_| {
|
||||
let runner = runner.clone();
|
||||
let acquire = acquire.clone();
|
||||
let renew = renew.clone();
|
||||
let release = release.clone();
|
||||
let next = next.clone();
|
||||
let keyspace = keyspace.clone();
|
||||
async move {
|
||||
loop {
|
||||
let index = next.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
if index >= config.redis_lock_total {
|
||||
break;
|
||||
}
|
||||
let owner = format!("lock-owner-{index}");
|
||||
let key = keyspace.lock_key(&format!("dependency-pressure-{index}"));
|
||||
let acquire_started = Instant::now();
|
||||
match runner.try_acquire(&key, &owner, None).await {
|
||||
Ok(Some(lease)) => {
|
||||
acquire.record(acquire_started.elapsed(), 1, false).await;
|
||||
record_redis_lock_follow_up(&runner, &lease, &renew, &release).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
acquire.record(acquire_started.elapsed(), 0, true).await;
|
||||
}
|
||||
Err(_) => {
|
||||
acquire.record(acquire_started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(RedisLockPressureReport {
|
||||
acquire: acquire.summarize().await,
|
||||
renew: renew.summarize().await,
|
||||
release: release.summarize().await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn record_redis_lock_follow_up(
|
||||
runner: &RedisLockRunner,
|
||||
lease: &RedisLockLease,
|
||||
renew: &SummaryCollector,
|
||||
release: &SummaryCollector,
|
||||
) {
|
||||
let renew_started = Instant::now();
|
||||
let renew_ok = runner.renew(lease, None).await.unwrap_or(false);
|
||||
renew
|
||||
.record(renew_started.elapsed(), usize::from(renew_ok), !renew_ok)
|
||||
.await;
|
||||
|
||||
let release_started = Instant::now();
|
||||
let release_ok = runner.release(lease).await.unwrap_or(false);
|
||||
release
|
||||
.record(
|
||||
release_started.elapsed(),
|
||||
usize::from(release_ok),
|
||||
!release_ok,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn benchmark_redis_stream(
|
||||
keyspace: &RedisKeyspace,
|
||||
runner: &RedisStreamRunner,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<RedisStreamPressureReport, Box<dyn std::error::Error>> {
|
||||
let stream = keyspace.stream_name("dependency-pressure");
|
||||
let group = RedisConsumerGroup("dependency-group".to_string());
|
||||
let consumer_a = RedisConsumerName("consumer-a".to_string());
|
||||
let consumer_b = RedisConsumerName("consumer-b".to_string());
|
||||
runner
|
||||
.ensure_consumer_group(&stream, &group, "0-0")
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let append = benchmark_redis_stream_append(runner, &stream, config).await?;
|
||||
let (read_group, drained_ids) =
|
||||
benchmark_redis_stream_read_group(runner, &stream, &group, &consumer_a, config).await?;
|
||||
let ack = SummaryCollector::default();
|
||||
benchmark_redis_stream_ack_into(&ack, runner, &stream, &group, &drained_ids).await?;
|
||||
let (reclaim, reclaimed_ids) =
|
||||
benchmark_redis_stream_reclaim(runner, &stream, &group, &consumer_a, &consumer_b, config)
|
||||
.await?;
|
||||
benchmark_redis_stream_ack_into(&ack, runner, &stream, &group, &reclaimed_ids).await?;
|
||||
|
||||
Ok(RedisStreamPressureReport {
|
||||
append,
|
||||
read_group,
|
||||
reclaim,
|
||||
ack: ack.summarize().await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn benchmark_redis_stream_append(
|
||||
runner: &RedisStreamRunner,
|
||||
stream: &RedisStreamName,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<OperationSummary, Box<dyn std::error::Error>> {
|
||||
let collector = Arc::new(SummaryCollector::default());
|
||||
let next = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
stream::iter(0..config.redis_stream_concurrency)
|
||||
.for_each_concurrent(config.redis_stream_concurrency, |_| {
|
||||
let runner = runner.clone();
|
||||
let stream = stream.clone();
|
||||
let collector = collector.clone();
|
||||
let next = next.clone();
|
||||
async move {
|
||||
loop {
|
||||
let index = next.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
if index >= config.redis_stream_total {
|
||||
break;
|
||||
}
|
||||
let started = Instant::now();
|
||||
let result = runner
|
||||
.append_json(
|
||||
&stream,
|
||||
"payload",
|
||||
&serde_json::json!({
|
||||
"job_id": index,
|
||||
"kind": "dependency-pressure",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
collector
|
||||
.record(
|
||||
started.elapsed(),
|
||||
usize::from(result.is_ok()),
|
||||
result.is_err(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(collector.summarize().await)
|
||||
}
|
||||
|
||||
async fn benchmark_redis_stream_read_group(
|
||||
runner: &RedisStreamRunner,
|
||||
stream: &RedisStreamName,
|
||||
group: &RedisConsumerGroup,
|
||||
consumer: &RedisConsumerName,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<(OperationSummary, Vec<String>), Box<dyn std::error::Error>> {
|
||||
let collector = SummaryCollector::default();
|
||||
let mut ids = Vec::with_capacity(config.redis_stream_total);
|
||||
|
||||
while ids.len() < config.redis_stream_total {
|
||||
let started = Instant::now();
|
||||
match runner.read_group(stream, group, consumer).await {
|
||||
Ok(entries) => {
|
||||
let item_count = entries.len();
|
||||
ids.extend(entries.into_iter().map(|entry| entry.id));
|
||||
collector.record(started.elapsed(), item_count, false).await;
|
||||
}
|
||||
Err(_) => {
|
||||
collector.record(started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((collector.summarize().await, ids))
|
||||
}
|
||||
|
||||
async fn benchmark_redis_stream_reclaim(
|
||||
runner: &RedisStreamRunner,
|
||||
stream: &RedisStreamName,
|
||||
group: &RedisConsumerGroup,
|
||||
consumer_a: &RedisConsumerName,
|
||||
consumer_b: &RedisConsumerName,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<(OperationSummary, Vec<String>), Box<dyn std::error::Error>> {
|
||||
for index in 0..config.redis_reclaim_total {
|
||||
runner
|
||||
.append_json(
|
||||
stream,
|
||||
"payload",
|
||||
&serde_json::json!({
|
||||
"job_id": format!("reclaim-{index}"),
|
||||
"kind": "dependency-pressure",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
|
||||
let mut pending_ids = Vec::with_capacity(config.redis_reclaim_total);
|
||||
while pending_ids.len() < config.redis_reclaim_total {
|
||||
let entries = runner
|
||||
.read_group(stream, group, consumer_a)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
pending_ids.extend(entries.into_iter().map(|entry| entry.id));
|
||||
}
|
||||
|
||||
tokio::time::sleep(config.redis_reclaim_min_idle + Duration::from_millis(20)).await;
|
||||
|
||||
let collector = SummaryCollector::default();
|
||||
let mut reclaimed_ids = Vec::with_capacity(config.redis_reclaim_total);
|
||||
let mut next_start_id = "0-0".to_string();
|
||||
|
||||
while reclaimed_ids.len() < config.redis_reclaim_total {
|
||||
let started = Instant::now();
|
||||
match runner
|
||||
.claim_stale(
|
||||
stream,
|
||||
group,
|
||||
consumer_b,
|
||||
&next_start_id,
|
||||
RedisStreamReclaimConfig {
|
||||
min_idle_ms: config.redis_reclaim_min_idle.as_millis() as u64,
|
||||
count: 64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
next_start_id = result.next_start_id.clone();
|
||||
let item_count = result.entries.len();
|
||||
reclaimed_ids.extend(result.entries.into_iter().map(|entry| entry.id));
|
||||
collector.record(started.elapsed(), item_count, false).await;
|
||||
}
|
||||
Err(_) => {
|
||||
collector.record(started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((collector.summarize().await, reclaimed_ids))
|
||||
}
|
||||
|
||||
async fn benchmark_redis_stream_ack_into(
|
||||
collector: &SummaryCollector,
|
||||
runner: &RedisStreamRunner,
|
||||
stream: &RedisStreamName,
|
||||
group: &RedisConsumerGroup,
|
||||
ids: &[String],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
for chunk in ids.chunks(64) {
|
||||
let started = Instant::now();
|
||||
match runner.ack(stream, group, chunk).await {
|
||||
Ok(acked) => {
|
||||
collector.record(started.elapsed(), acked, false).await;
|
||||
}
|
||||
Err(_) => {
|
||||
collector.record(started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn benchmark_postgres_lease(
|
||||
runner: &aether_data::driver::postgres::PostgresLeaseRunner,
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<PostgresLeasePressureReport, Box<dyn std::error::Error>> {
|
||||
let spec = PostgresLeaseClaimSpec {
|
||||
table: "baseline_lease_jobs",
|
||||
id_column: "id",
|
||||
lease_owner_column: "lease_owner",
|
||||
lease_expires_at_column: "lease_expires_at",
|
||||
eligibility_predicate_sql: "status = 'ready'",
|
||||
order_by_sql: "id ASC",
|
||||
};
|
||||
let claim_options = PostgresLeaseClaimOptions {
|
||||
batch_size: config.postgres_lease_batch_size,
|
||||
lease_ms: config.postgres_lease_ms,
|
||||
};
|
||||
|
||||
let claim = Arc::new(SummaryCollector::default());
|
||||
let renew = Arc::new(SummaryCollector::default());
|
||||
let release = Arc::new(SummaryCollector::default());
|
||||
let next_cycle = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
stream::iter(0..config.postgres_lease_concurrency)
|
||||
.for_each_concurrent(config.postgres_lease_concurrency, |worker_index| {
|
||||
let runner = runner.clone();
|
||||
let spec = spec.clone();
|
||||
let claim = claim.clone();
|
||||
let renew = renew.clone();
|
||||
let release = release.clone();
|
||||
let next_cycle = next_cycle.clone();
|
||||
async move {
|
||||
let owner = format!("lease-owner-{worker_index}");
|
||||
loop {
|
||||
let cycle = next_cycle.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
if cycle >= config.postgres_lease_cycles {
|
||||
break;
|
||||
}
|
||||
|
||||
let claim_started = Instant::now();
|
||||
match runner.claim_ids(&spec, claim_options, &owner).await {
|
||||
Ok(ids) => {
|
||||
let item_count = ids.len();
|
||||
claim
|
||||
.record(claim_started.elapsed(), item_count, false)
|
||||
.await;
|
||||
if ids.is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
record_postgres_lease_follow_up(
|
||||
&runner,
|
||||
&spec,
|
||||
&owner,
|
||||
&ids,
|
||||
config.postgres_lease_ms,
|
||||
&renew,
|
||||
&release,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(_) => {
|
||||
claim.record(claim_started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(PostgresLeasePressureReport {
|
||||
claim: claim.summarize().await,
|
||||
renew: renew.summarize().await,
|
||||
release: release.summarize().await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn record_postgres_lease_follow_up(
|
||||
runner: &aether_data::driver::postgres::PostgresLeaseRunner,
|
||||
spec: &PostgresLeaseClaimSpec,
|
||||
owner: &str,
|
||||
ids: &[DatabaseRecordId],
|
||||
lease_ms: u64,
|
||||
renew: &SummaryCollector,
|
||||
release: &SummaryCollector,
|
||||
) {
|
||||
let renew_started = Instant::now();
|
||||
match runner.renew_ids(spec, ids, owner, lease_ms).await {
|
||||
Ok(renewed) => {
|
||||
let item_count = renewed.len();
|
||||
renew
|
||||
.record(renew_started.elapsed(), item_count, false)
|
||||
.await;
|
||||
}
|
||||
Err(_) => {
|
||||
renew.record(renew_started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
|
||||
let release_started = Instant::now();
|
||||
match runner.release_ids(spec, ids, owner).await {
|
||||
Ok(released) => {
|
||||
let item_count = released.len();
|
||||
release
|
||||
.record(release_started.elapsed(), item_count, false)
|
||||
.await;
|
||||
}
|
||||
Err(_) => {
|
||||
release.record(release_started.elapsed(), 0, true).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_latencies(latencies: &[u64]) -> (u64, u64, u64, u64) {
|
||||
if latencies.is_empty() {
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
let max_ms = *latencies.last().unwrap_or(&0);
|
||||
let mean_ms = latencies.iter().sum::<u64>() / latencies.len() as u64;
|
||||
let p50_ms = percentile(latencies, 50);
|
||||
let p95_ms = percentile(latencies, 95);
|
||||
(p50_ms, p95_ms, max_ms, mean_ms)
|
||||
}
|
||||
|
||||
fn percentile(latencies: &[u64], percentile: u8) -> u64 {
|
||||
if latencies.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let last_index = latencies.len() - 1;
|
||||
let rank = ((last_index as f64) * (percentile as f64 / 100.0)).round() as usize;
|
||||
latencies[rank.min(last_index)]
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<DependencyPressureBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = DependencyPressureBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--redis-lock-total" => {
|
||||
config.redis_lock_total = iter
|
||||
.next()
|
||||
.ok_or("missing value for --redis-lock-total")?
|
||||
.parse()?;
|
||||
}
|
||||
"--redis-lock-concurrency" => {
|
||||
config.redis_lock_concurrency = iter
|
||||
.next()
|
||||
.ok_or("missing value for --redis-lock-concurrency")?
|
||||
.parse()?;
|
||||
}
|
||||
"--redis-stream-total" => {
|
||||
config.redis_stream_total = iter
|
||||
.next()
|
||||
.ok_or("missing value for --redis-stream-total")?
|
||||
.parse()?;
|
||||
}
|
||||
"--redis-stream-concurrency" => {
|
||||
config.redis_stream_concurrency = iter
|
||||
.next()
|
||||
.ok_or("missing value for --redis-stream-concurrency")?
|
||||
.parse()?;
|
||||
}
|
||||
"--redis-reclaim-total" => {
|
||||
config.redis_reclaim_total = iter
|
||||
.next()
|
||||
.ok_or("missing value for --redis-reclaim-total")?
|
||||
.parse()?;
|
||||
}
|
||||
"--redis-reclaim-min-idle-ms" => {
|
||||
config.redis_reclaim_min_idle = Duration::from_millis(
|
||||
iter.next()
|
||||
.ok_or("missing value for --redis-reclaim-min-idle-ms")?
|
||||
.parse()?,
|
||||
);
|
||||
}
|
||||
"--postgres-rows" => {
|
||||
config.postgres_rows = iter
|
||||
.next()
|
||||
.ok_or("missing value for --postgres-rows")?
|
||||
.parse()?;
|
||||
}
|
||||
"--postgres-lease-cycles" => {
|
||||
config.postgres_lease_cycles = iter
|
||||
.next()
|
||||
.ok_or("missing value for --postgres-lease-cycles")?
|
||||
.parse()?;
|
||||
}
|
||||
"--postgres-lease-concurrency" => {
|
||||
config.postgres_lease_concurrency = iter
|
||||
.next()
|
||||
.ok_or("missing value for --postgres-lease-concurrency")?
|
||||
.parse()?;
|
||||
}
|
||||
"--postgres-lease-batch-size" => {
|
||||
config.postgres_lease_batch_size = iter
|
||||
.next()
|
||||
.ok_or("missing value for --postgres-lease-batch-size")?
|
||||
.parse()?;
|
||||
}
|
||||
"--postgres-lease-ms" => {
|
||||
config.postgres_lease_ms = iter
|
||||
.next()
|
||||
.ok_or("missing value for --postgres-lease-ms")?
|
||||
.parse()?;
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout = Duration::from_millis(
|
||||
iter.next()
|
||||
.ok_or("missing value for --timeout-ms")?
|
||||
.parse()?,
|
||||
);
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(
|
||||
iter.next().ok_or("missing value for --output")?,
|
||||
));
|
||||
}
|
||||
"--redis-url" => {
|
||||
config.redis_url = Some(iter.next().ok_or("missing value for --redis-url")?);
|
||||
}
|
||||
"--postgres-url" => {
|
||||
config.postgres_url = Some(iter.next().ok_or("missing value for --postgres-url")?);
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument: {other}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn validate_config(
|
||||
config: &DependencyPressureBaselineConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if config.redis_lock_total == 0
|
||||
|| config.redis_lock_concurrency == 0
|
||||
|| config.redis_stream_total == 0
|
||||
|| config.redis_stream_concurrency == 0
|
||||
|| config.redis_reclaim_total == 0
|
||||
|| config.postgres_rows == 0
|
||||
|| config.postgres_lease_cycles == 0
|
||||
|| config.postgres_lease_concurrency == 0
|
||||
|| config.postgres_lease_batch_size == 0
|
||||
|| config.postgres_lease_ms == 0
|
||||
|| config.timeout.is_zero()
|
||||
{
|
||||
return Err("all dependency pressure baseline numeric settings must be positive".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_data::driver::postgres::{
|
||||
PostgresLeaseClaimOptions, PostgresLeaseClaimSpec, PostgresLeaseRunnerConfig,
|
||||
PostgresPoolConfig, PostgresTransactionOptions,
|
||||
};
|
||||
use aether_data::{DataLayerError, PostgresBackend};
|
||||
use aether_runtime_state::{RedisClientConfig, RedisLockRunner, RedisLockRunnerConfig};
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, reserve_local_port, BenchmarkRuntimeSampler, BenchmarkRuntimeSnapshot,
|
||||
ManagedPostgresServer, ManagedRedisServer, TunnelHarness, TunnelHarnessConfig,
|
||||
};
|
||||
use futures_util::{FutureExt, StreamExt};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FailureRecoveryBaselineConfig {
|
||||
redis_attempts: usize,
|
||||
redis_concurrency: usize,
|
||||
redis_restart_delay: Duration,
|
||||
redis_downtime: Duration,
|
||||
postgres_statement_timeout: Duration,
|
||||
postgres_sleep: Duration,
|
||||
tunnel_attempts: usize,
|
||||
tunnel_concurrency: usize,
|
||||
tunnel_hold: Duration,
|
||||
tunnel_restart_delay: Duration,
|
||||
tunnel_downtime: Duration,
|
||||
timeout: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
redis_url: Option<String>,
|
||||
postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for FailureRecoveryBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
redis_attempts: 400,
|
||||
redis_concurrency: 10,
|
||||
redis_restart_delay: Duration::from_millis(200),
|
||||
redis_downtime: Duration::from_millis(150),
|
||||
postgres_statement_timeout: Duration::from_millis(50),
|
||||
postgres_sleep: Duration::from_millis(200),
|
||||
tunnel_attempts: 60,
|
||||
tunnel_concurrency: 4,
|
||||
tunnel_hold: Duration::from_millis(50),
|
||||
tunnel_restart_delay: Duration::from_millis(200),
|
||||
tunnel_downtime: Duration::from_millis(150),
|
||||
timeout: Duration::from_secs(10),
|
||||
output_path: None,
|
||||
redis_url: None,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FaultPhase {
|
||||
Pre,
|
||||
During,
|
||||
Post,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
struct PhaseCounts {
|
||||
pre_successes: usize,
|
||||
pre_failures: usize,
|
||||
during_successes: usize,
|
||||
during_failures: usize,
|
||||
post_successes: usize,
|
||||
post_failures: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RecoverySummary {
|
||||
total_attempts: usize,
|
||||
successful_attempts: usize,
|
||||
failed_attempts: usize,
|
||||
recovered_after_restart_ms: Option<u64>,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
p99_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
phase_counts: PhaseCounts,
|
||||
runtime: BenchmarkRuntimeSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PostgresSlowQueryRecoveryReport {
|
||||
slow_query_timed_out: bool,
|
||||
slow_query_latency_ms: u64,
|
||||
recovery_claim_succeeded: bool,
|
||||
recovery_claim_latency_ms: u64,
|
||||
recovery_claimed_items: usize,
|
||||
runtime: BenchmarkRuntimeSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FailureRecoveryBaselineReport {
|
||||
suite: &'static str,
|
||||
redis_url: String,
|
||||
postgres_url: String,
|
||||
redis_restart: RecoverySummary,
|
||||
postgres_slow_query: PostgresSlowQueryRecoveryReport,
|
||||
tunnel_restart: RecoverySummary,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecoveryCollector {
|
||||
latencies_ms: Mutex<Vec<u64>>,
|
||||
phase_counts: Mutex<PhaseCounts>,
|
||||
successful_attempts: AtomicUsize,
|
||||
failed_attempts: AtomicUsize,
|
||||
}
|
||||
|
||||
impl RecoveryCollector {
|
||||
async fn record(&self, phase: FaultPhase, success: bool, latency: Duration) {
|
||||
self.latencies_ms
|
||||
.lock()
|
||||
.await
|
||||
.push(latency.as_millis() as u64);
|
||||
let mut counts = self.phase_counts.lock().await;
|
||||
match (phase, success) {
|
||||
(FaultPhase::Pre, true) => counts.pre_successes += 1,
|
||||
(FaultPhase::Pre, false) => counts.pre_failures += 1,
|
||||
(FaultPhase::During, true) => counts.during_successes += 1,
|
||||
(FaultPhase::During, false) => counts.during_failures += 1,
|
||||
(FaultPhase::Post, true) => counts.post_successes += 1,
|
||||
(FaultPhase::Post, false) => counts.post_failures += 1,
|
||||
}
|
||||
if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::AcqRel);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
async fn summarize(
|
||||
&self,
|
||||
recovered_after_restart_ms: Option<u64>,
|
||||
runtime: BenchmarkRuntimeSnapshot,
|
||||
) -> RecoverySummary {
|
||||
let mut latencies = self.latencies_ms.lock().await.clone();
|
||||
latencies.sort_unstable();
|
||||
let (p50_ms, p95_ms, p99_ms, max_ms, mean_ms) = summarize_latencies(&latencies);
|
||||
let phase_counts = self.phase_counts.lock().await.clone();
|
||||
RecoverySummary {
|
||||
total_attempts: self.successful_attempts.load(Ordering::Acquire)
|
||||
+ self.failed_attempts.load(Ordering::Acquire),
|
||||
successful_attempts: self.successful_attempts.load(Ordering::Acquire),
|
||||
failed_attempts: self.failed_attempts.load(Ordering::Acquire),
|
||||
recovered_after_restart_ms,
|
||||
p50_ms,
|
||||
p95_ms,
|
||||
p99_ms,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
phase_counts,
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("failure-recovery-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<FailureRecoveryBaselineReport, Box<dyn std::error::Error>> {
|
||||
let managed_redis = if config.redis_url.is_none() {
|
||||
Some(ManagedRedisServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let managed_postgres = if config.postgres_url.is_none() {
|
||||
Some(ManagedPostgresServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let redis_url = config
|
||||
.redis_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_redis
|
||||
.as_ref()
|
||||
.map(|server| server.redis_url().to_string())
|
||||
})
|
||||
.expect("redis url should resolve");
|
||||
let postgres_url = config
|
||||
.postgres_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_postgres
|
||||
.as_ref()
|
||||
.map(|server| server.database_url().to_string())
|
||||
})
|
||||
.expect("postgres url should resolve");
|
||||
|
||||
let redis_server = Arc::new(Mutex::new(
|
||||
managed_redis.ok_or("failure recovery baseline requires managed redis")?,
|
||||
));
|
||||
let postgres_server =
|
||||
managed_postgres.ok_or("failure recovery baseline requires managed postgres")?;
|
||||
|
||||
let redis_restart = benchmark_redis_restart_recovery(redis_server.clone(), config).await?;
|
||||
let postgres_slow_query =
|
||||
benchmark_postgres_slow_query_recovery(postgres_server.database_url(), config).await?;
|
||||
let tunnel_restart = benchmark_tunnel_restart_recovery(config).await?;
|
||||
|
||||
Ok(FailureRecoveryBaselineReport {
|
||||
suite: "failure_recovery_baseline",
|
||||
redis_url,
|
||||
postgres_url,
|
||||
redis_restart,
|
||||
postgres_slow_query,
|
||||
tunnel_restart,
|
||||
})
|
||||
}
|
||||
|
||||
async fn benchmark_redis_restart_recovery(
|
||||
redis_server: Arc<Mutex<ManagedRedisServer>>,
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<RecoverySummary, Box<dyn std::error::Error>> {
|
||||
let mut runtime_sampler = BenchmarkRuntimeSampler::new();
|
||||
let redis_url = redis_server.lock().await.redis_url().to_string();
|
||||
let redis_config = RedisClientConfig {
|
||||
url: redis_url,
|
||||
key_prefix: Some(format!("aether-failure-recovery-{}", std::process::id())),
|
||||
};
|
||||
let keyspace = redis_config.keyspace();
|
||||
let runner = RedisLockRunner::from_config(
|
||||
redis_config,
|
||||
RedisLockRunnerConfig {
|
||||
command_timeout_ms: Some(250),
|
||||
default_ttl_ms: 1_000,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let collector = Arc::new(RecoveryCollector::default());
|
||||
let next_attempt = Arc::new(AtomicUsize::new(0));
|
||||
let phase = Arc::new(AtomicUsize::new(0));
|
||||
let recovered_after_restart_ms = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let absolute_restart_started = Arc::new(Mutex::new(None::<Instant>));
|
||||
let restart_phase = phase.clone();
|
||||
let restart_started = absolute_restart_started.clone();
|
||||
let server_for_restart = redis_server.clone();
|
||||
let redis_restart_delay = config.redis_restart_delay;
|
||||
let redis_downtime = config.redis_downtime;
|
||||
let restart_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(redis_restart_delay).await;
|
||||
restart_phase.store(1, Ordering::Release);
|
||||
*restart_started.lock().await = Some(Instant::now());
|
||||
{
|
||||
let mut server = server_for_restart.lock().await;
|
||||
server.stop().map_err(std::io::Error::other)?;
|
||||
}
|
||||
tokio::time::sleep(redis_downtime).await;
|
||||
{
|
||||
let mut server = server_for_restart.lock().await;
|
||||
server
|
||||
.restart()
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
}
|
||||
restart_phase.store(2, Ordering::Release);
|
||||
Ok::<(), std::io::Error>(())
|
||||
});
|
||||
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for _ in 0..config.redis_concurrency {
|
||||
let runner = runner.clone();
|
||||
let keyspace = keyspace.clone();
|
||||
let collector = collector.clone();
|
||||
let next_attempt = next_attempt.clone();
|
||||
let phase = phase.clone();
|
||||
let restart_started = absolute_restart_started.clone();
|
||||
let recovered_after_restart_ms = recovered_after_restart_ms.clone();
|
||||
let total_attempts = config.redis_attempts;
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
let current = next_attempt.fetch_add(1, Ordering::AcqRel);
|
||||
if current >= total_attempts {
|
||||
break;
|
||||
}
|
||||
let current_phase = classify_phase(phase.load(Ordering::Acquire));
|
||||
let key = keyspace.lock_key(&format!("recovery-lock-{current}"));
|
||||
let owner = format!("redis-owner-{current}");
|
||||
let started = Instant::now();
|
||||
let success = match runner.try_acquire(&key, &owner, Some(1_000)).await {
|
||||
Ok(Some(lease)) => runner.release(&lease).await.unwrap_or(false),
|
||||
Ok(None) => false,
|
||||
Err(_) => false,
|
||||
};
|
||||
if success && matches!(current_phase, FaultPhase::Post) {
|
||||
if let Some(restart_started_at) = *restart_started.lock().await {
|
||||
let elapsed = restart_started_at.elapsed().as_millis() as u64;
|
||||
let _ = recovered_after_restart_ms.compare_exchange(
|
||||
0,
|
||||
elapsed,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
);
|
||||
}
|
||||
}
|
||||
collector
|
||||
.record(current_phase, success, started.elapsed())
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
result.map_err(std::io::Error::other)?;
|
||||
}
|
||||
restart_task
|
||||
.await
|
||||
.map_err(|err| format!("redis restart task failed: {err}"))?
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(collector
|
||||
.summarize(
|
||||
load_optional_atomic_u64(&recovered_after_restart_ms),
|
||||
runtime_sampler.snapshot(),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn benchmark_postgres_slow_query_recovery(
|
||||
postgres_url: &str,
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<PostgresSlowQueryRecoveryReport, Box<dyn std::error::Error>> {
|
||||
let mut runtime_sampler = BenchmarkRuntimeSampler::new();
|
||||
let backend = PostgresBackend::from_config(PostgresPoolConfig {
|
||||
database_url: postgres_url.to_string(),
|
||||
min_connections: 1,
|
||||
max_connections: 8,
|
||||
acquire_timeout_ms: config.timeout.as_millis() as u64,
|
||||
idle_timeout_ms: 60_000,
|
||||
max_lifetime_ms: 10 * 60_000,
|
||||
statement_cache_capacity: 64,
|
||||
require_ssl: false,
|
||||
})?;
|
||||
bootstrap_failure_recovery_lease_table(backend.pool_clone()).await?;
|
||||
let transaction_runner = backend.transaction_runner();
|
||||
let lease_runner = backend.lease_runner(PostgresLeaseRunnerConfig {
|
||||
statement_timeout_ms: Some(config.timeout.as_millis() as u64),
|
||||
lock_timeout_ms: Some(1_000),
|
||||
})?;
|
||||
let postgres_sleep_secs = config.postgres_sleep.as_secs_f64();
|
||||
|
||||
let slow_query_started = Instant::now();
|
||||
let slow_query_timed_out = transaction_runner
|
||||
.run(
|
||||
PostgresTransactionOptions {
|
||||
statement_timeout_ms: Some(config.postgres_statement_timeout.as_millis() as u64),
|
||||
..PostgresTransactionOptions::read_write()
|
||||
},
|
||||
|tx| {
|
||||
async move {
|
||||
sqlx::query("SELECT pg_sleep($1::double precision)")
|
||||
.bind(postgres_sleep_secs)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(DataLayerError::postgres)?;
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err();
|
||||
let slow_query_latency_ms = slow_query_started.elapsed().as_millis() as u64;
|
||||
|
||||
let recovery_claim_started = Instant::now();
|
||||
let claimed_ids = lease_runner
|
||||
.claim_ids(
|
||||
&PostgresLeaseClaimSpec {
|
||||
table: "baseline_failure_lease_jobs",
|
||||
id_column: "id",
|
||||
lease_owner_column: "lease_owner",
|
||||
lease_expires_at_column: "lease_expires_at",
|
||||
eligibility_predicate_sql: "status = 'ready'",
|
||||
order_by_sql: "id ASC",
|
||||
},
|
||||
PostgresLeaseClaimOptions {
|
||||
batch_size: 8,
|
||||
lease_ms: 250,
|
||||
},
|
||||
"recovery-owner",
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let recovery_claim_latency_ms = recovery_claim_started.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(PostgresSlowQueryRecoveryReport {
|
||||
slow_query_timed_out,
|
||||
slow_query_latency_ms,
|
||||
recovery_claim_succeeded: !claimed_ids.is_empty(),
|
||||
recovery_claim_latency_ms,
|
||||
recovery_claimed_items: claimed_ids.len(),
|
||||
runtime: runtime_sampler.snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn bootstrap_failure_recovery_lease_table(
|
||||
pool: sqlx::PgPool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
sqlx::query("DROP TABLE IF EXISTS baseline_failure_lease_jobs")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE baseline_failure_lease_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lease_owner TEXT,
|
||||
lease_expires_at TIMESTAMPTZ
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let mut builder =
|
||||
sqlx::QueryBuilder::new("INSERT INTO baseline_failure_lease_jobs (id, status) ");
|
||||
builder.push_values(0..32, |mut row, index| {
|
||||
row.push_bind(format!("recovery-job-{index:03}"))
|
||||
.push_bind("ready");
|
||||
});
|
||||
builder.build().execute(&pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn benchmark_tunnel_restart_recovery(
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<RecoverySummary, Box<dyn std::error::Error>> {
|
||||
let mut runtime_sampler = BenchmarkRuntimeSampler::new();
|
||||
let port = reserve_local_port()?;
|
||||
let tunnel_config = TunnelHarnessConfig::default();
|
||||
let initial_tunnel = TunnelHarness::start_on_port(tunnel_config.clone(), port).await?;
|
||||
let ws_url = format!("ws://127.0.0.1:{port}{PROXY_TUNNEL_PATH}");
|
||||
let collector = Arc::new(RecoveryCollector::default());
|
||||
let next_attempt = Arc::new(AtomicUsize::new(0));
|
||||
let phase = Arc::new(AtomicUsize::new(0));
|
||||
let recovered_after_restart_ms = Arc::new(AtomicU64::new(0));
|
||||
let restart_started = Arc::new(Mutex::new(None::<Instant>));
|
||||
let (done_tx, done_rx) = oneshot::channel::<()>();
|
||||
|
||||
let tunnel_restart_delay = config.tunnel_restart_delay;
|
||||
let tunnel_downtime = config.tunnel_downtime;
|
||||
let phase_for_restart = phase.clone();
|
||||
let restart_started_for_task = restart_started.clone();
|
||||
let restart_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(tunnel_restart_delay).await;
|
||||
phase_for_restart.store(1, Ordering::Release);
|
||||
*restart_started_for_task.lock().await = Some(Instant::now());
|
||||
drop(initial_tunnel);
|
||||
tokio::time::sleep(tunnel_downtime).await;
|
||||
let restarted_tunnel = start_tunnel_on_port_retry(tunnel_config, port).await?;
|
||||
phase_for_restart.store(2, Ordering::Release);
|
||||
let _ = done_rx.await;
|
||||
drop(restarted_tunnel);
|
||||
Ok::<(), String>(())
|
||||
});
|
||||
|
||||
let mut workers = tokio::task::JoinSet::new();
|
||||
for worker_index in 0..config.tunnel_concurrency {
|
||||
let ws_url = ws_url.clone();
|
||||
let next_attempt = next_attempt.clone();
|
||||
let collector = collector.clone();
|
||||
let phase = phase.clone();
|
||||
let recovered_after_restart_ms = recovered_after_restart_ms.clone();
|
||||
let restart_started = restart_started.clone();
|
||||
let timeout = config.timeout;
|
||||
let hold = config.tunnel_hold;
|
||||
let total_attempts = config.tunnel_attempts;
|
||||
workers.spawn(async move {
|
||||
loop {
|
||||
let current = next_attempt.fetch_add(1, Ordering::AcqRel);
|
||||
if current >= total_attempts {
|
||||
break;
|
||||
}
|
||||
let current_phase = classify_phase(phase.load(Ordering::Acquire));
|
||||
let mut request = ws_url
|
||||
.clone()
|
||||
.into_client_request()
|
||||
.map_err(|err| format!("failed to build websocket request: {err}"))?;
|
||||
request.headers_mut().insert(
|
||||
"x-node-id",
|
||||
format!("recovery-node-{worker_index}-{current}")
|
||||
.parse()
|
||||
.map_err(|err| format!("failed to build x-node-id header: {err}"))?,
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||
aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION_STR
|
||||
.parse()
|
||||
.expect("protocol version header value should be valid"),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-node-name",
|
||||
format!("recovery-node-{worker_index}-{current}")
|
||||
.parse()
|
||||
.map_err(|err| format!("failed to build x-node-name header: {err}"))?,
|
||||
);
|
||||
|
||||
let started = Instant::now();
|
||||
let success =
|
||||
match tokio::time::timeout(timeout, tokio_tungstenite::connect_async(request))
|
||||
.await
|
||||
{
|
||||
Ok(Ok((mut ws, _))) => {
|
||||
tokio::time::sleep(hold).await;
|
||||
let _ = ws.close(None).await;
|
||||
while let Some(message) = ws.next().await {
|
||||
if matches!(message, Ok(Message::Close(_))) || message.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if success && matches!(current_phase, FaultPhase::Post) {
|
||||
if let Some(restart_started_at) = *restart_started.lock().await {
|
||||
let elapsed = restart_started_at.elapsed().as_millis() as u64;
|
||||
let _ = recovered_after_restart_ms.compare_exchange(
|
||||
0,
|
||||
elapsed,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
);
|
||||
}
|
||||
}
|
||||
collector
|
||||
.record(current_phase, success, started.elapsed())
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = workers.join_next().await {
|
||||
result
|
||||
.map_err(|err| format!("tunnel recovery worker task failed: {err}"))?
|
||||
.map_err(|err| format!("tunnel recovery worker failed: {err}"))?;
|
||||
}
|
||||
let _ = done_tx.send(());
|
||||
restart_task
|
||||
.await
|
||||
.map_err(|err| format!("tunnel restart task failed: {err}"))?
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(collector
|
||||
.summarize(
|
||||
load_optional_atomic_u64(&recovered_after_restart_ms),
|
||||
runtime_sampler.snapshot(),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn start_tunnel_on_port_retry(
|
||||
config: TunnelHarnessConfig,
|
||||
port: u16,
|
||||
) -> Result<TunnelHarness, String> {
|
||||
let mut attempts = 0usize;
|
||||
loop {
|
||||
match TunnelHarness::start_on_port(config.clone(), port).await {
|
||||
Ok(tunnel) => return Ok(tunnel),
|
||||
Err(err) => {
|
||||
attempts += 1;
|
||||
if attempts >= 20 {
|
||||
return Err(format!(
|
||||
"failed to restart tunnel on fixed port {port}: {err}"
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_phase(value: usize) -> FaultPhase {
|
||||
match value {
|
||||
0 => FaultPhase::Pre,
|
||||
1 => FaultPhase::During,
|
||||
_ => FaultPhase::Post,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_optional_atomic_u64(value: &AtomicU64) -> Option<u64> {
|
||||
match value.load(Ordering::Acquire) {
|
||||
0 => None,
|
||||
millis => Some(millis),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_latencies(latencies: &[u64]) -> (u64, u64, u64, u64, u64) {
|
||||
if latencies.is_empty() {
|
||||
return (0, 0, 0, 0, 0);
|
||||
}
|
||||
let max_ms = *latencies.last().unwrap_or(&0);
|
||||
let mean_ms = latencies.iter().sum::<u64>() / latencies.len() as u64;
|
||||
let p50_ms = percentile(latencies, 50);
|
||||
let p95_ms = percentile(latencies, 95);
|
||||
let p99_ms = percentile(latencies, 99);
|
||||
(p50_ms, p95_ms, p99_ms, max_ms, mean_ms)
|
||||
}
|
||||
|
||||
fn percentile(latencies: &[u64], percentile: u8) -> u64 {
|
||||
if latencies.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let last_index = latencies.len() - 1;
|
||||
let rank = ((last_index as f64) * (percentile as f64 / 100.0)).round() as usize;
|
||||
latencies[rank.min(last_index)]
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<FailureRecoveryBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = FailureRecoveryBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--redis-attempts" => {
|
||||
config.redis_attempts = next_value(&mut iter, "--redis-attempts")?.parse()?
|
||||
}
|
||||
"--redis-concurrency" => {
|
||||
config.redis_concurrency = next_value(&mut iter, "--redis-concurrency")?.parse()?
|
||||
}
|
||||
"--redis-restart-delay-ms" => {
|
||||
config.redis_restart_delay = Duration::from_millis(
|
||||
next_value(&mut iter, "--redis-restart-delay-ms")?.parse()?,
|
||||
)
|
||||
}
|
||||
"--redis-downtime-ms" => {
|
||||
config.redis_downtime =
|
||||
Duration::from_millis(next_value(&mut iter, "--redis-downtime-ms")?.parse()?)
|
||||
}
|
||||
"--postgres-statement-timeout-ms" => {
|
||||
config.postgres_statement_timeout = Duration::from_millis(
|
||||
next_value(&mut iter, "--postgres-statement-timeout-ms")?.parse()?,
|
||||
)
|
||||
}
|
||||
"--postgres-sleep-ms" => {
|
||||
config.postgres_sleep =
|
||||
Duration::from_millis(next_value(&mut iter, "--postgres-sleep-ms")?.parse()?)
|
||||
}
|
||||
"--tunnel-attempts" => {
|
||||
config.tunnel_attempts = next_value(&mut iter, "--tunnel-attempts")?.parse()?
|
||||
}
|
||||
"--tunnel-concurrency" => {
|
||||
config.tunnel_concurrency =
|
||||
next_value(&mut iter, "--tunnel-concurrency")?.parse()?
|
||||
}
|
||||
"--tunnel-hold-ms" => {
|
||||
config.tunnel_hold =
|
||||
Duration::from_millis(next_value(&mut iter, "--tunnel-hold-ms")?.parse()?)
|
||||
}
|
||||
"--tunnel-restart-delay-ms" => {
|
||||
config.tunnel_restart_delay = Duration::from_millis(
|
||||
next_value(&mut iter, "--tunnel-restart-delay-ms")?.parse()?,
|
||||
)
|
||||
}
|
||||
"--tunnel-downtime-ms" => {
|
||||
config.tunnel_downtime =
|
||||
Duration::from_millis(next_value(&mut iter, "--tunnel-downtime-ms")?.parse()?)
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout =
|
||||
Duration::from_millis(next_value(&mut iter, "--timeout-ms")?.parse()?)
|
||||
}
|
||||
"--redis-url" => config.redis_url = Some(next_value(&mut iter, "--redis-url")?),
|
||||
"--postgres-url" => {
|
||||
config.postgres_url = Some(next_value(&mut iter, "--postgres-url")?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}").into()),
|
||||
}
|
||||
}
|
||||
validate_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next()
|
||||
.ok_or_else(|| format!("missing value for {flag}").into())
|
||||
}
|
||||
|
||||
fn validate_config(
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if config.redis_attempts == 0
|
||||
|| config.redis_concurrency == 0
|
||||
|| config.postgres_statement_timeout.is_zero()
|
||||
|| config.postgres_sleep.is_zero()
|
||||
|| config.tunnel_attempts == 0
|
||||
|| config.tunnel_concurrency == 0
|
||||
|| config.timeout.is_zero()
|
||||
{
|
||||
return Err("all failure recovery baseline numeric settings must be positive".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_gateway::build_router_with_state;
|
||||
use aether_gateway::testkit::{build_openai_chat_pressure_state, OpenAiChatPressureStateConfig};
|
||||
use aether_testkit::{
|
||||
fetch_prometheus_samples, run_http_load_probe, run_multi_url_http_load_probe,
|
||||
HttpLoadProbeConfig, HttpLoadProbeResponseMode, HttpLoadProbeResult,
|
||||
MultiUrlHttpLoadProbeResult, PrometheusSample, SpawnedServer,
|
||||
};
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::extract::State;
|
||||
use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
|
||||
const DEFAULT_REQUESTS: usize = 10_000;
|
||||
const DEFAULT_C1K: usize = 1_000;
|
||||
const DEFAULT_C1W: usize = 10_000;
|
||||
const DEFAULT_TARGETS: usize = 4;
|
||||
const DEFAULT_CLIENT_SHARDS: usize = 512;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 60_000;
|
||||
const DEFAULT_WARMUP_REQUESTS: usize = 1_000;
|
||||
const DEFAULT_WARMUP_CONCURRENCY: usize = 50;
|
||||
const BODY_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
requests: usize,
|
||||
c1k: usize,
|
||||
c1w: usize,
|
||||
targets: usize,
|
||||
client_shards: usize,
|
||||
timeout_ms: u64,
|
||||
warmup_requests: usize,
|
||||
warmup_concurrency: usize,
|
||||
first_body_hold_ms: u64,
|
||||
output_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: DEFAULT_REQUESTS,
|
||||
c1k: DEFAULT_C1K,
|
||||
c1w: DEFAULT_C1W,
|
||||
targets: DEFAULT_TARGETS,
|
||||
client_shards: DEFAULT_CLIENT_SHARDS,
|
||||
timeout_ms: DEFAULT_TIMEOUT_MS,
|
||||
warmup_requests: DEFAULT_WARMUP_REQUESTS,
|
||||
warmup_concurrency: DEFAULT_WARMUP_CONCURRENCY,
|
||||
first_body_hold_ms: 0,
|
||||
output_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CompareReport {
|
||||
suite: &'static str,
|
||||
gateway_base_url: String,
|
||||
gateway_metrics_url: String,
|
||||
upstream_targets: Vec<String>,
|
||||
config: ReportConfig,
|
||||
warmup: Option<HttpLoadProbeResult>,
|
||||
c1k: RunReport,
|
||||
c1w: RunReport,
|
||||
mock_only: MockOnlyCompareReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReportConfig {
|
||||
requests: usize,
|
||||
c1k: usize,
|
||||
c1w: usize,
|
||||
targets: usize,
|
||||
client_shards: usize,
|
||||
timeout_ms: u64,
|
||||
warmup_requests: usize,
|
||||
warmup_concurrency: usize,
|
||||
first_body_hold_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RunReport {
|
||||
label: String,
|
||||
load: HttpLoadProbeResult,
|
||||
gateway_metrics: GatewayMetricDelta,
|
||||
mock_metrics: Vec<MockMetricDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MockOnlyCompareReport {
|
||||
c1k: MultiUrlHttpLoadProbeResult,
|
||||
c1w: MultiUrlHttpLoadProbeResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct GatewayMetricDelta {
|
||||
stream_pre_first_byte_spawn_total: u64,
|
||||
request_candidate_queue_dropped_total: u64,
|
||||
raw_candidates_scanned_total: u64,
|
||||
payload_build_selected_total: u64,
|
||||
payload_build_prefetch_avoided_total: u64,
|
||||
selected_rank_sum: u64,
|
||||
model_directive_cache_hit_total: u64,
|
||||
model_directive_cache_miss_total: u64,
|
||||
redaction_request_cache_hit_total: u64,
|
||||
redaction_request_cache_miss_total: u64,
|
||||
target_raw_seen_total: BTreeMap<String, u64>,
|
||||
target_preselect_total: BTreeMap<String, u64>,
|
||||
target_selected_total: BTreeMap<String, u64>,
|
||||
target_max_in_flight: BTreeMap<String, u64>,
|
||||
target_saturated_total: BTreeMap<String, u64>,
|
||||
stage_deltas: BTreeMap<String, StageMetricDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct StageMetricDelta {
|
||||
count: u64,
|
||||
sum_ms: u64,
|
||||
max_ms_after: u64,
|
||||
p95_bucket_ms: Option<u64>,
|
||||
p99_bucket_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
struct MockMetricSnapshot {
|
||||
requests_total: u64,
|
||||
completed_total: u64,
|
||||
in_flight: u64,
|
||||
max_in_flight: u64,
|
||||
first_chunk_yield_total: u64,
|
||||
request_body_read_sum_ms: u64,
|
||||
request_body_read_max_ms: u64,
|
||||
response_header_to_first_chunk_sum_ms: u64,
|
||||
response_header_to_first_chunk_max_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MockMetricDelta {
|
||||
target: String,
|
||||
requests_total: u64,
|
||||
completed_total: u64,
|
||||
in_flight_after: u64,
|
||||
max_in_flight_after: u64,
|
||||
first_chunk_yield_total: u64,
|
||||
request_body_read_sum_ms: u64,
|
||||
request_body_read_max_ms_after: u64,
|
||||
response_header_to_first_chunk_sum_ms: u64,
|
||||
response_header_to_first_chunk_max_ms_after: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct MockTargetMetrics {
|
||||
requests_total: AtomicU64,
|
||||
completed_total: AtomicU64,
|
||||
in_flight: AtomicUsize,
|
||||
max_in_flight: AtomicUsize,
|
||||
first_chunk_yield_total: AtomicU64,
|
||||
request_body_read_sum_ms: AtomicU64,
|
||||
request_body_read_max_ms: AtomicU64,
|
||||
response_header_to_first_chunk_sum_ms: AtomicU64,
|
||||
response_header_to_first_chunk_max_ms: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockTargetState {
|
||||
label: String,
|
||||
metrics: Arc<MockTargetMetrics>,
|
||||
first_chunk_delay: Duration,
|
||||
}
|
||||
|
||||
struct CompletionGuard {
|
||||
metrics: Option<Arc<MockTargetMetrics>>,
|
||||
}
|
||||
|
||||
impl CompletionGuard {
|
||||
fn new(metrics: Arc<MockTargetMetrics>) -> Self {
|
||||
Self {
|
||||
metrics: Some(metrics),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CompletionGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(metrics) = self.metrics.take() {
|
||||
record_mock_completed(&metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
if config.targets == 0 {
|
||||
return Err("targets must be positive".into());
|
||||
}
|
||||
|
||||
let mut mock_servers = Vec::with_capacity(config.targets);
|
||||
let mut mock_metrics = Vec::with_capacity(config.targets);
|
||||
for index in 0..config.targets {
|
||||
let metrics = Arc::new(MockTargetMetrics::default());
|
||||
let state = MockTargetState {
|
||||
label: format!("target-{index}"),
|
||||
metrics: Arc::clone(&metrics),
|
||||
first_chunk_delay: Duration::ZERO,
|
||||
};
|
||||
let server = SpawnedServer::start(mock_router(state)).await?;
|
||||
mock_servers.push(server);
|
||||
mock_metrics.push(metrics);
|
||||
}
|
||||
|
||||
let upstream_targets = mock_servers
|
||||
.iter()
|
||||
.map(|server| format!("{}/v1", server.base_url()))
|
||||
.collect::<Vec<_>>();
|
||||
let mock_chat_urls = mock_servers
|
||||
.iter()
|
||||
.map(|server| format!("{}/v1/chat/completions", server.base_url()))
|
||||
.collect::<Vec<_>>();
|
||||
let mock_health_url = format!("{}/health", mock_servers[0].base_url());
|
||||
let gateway_state = build_openai_chat_pressure_state(OpenAiChatPressureStateConfig::new(
|
||||
upstream_targets.clone(),
|
||||
))
|
||||
.map_err(std::io::Error::other)?;
|
||||
let gateway = SpawnedServer::start(build_router_with_state(gateway_state)).await?;
|
||||
let gateway_url = format!("{}/v1/chat/completions", gateway.base_url());
|
||||
let gateway_health_url = format!("{}/_gateway/health", gateway.base_url());
|
||||
let gateway_metrics_url = format!("{}/_gateway/metrics", gateway.base_url());
|
||||
|
||||
let warmup = if config.warmup_requests > 0 {
|
||||
Some(
|
||||
run_http_load_probe(&load_config(
|
||||
&gateway_url,
|
||||
&gateway_health_url,
|
||||
&config,
|
||||
config.warmup_requests,
|
||||
config.warmup_concurrency,
|
||||
0,
|
||||
))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let before_c1k = fetch_prometheus_samples(&gateway_metrics_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mock_before_c1k = snapshot_mock_metrics(&mock_metrics);
|
||||
let c1k_load = run_http_load_probe(&load_config(
|
||||
&gateway_url,
|
||||
&gateway_health_url,
|
||||
&config,
|
||||
config.requests,
|
||||
config.c1k,
|
||||
config.first_body_hold_ms,
|
||||
))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let after_c1k = fetch_prometheus_samples(&gateway_metrics_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mock_after_c1k = snapshot_mock_metrics(&mock_metrics);
|
||||
|
||||
let before_c1w = after_c1k.clone();
|
||||
let mock_before_c1w = mock_after_c1k.clone();
|
||||
let c1w_load = run_http_load_probe(&load_config(
|
||||
&gateway_url,
|
||||
&gateway_health_url,
|
||||
&config,
|
||||
config.requests,
|
||||
config.c1w,
|
||||
config.first_body_hold_ms,
|
||||
))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let after_c1w = fetch_prometheus_samples(&gateway_metrics_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mock_after_c1w = snapshot_mock_metrics(&mock_metrics);
|
||||
let mock_only_c1k = run_multi_url_http_load_probe(
|
||||
&load_config(
|
||||
&mock_chat_urls[0],
|
||||
&mock_health_url,
|
||||
&config,
|
||||
config.requests,
|
||||
config.c1k,
|
||||
config.first_body_hold_ms,
|
||||
),
|
||||
&mock_chat_urls,
|
||||
)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mock_only_c1w = run_multi_url_http_load_probe(
|
||||
&load_config(
|
||||
&mock_chat_urls[0],
|
||||
&mock_health_url,
|
||||
&config,
|
||||
config.requests,
|
||||
config.c1w,
|
||||
config.first_body_hold_ms,
|
||||
),
|
||||
&mock_chat_urls,
|
||||
)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let report = CompareReport {
|
||||
suite: "gateway_openai_chat_c_compare",
|
||||
gateway_base_url: gateway.base_url().to_string(),
|
||||
gateway_metrics_url,
|
||||
upstream_targets,
|
||||
config: ReportConfig {
|
||||
requests: config.requests,
|
||||
c1k: config.c1k,
|
||||
c1w: config.c1w,
|
||||
targets: config.targets,
|
||||
client_shards: config.client_shards,
|
||||
timeout_ms: config.timeout_ms,
|
||||
warmup_requests: config.warmup_requests,
|
||||
warmup_concurrency: config.warmup_concurrency,
|
||||
first_body_hold_ms: config.first_body_hold_ms,
|
||||
},
|
||||
warmup,
|
||||
c1k: RunReport {
|
||||
label: format!("c{}", config.c1k),
|
||||
load: c1k_load,
|
||||
gateway_metrics: gateway_metric_delta(&before_c1k, &after_c1k),
|
||||
mock_metrics: mock_metric_delta(&mock_before_c1k, &mock_after_c1k),
|
||||
},
|
||||
c1w: RunReport {
|
||||
label: format!("c{}", config.c1w),
|
||||
load: c1w_load,
|
||||
gateway_metrics: gateway_metric_delta(&before_c1w, &after_c1w),
|
||||
mock_metrics: mock_metric_delta(&mock_before_c1w, &mock_after_c1w),
|
||||
},
|
||||
mock_only: MockOnlyCompareReport {
|
||||
c1k: mock_only_c1k,
|
||||
c1w: mock_only_c1w,
|
||||
},
|
||||
};
|
||||
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_config(
|
||||
gateway_url: &str,
|
||||
gateway_health_url: &str,
|
||||
config: &Config,
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
first_body_hold_ms: u64,
|
||||
) -> HttpLoadProbeConfig {
|
||||
let mut headers = BTreeMap::new();
|
||||
headers.insert(
|
||||
CONTENT_TYPE.as_str().to_string(),
|
||||
"application/json".to_string(),
|
||||
);
|
||||
headers.insert(
|
||||
AUTHORIZATION.as_str().to_string(),
|
||||
"Bearer sk-aether-openai-chat-pressure".to_string(),
|
||||
);
|
||||
HttpLoadProbeConfig {
|
||||
url: gateway_url.to_string(),
|
||||
warmup_url: Some(gateway_health_url.to_string()),
|
||||
method: Method::POST,
|
||||
headers,
|
||||
header_sets: Vec::new(),
|
||||
body: Some(
|
||||
serde_json::json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": true
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
),
|
||||
total_requests: requests,
|
||||
concurrency,
|
||||
warmup_connections: 0,
|
||||
timeout: Duration::from_millis(config.timeout_ms),
|
||||
connect_timeout: Some(Duration::from_millis(10_000)),
|
||||
response_mode: HttpLoadProbeResponseMode::FirstBodyByte,
|
||||
client_shards: config.client_shards,
|
||||
pool_max_idle_per_host: Some(config.requests.max(concurrency).max(1024)),
|
||||
start_ramp: Duration::ZERO,
|
||||
http1_only: true,
|
||||
http2_prior_knowledge: false,
|
||||
first_body_hold: Duration::from_millis(first_body_hold_ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_router(state: MockTargetState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/v1/chat/completions", post(mock_chat_completions))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn mock_chat_completions(
|
||||
State(state): State<MockTargetState>,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
record_mock_started(&state.metrics);
|
||||
let body_started_at = Instant::now();
|
||||
let body = match to_bytes(request.into_body(), BODY_LIMIT).await {
|
||||
Ok(body) => body,
|
||||
Err(err) => {
|
||||
record_mock_completed(&state.metrics);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("failed to read mock request body: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
record_mock_request_body_read(&state.metrics, body_started_at.elapsed());
|
||||
|
||||
let wants_stream = request_wants_stream(&body);
|
||||
let model = request_model(&body).unwrap_or_else(|| "gpt-5-upstream".to_string());
|
||||
if !wants_stream {
|
||||
record_mock_completed(&state.metrics);
|
||||
return axum::Json(serde_json::json!({
|
||||
"id": "chatcmpl-pressure-mock",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let metrics = Arc::clone(&state.metrics);
|
||||
let first_chunk_delay = state.first_chunk_delay;
|
||||
let label = state.label.clone();
|
||||
let stream = async_stream::stream! {
|
||||
let _guard = CompletionGuard::new(Arc::clone(&metrics));
|
||||
let header_created_at = Instant::now();
|
||||
if !first_chunk_delay.is_zero() {
|
||||
tokio::time::sleep(first_chunk_delay).await;
|
||||
}
|
||||
record_mock_first_chunk_yield(&metrics, header_created_at.elapsed());
|
||||
let chunk = serde_json::json!({
|
||||
"id": format!("chatcmpl-pressure-mock-{label}"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "x"},
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(format!("data: {chunk}\n\n")));
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from_static(
|
||||
b"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
));
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from_static(b"data: [DONE]\n\n"));
|
||||
};
|
||||
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
"text/event-stream; charset=utf-8"
|
||||
.parse()
|
||||
.expect("valid content-type"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
"no-cache".parse().expect("valid cache-control"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn request_wants_stream(body: &[u8]) -> bool {
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| value.get("stream").and_then(serde_json::Value::as_bool))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn request_model(body: &[u8]) -> Option<String> {
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn record_mock_started(metrics: &MockTargetMetrics) {
|
||||
metrics.requests_total.fetch_add(1, Ordering::AcqRel);
|
||||
let in_flight = metrics.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
metrics.max_in_flight.fetch_max(in_flight, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn record_mock_completed(metrics: &MockTargetMetrics) {
|
||||
metrics.completed_total.fetch_add(1, Ordering::AcqRel);
|
||||
metrics.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn record_mock_request_body_read(metrics: &MockTargetMetrics, elapsed: Duration) {
|
||||
let elapsed_ms = elapsed.as_millis() as u64;
|
||||
metrics
|
||||
.request_body_read_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
metrics
|
||||
.request_body_read_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn record_mock_first_chunk_yield(metrics: &MockTargetMetrics, elapsed: Duration) {
|
||||
let elapsed_ms = elapsed.as_millis() as u64;
|
||||
metrics
|
||||
.first_chunk_yield_total
|
||||
.fetch_add(1, Ordering::AcqRel);
|
||||
metrics
|
||||
.response_header_to_first_chunk_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
metrics
|
||||
.response_header_to_first_chunk_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn snapshot_mock_metrics(metrics: &[Arc<MockTargetMetrics>]) -> Vec<MockMetricSnapshot> {
|
||||
metrics
|
||||
.iter()
|
||||
.map(|metrics| MockMetricSnapshot {
|
||||
requests_total: metrics.requests_total.load(Ordering::Acquire),
|
||||
completed_total: metrics.completed_total.load(Ordering::Acquire),
|
||||
in_flight: metrics.in_flight.load(Ordering::Acquire) as u64,
|
||||
max_in_flight: metrics.max_in_flight.load(Ordering::Acquire) as u64,
|
||||
first_chunk_yield_total: metrics.first_chunk_yield_total.load(Ordering::Acquire),
|
||||
request_body_read_sum_ms: metrics.request_body_read_sum_ms.load(Ordering::Acquire),
|
||||
request_body_read_max_ms: metrics.request_body_read_max_ms.load(Ordering::Acquire),
|
||||
response_header_to_first_chunk_sum_ms: metrics
|
||||
.response_header_to_first_chunk_sum_ms
|
||||
.load(Ordering::Acquire),
|
||||
response_header_to_first_chunk_max_ms: metrics
|
||||
.response_header_to_first_chunk_max_ms
|
||||
.load(Ordering::Acquire),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mock_metric_delta(
|
||||
before: &[MockMetricSnapshot],
|
||||
after: &[MockMetricSnapshot],
|
||||
) -> Vec<MockMetricDelta> {
|
||||
after
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, after)| {
|
||||
let before = before.get(index).cloned().unwrap_or_default();
|
||||
MockMetricDelta {
|
||||
target: format!("target-{index}"),
|
||||
requests_total: after.requests_total.saturating_sub(before.requests_total),
|
||||
completed_total: after.completed_total.saturating_sub(before.completed_total),
|
||||
in_flight_after: after.in_flight,
|
||||
max_in_flight_after: after.max_in_flight,
|
||||
first_chunk_yield_total: after
|
||||
.first_chunk_yield_total
|
||||
.saturating_sub(before.first_chunk_yield_total),
|
||||
request_body_read_sum_ms: after
|
||||
.request_body_read_sum_ms
|
||||
.saturating_sub(before.request_body_read_sum_ms),
|
||||
request_body_read_max_ms_after: after.request_body_read_max_ms,
|
||||
response_header_to_first_chunk_sum_ms: after
|
||||
.response_header_to_first_chunk_sum_ms
|
||||
.saturating_sub(before.response_header_to_first_chunk_sum_ms),
|
||||
response_header_to_first_chunk_max_ms_after: after
|
||||
.response_header_to_first_chunk_max_ms,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gateway_metric_delta(
|
||||
before: &[PrometheusSample],
|
||||
after: &[PrometheusSample],
|
||||
) -> GatewayMetricDelta {
|
||||
let stage_names = [
|
||||
"frontdoor_handler_queue",
|
||||
"frontdoor_admission",
|
||||
"frontdoor_context",
|
||||
"frontdoor_body_buffer",
|
||||
"frontdoor_owner_forward",
|
||||
"frontdoor_auth_model",
|
||||
"frontdoor_rpm",
|
||||
"frontdoor_rpm_system_default",
|
||||
"frontdoor_rpm_runtime_check",
|
||||
"frontdoor_rpm_memory_fallback",
|
||||
"frontdoor_local_ai_public",
|
||||
"frontdoor_execute_stream",
|
||||
"frontdoor_to_stream_response_ready",
|
||||
"frontdoor_to_stream_body_first_poll",
|
||||
"frontdoor_to_stream_first_client_yield",
|
||||
"openai_chat_stream_target_select",
|
||||
"openai_chat_payload_parts_prepare",
|
||||
"openai_chat_payload_model_directives",
|
||||
"openai_chat_payload_redaction",
|
||||
"openai_chat_payload_auth_prepare",
|
||||
"openai_chat_payload_body_build",
|
||||
"stream_candidate_execute",
|
||||
"upstream_execution_gate_wait",
|
||||
"stream_upstream_target_admission",
|
||||
"stream_upstream_headers",
|
||||
"direct_reqwest_request_send",
|
||||
"direct_send_headers",
|
||||
"direct_h2c_response_headers_wait",
|
||||
"stream_first_data",
|
||||
"stream_first_client_yield",
|
||||
"stream_total",
|
||||
];
|
||||
|
||||
GatewayMetricDelta {
|
||||
stream_pre_first_byte_spawn_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"stream_pre_first_byte_spawn_total",
|
||||
&[],
|
||||
),
|
||||
request_candidate_queue_dropped_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"request_candidate_queue_dropped_total",
|
||||
&[],
|
||||
),
|
||||
raw_candidates_scanned_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"openai_chat_stream_target_select_raw_candidates_scanned_total",
|
||||
&[],
|
||||
),
|
||||
payload_build_selected_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"openai_chat_stream_payload_build_selected_total",
|
||||
&[],
|
||||
),
|
||||
payload_build_prefetch_avoided_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"openai_chat_stream_payload_build_prefetch_avoided_total",
|
||||
&[],
|
||||
),
|
||||
selected_rank_sum: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"openai_chat_stream_target_select_selected_rank_sum",
|
||||
&[],
|
||||
),
|
||||
model_directive_cache_hit_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"openai_chat_model_directive_cache_hit_total",
|
||||
&[],
|
||||
),
|
||||
model_directive_cache_miss_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"openai_chat_model_directive_cache_miss_total",
|
||||
&[],
|
||||
),
|
||||
redaction_request_cache_hit_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"chat_pii_redaction_request_cache_hit_total",
|
||||
&[],
|
||||
),
|
||||
redaction_request_cache_miss_total: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"chat_pii_redaction_request_cache_miss_total",
|
||||
&[],
|
||||
),
|
||||
target_raw_seen_total: metric_delta_by_target(
|
||||
before,
|
||||
after,
|
||||
"upstream_target_raw_seen_total",
|
||||
),
|
||||
target_preselect_total: metric_delta_by_target(
|
||||
before,
|
||||
after,
|
||||
"upstream_target_preselect_total",
|
||||
),
|
||||
target_selected_total: metric_delta_by_target(
|
||||
before,
|
||||
after,
|
||||
"upstream_target_selected_total",
|
||||
),
|
||||
target_max_in_flight: metric_after_by_target(after, "upstream_target_max_in_flight"),
|
||||
target_saturated_total: metric_delta_by_target(
|
||||
before,
|
||||
after,
|
||||
"upstream_target_saturated_total",
|
||||
),
|
||||
stage_deltas: stage_names
|
||||
.iter()
|
||||
.map(|stage| {
|
||||
(
|
||||
(*stage).to_string(),
|
||||
StageMetricDelta {
|
||||
count: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"gateway_stage_latency_count",
|
||||
&[("stage", stage)],
|
||||
),
|
||||
sum_ms: counter_delta(
|
||||
before,
|
||||
after,
|
||||
"gateway_stage_latency_sum_ms",
|
||||
&[("stage", stage)],
|
||||
),
|
||||
max_ms_after: metric_value(
|
||||
after,
|
||||
"gateway_stage_latency_max_ms",
|
||||
&[("stage", stage)],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
p95_bucket_ms: stage_latency_bucket_quantile(before, after, stage, 0.95),
|
||||
p99_bucket_ms: stage_latency_bucket_quantile(before, after, stage, 0.99),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_latency_bucket_quantile(
|
||||
before: &[PrometheusSample],
|
||||
after: &[PrometheusSample],
|
||||
stage: &str,
|
||||
quantile: f64,
|
||||
) -> Option<u64> {
|
||||
let count = counter_delta(
|
||||
before,
|
||||
after,
|
||||
"gateway_stage_latency_count",
|
||||
&[("stage", stage)],
|
||||
);
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
let threshold = ((count as f64) * quantile).ceil().max(1.0) as u64;
|
||||
let mut buckets = after
|
||||
.iter()
|
||||
.filter(|sample| {
|
||||
metric_name_matches(&sample.name, "gateway_stage_latency_bucket")
|
||||
&& labels_match(sample, &[("stage", stage)])
|
||||
})
|
||||
.filter_map(|sample| {
|
||||
let le_ms = sample.labels.get("le_ms")?.parse::<u64>().ok()?;
|
||||
let after_value = sample.value.parse::<u64>().ok()?;
|
||||
let before_value = metric_value(
|
||||
before,
|
||||
"gateway_stage_latency_bucket",
|
||||
&[("stage", stage), ("le_ms", sample.labels.get("le_ms")?)],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
Some((le_ms, after_value.saturating_sub(before_value)))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
buckets.sort_unstable_by_key(|(le_ms, _)| *le_ms);
|
||||
buckets
|
||||
.into_iter()
|
||||
.find_map(|(le_ms, value)| (value >= threshold).then_some(le_ms))
|
||||
}
|
||||
|
||||
fn counter_delta(
|
||||
before: &[PrometheusSample],
|
||||
after: &[PrometheusSample],
|
||||
metric_name: &str,
|
||||
labels: &[(&str, &str)],
|
||||
) -> u64 {
|
||||
let before = metric_value(before, metric_name, labels).unwrap_or_default();
|
||||
let after = metric_value(after, metric_name, labels).unwrap_or_default();
|
||||
after.saturating_sub(before)
|
||||
}
|
||||
|
||||
fn metric_value(
|
||||
samples: &[PrometheusSample],
|
||||
metric_name: &str,
|
||||
labels: &[(&str, &str)],
|
||||
) -> Option<u64> {
|
||||
samples
|
||||
.iter()
|
||||
.find(|sample| {
|
||||
metric_name_matches(&sample.name, metric_name) && labels_match(sample, labels)
|
||||
})
|
||||
.and_then(|sample| sample.value.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn metric_delta_by_target(
|
||||
before: &[PrometheusSample],
|
||||
after: &[PrometheusSample],
|
||||
metric_name: &str,
|
||||
) -> BTreeMap<String, u64> {
|
||||
let mut values = BTreeMap::new();
|
||||
for sample in after
|
||||
.iter()
|
||||
.filter(|sample| metric_name_matches(&sample.name, metric_name))
|
||||
{
|
||||
let Some(target) = sample.labels.get("target") else {
|
||||
continue;
|
||||
};
|
||||
let before_value =
|
||||
metric_value(before, metric_name, &[("target", target)]).unwrap_or_default();
|
||||
let after_value = sample.value.parse::<u64>().unwrap_or_default();
|
||||
values.insert(target.clone(), after_value.saturating_sub(before_value));
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn metric_after_by_target(
|
||||
samples: &[PrometheusSample],
|
||||
metric_name: &str,
|
||||
) -> BTreeMap<String, u64> {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|sample| metric_name_matches(&sample.name, metric_name))
|
||||
.filter_map(|sample| {
|
||||
Some((
|
||||
sample.labels.get("target")?.clone(),
|
||||
sample.value.parse::<u64>().ok()?,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn metric_name_matches(actual: &str, expected: &str) -> bool {
|
||||
actual == expected || actual.ends_with(&format!("_{expected}"))
|
||||
}
|
||||
|
||||
fn labels_match(sample: &PrometheusSample, labels: &[(&str, &str)]) -> bool {
|
||||
labels
|
||||
.iter()
|
||||
.all(|(key, value)| sample.labels.get(*key).map(String::as_str) == Some(*value))
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut config = Config::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--requests" => config.requests = next_value(&mut iter, "--requests")?.parse()?,
|
||||
"--c1k" => config.c1k = next_value(&mut iter, "--c1k")?.parse()?,
|
||||
"--c1w" => config.c1w = next_value(&mut iter, "--c1w")?.parse()?,
|
||||
"--targets" => config.targets = next_value(&mut iter, "--targets")?.parse()?,
|
||||
"--client-shards" => {
|
||||
config.client_shards = next_value(&mut iter, "--client-shards")?.parse()?
|
||||
}
|
||||
"--timeout-ms" => config.timeout_ms = next_value(&mut iter, "--timeout-ms")?.parse()?,
|
||||
"--warmup-requests" => {
|
||||
config.warmup_requests = next_value(&mut iter, "--warmup-requests")?.parse()?
|
||||
}
|
||||
"--warmup-concurrency" => {
|
||||
config.warmup_concurrency =
|
||||
next_value(&mut iter, "--warmup-concurrency")?.parse()?
|
||||
}
|
||||
"--first-body-hold-ms" => {
|
||||
config.first_body_hold_ms =
|
||||
next_value(&mut iter, "--first-body-hold-ms")?.parse()?
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}").into()),
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next()
|
||||
.ok_or_else(|| format!("missing value for {flag}").into())
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"gateway_openai_chat_c_compare \
|
||||
[--requests N] [--c1k N] [--c1w N] [--targets N] \
|
||||
[--client-shards N] [--timeout-ms N] \
|
||||
[--warmup-requests N] [--warmup-concurrency N] \
|
||||
[--first-body-hold-ms N] [--output PATH]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aether_data::repository::auth::CreateStandaloneApiKeyRecord;
|
||||
use aether_data::repository::wallet::WalletLookupKey;
|
||||
use aether_data::{
|
||||
DataBackends, DataLayerConfig, DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig,
|
||||
};
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
CreateAdminGlobalModelRecord, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::Digest;
|
||||
|
||||
const DEFAULT_POSTGRES_URL: &str = "postgresql://postgres:[email protected]:5432/aether";
|
||||
const DEFAULT_OUTPUT_ENV_PATH: &str = "/tmp/aether_local_env.sh";
|
||||
const DEFAULT_OUTPUT_KEY_PATH: &str = "/tmp/aether_fullchain_api_key";
|
||||
const DEFAULT_OUTPUT_KEY_LIST_PATH: &str = "/tmp/aether_fullchain_api_keys";
|
||||
const DEFAULT_PROVIDER_ID: &str = "provider-local-pressure-openai";
|
||||
const DEFAULT_ENDPOINT_ID: &str = "endpoint-local-pressure-openai-chat";
|
||||
const DEFAULT_PROVIDER_KEY_ID: &str = "provider-key-local-pressure-openai";
|
||||
const DEFAULT_GLOBAL_MODEL_ID: &str = "gm-local-pressure-gpt-5-mini";
|
||||
const DEFAULT_MODEL_ID: &str = "model-local-pressure-gpt-5-mini";
|
||||
const DEFAULT_API_KEY_ID: &str = "api-key-local-pressure";
|
||||
const DEFAULT_OPERATOR_ID: &str = "pressure-local";
|
||||
const DEFAULT_MODEL: &str = "gpt-5-mini";
|
||||
const DEFAULT_MOCK_UPSTREAM_BASE_URL: &str = "http://127.0.0.1:18181/v1";
|
||||
const DEFAULT_GATEWAY_BASE_URL: &str = "http://127.0.0.1:8084";
|
||||
const DEFAULT_API_KEY: &str = "sk-aether-local-pressure";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
database_url: String,
|
||||
output_env_path: PathBuf,
|
||||
output_key_path: PathBuf,
|
||||
output_key_list_path: PathBuf,
|
||||
provider_id: String,
|
||||
endpoint_id: String,
|
||||
provider_key_id: String,
|
||||
global_model_id: String,
|
||||
model_id: String,
|
||||
api_key_id: String,
|
||||
operator_id: String,
|
||||
model: String,
|
||||
mock_upstream_base_url: String,
|
||||
gateway_base_url: String,
|
||||
api_key: String,
|
||||
api_key_count: usize,
|
||||
provider_api_key: String,
|
||||
postgres_min_connections: u32,
|
||||
postgres_max_connections: u32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn from_env_and_args() -> Result<Self, String> {
|
||||
let mut config = Self {
|
||||
database_url: env_value("DATABASE_URL")
|
||||
.or_else(|| env_value("AETHER_DATABASE_URL"))
|
||||
.or_else(|| env_value("AETHER_GATEWAY_DATA_POSTGRES_URL"))
|
||||
.unwrap_or_else(|| DEFAULT_POSTGRES_URL.to_string()),
|
||||
output_env_path: PathBuf::from(
|
||||
env_value("OUTPUT_ENV_PATH").unwrap_or_else(|| DEFAULT_OUTPUT_ENV_PATH.to_string()),
|
||||
),
|
||||
output_key_path: PathBuf::from(
|
||||
env_value("OUTPUT_KEY_PATH").unwrap_or_else(|| DEFAULT_OUTPUT_KEY_PATH.to_string()),
|
||||
),
|
||||
output_key_list_path: PathBuf::from(
|
||||
env_value("OUTPUT_KEY_LIST_PATH")
|
||||
.or_else(|| env_value("PRESSURE_API_KEY_LIST_FILE"))
|
||||
.unwrap_or_else(|| DEFAULT_OUTPUT_KEY_LIST_PATH.to_string()),
|
||||
),
|
||||
provider_id: env_value("PRESSURE_PROVIDER_ID")
|
||||
.unwrap_or_else(|| DEFAULT_PROVIDER_ID.to_string()),
|
||||
endpoint_id: env_value("PRESSURE_ENDPOINT_ID")
|
||||
.unwrap_or_else(|| DEFAULT_ENDPOINT_ID.to_string()),
|
||||
provider_key_id: env_value("PRESSURE_PROVIDER_KEY_ID")
|
||||
.unwrap_or_else(|| DEFAULT_PROVIDER_KEY_ID.to_string()),
|
||||
global_model_id: env_value("PRESSURE_GLOBAL_MODEL_ID")
|
||||
.unwrap_or_else(|| DEFAULT_GLOBAL_MODEL_ID.to_string()),
|
||||
model_id: env_value("PRESSURE_MODEL_ID")
|
||||
.unwrap_or_else(|| DEFAULT_MODEL_ID.to_string()),
|
||||
api_key_id: env_value("PRESSURE_API_KEY_ID")
|
||||
.unwrap_or_else(|| DEFAULT_API_KEY_ID.to_string()),
|
||||
operator_id: env_value("PRESSURE_OPERATOR_ID")
|
||||
.unwrap_or_else(|| DEFAULT_OPERATOR_ID.to_string()),
|
||||
model: env_value("PRESSURE_MODEL").unwrap_or_else(|| DEFAULT_MODEL.to_string()),
|
||||
mock_upstream_base_url: env_value("PRESSURE_MOCK_UPSTREAM_BASE_URL")
|
||||
.unwrap_or_else(|| DEFAULT_MOCK_UPSTREAM_BASE_URL.to_string()),
|
||||
gateway_base_url: env_value("GATEWAY_BASE_URL")
|
||||
.unwrap_or_else(|| DEFAULT_GATEWAY_BASE_URL.to_string()),
|
||||
api_key: env_value("AETHER_API_KEY").unwrap_or_else(|| DEFAULT_API_KEY.to_string()),
|
||||
api_key_count: env_value("PRESSURE_API_KEY_COUNT")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(1),
|
||||
provider_api_key: env_value("PRESSURE_PROVIDER_API_KEY")
|
||||
.unwrap_or_else(|| "dummy-local-pressure-provider-key".to_string()),
|
||||
postgres_min_connections: 1,
|
||||
postgres_max_connections: 8,
|
||||
};
|
||||
|
||||
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
match arg.as_str() {
|
||||
"--database-url" => config.database_url = arg_value(&args, &mut index, arg)?,
|
||||
"--output-env" => {
|
||||
config.output_env_path = PathBuf::from(arg_value(&args, &mut index, arg)?)
|
||||
}
|
||||
"--output-key" => {
|
||||
config.output_key_path = PathBuf::from(arg_value(&args, &mut index, arg)?)
|
||||
}
|
||||
"--output-key-list" => {
|
||||
config.output_key_list_path = PathBuf::from(arg_value(&args, &mut index, arg)?)
|
||||
}
|
||||
"--provider-id" => config.provider_id = arg_value(&args, &mut index, arg)?,
|
||||
"--endpoint-id" => config.endpoint_id = arg_value(&args, &mut index, arg)?,
|
||||
"--provider-key-id" => config.provider_key_id = arg_value(&args, &mut index, arg)?,
|
||||
"--global-model-id" => config.global_model_id = arg_value(&args, &mut index, arg)?,
|
||||
"--model-id" => config.model_id = arg_value(&args, &mut index, arg)?,
|
||||
"--api-key-id" => config.api_key_id = arg_value(&args, &mut index, arg)?,
|
||||
"--operator-id" => config.operator_id = arg_value(&args, &mut index, arg)?,
|
||||
"--model" => config.model = arg_value(&args, &mut index, arg)?,
|
||||
"--mock-upstream-base-url" => {
|
||||
config.mock_upstream_base_url = arg_value(&args, &mut index, arg)?
|
||||
}
|
||||
"--gateway-base-url" => {
|
||||
config.gateway_base_url = arg_value(&args, &mut index, arg)?
|
||||
}
|
||||
"--api-key" => config.api_key = arg_value(&args, &mut index, arg)?,
|
||||
"--api-key-count" => {
|
||||
config.api_key_count = parse_usize(&arg_value(&args, &mut index, arg)?, arg)?
|
||||
}
|
||||
"--provider-api-key" => {
|
||||
config.provider_api_key = arg_value(&args, &mut index, arg)?
|
||||
}
|
||||
"--postgres-min-connections" => {
|
||||
config.postgres_min_connections =
|
||||
parse_u32(&arg_value(&args, &mut index, arg)?, arg)?
|
||||
}
|
||||
"--postgres-max-connections" => {
|
||||
config.postgres_max_connections =
|
||||
parse_u32(&arg_value(&args, &mut index, arg)?, arg)?
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => return Err(format!("unknown argument: {arg}")),
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
for (name, value) in [
|
||||
("database_url", &self.database_url),
|
||||
("provider_id", &self.provider_id),
|
||||
("endpoint_id", &self.endpoint_id),
|
||||
("provider_key_id", &self.provider_key_id),
|
||||
("global_model_id", &self.global_model_id),
|
||||
("model_id", &self.model_id),
|
||||
("api_key_id", &self.api_key_id),
|
||||
("operator_id", &self.operator_id),
|
||||
("model", &self.model),
|
||||
("mock_upstream_base_url", &self.mock_upstream_base_url),
|
||||
("gateway_base_url", &self.gateway_base_url),
|
||||
("api_key", &self.api_key),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{name} cannot be empty"));
|
||||
}
|
||||
}
|
||||
if self.postgres_min_connections > self.postgres_max_connections {
|
||||
return Err("postgres min connections cannot exceed max connections".to_string());
|
||||
}
|
||||
if self.api_key_count == 0 {
|
||||
return Err("api_key_count must be positive".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = Config::from_env_and_args().map_err(|err| format!("invalid config: {err}"))?;
|
||||
|
||||
let backends = DataBackends::from_config(DataLayerConfig::from_database(SqlDatabaseConfig {
|
||||
driver: DatabaseDriver::Postgres,
|
||||
url: config.database_url.clone(),
|
||||
pool: SqlPoolConfig {
|
||||
min_connections: config.postgres_min_connections,
|
||||
max_connections: config.postgres_max_connections,
|
||||
acquire_timeout_ms: 5_000,
|
||||
idle_timeout_ms: 30_000,
|
||||
max_lifetime_ms: 300_000,
|
||||
statement_cache_capacity: 128,
|
||||
require_ssl: false,
|
||||
},
|
||||
}))?;
|
||||
|
||||
seed_provider_catalog(&backends, &config).await?;
|
||||
seed_models(&backends, &config).await?;
|
||||
let operator_user_id = seed_operator_user(&backends, &config).await?;
|
||||
seed_api_keys(&backends, &config, &operator_user_id).await?;
|
||||
verify_candidate_selection(&backends, &config).await?;
|
||||
write_outputs(&config)?;
|
||||
|
||||
println!("gateway pressure seed complete");
|
||||
println!("provider_id={}", config.provider_id);
|
||||
println!("endpoint_id={}", config.endpoint_id);
|
||||
println!("provider_key_id={}", config.provider_key_id);
|
||||
println!("model={}", config.model);
|
||||
println!("api_key_id={}", config.api_key_id);
|
||||
println!("api_key_count={}", config.api_key_count);
|
||||
println!("env written to {}", config.output_env_path.display());
|
||||
println!("api key written to {}", config.output_key_path.display());
|
||||
println!(
|
||||
"api key list written to {}",
|
||||
config.output_key_list_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_provider_catalog(
|
||||
backends: &DataBackends,
|
||||
config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let reader = backends
|
||||
.read()
|
||||
.provider_catalog()
|
||||
.ok_or("provider catalog reader unavailable")?;
|
||||
let writer = backends
|
||||
.write()
|
||||
.provider_catalog()
|
||||
.ok_or("provider catalog writer unavailable")?;
|
||||
|
||||
let provider = StoredProviderCatalogProvider::new(
|
||||
config.provider_id.clone(),
|
||||
"Local pressure OpenAI mock".to_string(),
|
||||
Some("http://127.0.0.1:18181".to_string()),
|
||||
"openai".to_string(),
|
||||
)?
|
||||
.with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
Some(120.0),
|
||||
Some(30.0),
|
||||
None,
|
||||
)
|
||||
.with_routing_fields(0)
|
||||
.with_description(Some(
|
||||
"Local OpenAI-compatible mock provider for gateway pressure tests".to_string(),
|
||||
));
|
||||
|
||||
if reader
|
||||
.list_providers_by_ids(std::slice::from_ref(&config.provider_id))
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
writer.create_provider(&provider, None).await?;
|
||||
} else {
|
||||
writer.update_provider(&provider).await?;
|
||||
}
|
||||
|
||||
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||
config.endpoint_id.clone(),
|
||||
config.provider_id.clone(),
|
||||
"openai:chat".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("chat_completions".to_string()),
|
||||
true,
|
||||
)?
|
||||
.with_transport_fields(
|
||||
config.mock_upstream_base_url.clone(),
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
.with_health_score(1.0);
|
||||
|
||||
if reader
|
||||
.list_endpoints_by_ids(std::slice::from_ref(&config.endpoint_id))
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
writer.create_endpoint(&endpoint).await?;
|
||||
} else {
|
||||
writer.update_endpoint(&endpoint).await?;
|
||||
}
|
||||
|
||||
let provider_key = StoredProviderCatalogKey::new(
|
||||
config.provider_key_id.clone(),
|
||||
config.provider_id.clone(),
|
||||
"Local pressure mock key".to_string(),
|
||||
"api_key".to_string(),
|
||||
Some(json!({"streaming": true})),
|
||||
true,
|
||||
)?
|
||||
.with_transport_fields(
|
||||
Some(json!(["openai:chat"])),
|
||||
Some(config.provider_api_key.clone()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!([config.model.clone()])),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
.with_rate_limit_fields(None, None, None, None, None, None, None, None, None)
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:chat": {"status": "healthy"}})),
|
||||
Some(json!({"openai:chat": {"state": "closed"}})),
|
||||
);
|
||||
|
||||
if reader
|
||||
.list_keys_by_ids(std::slice::from_ref(&config.provider_key_id))
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
writer.create_key(&provider_key).await?;
|
||||
} else {
|
||||
writer.update_key(&provider_key).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_models(
|
||||
backends: &DataBackends,
|
||||
config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let reader = backends
|
||||
.read()
|
||||
.global_models()
|
||||
.ok_or("global model reader unavailable")?;
|
||||
let writer = backends
|
||||
.write()
|
||||
.global_models()
|
||||
.ok_or("global model writer unavailable")?;
|
||||
|
||||
let capabilities = Some(json!({
|
||||
"streaming": true,
|
||||
"chat": true
|
||||
}));
|
||||
let global_config = Some(json!({
|
||||
"model_mappings": [config.model],
|
||||
"pressure_seed": true
|
||||
}));
|
||||
|
||||
if reader
|
||||
.get_admin_global_model_by_id(&config.global_model_id)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
writer
|
||||
.update_admin_global_model(&UpdateAdminGlobalModelRecord::new(
|
||||
config.global_model_id.clone(),
|
||||
config.model.clone(),
|
||||
true,
|
||||
Some(0.0),
|
||||
None,
|
||||
capabilities.clone(),
|
||||
global_config.clone(),
|
||||
)?)
|
||||
.await?;
|
||||
} else {
|
||||
writer
|
||||
.create_admin_global_model(&CreateAdminGlobalModelRecord::new(
|
||||
config.global_model_id.clone(),
|
||||
config.model.clone(),
|
||||
config.model.clone(),
|
||||
true,
|
||||
Some(0.0),
|
||||
None,
|
||||
capabilities.clone(),
|
||||
global_config.clone(),
|
||||
)?)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let provider_model_mappings = Some(json!([
|
||||
{
|
||||
"name": config.model,
|
||||
"priority": 0,
|
||||
"api_formats": ["openai:chat"],
|
||||
"endpoint_ids": [config.endpoint_id]
|
||||
}
|
||||
]));
|
||||
let provider_model = UpsertAdminProviderModelRecord::new(
|
||||
config.model_id.clone(),
|
||||
config.provider_id.clone(),
|
||||
config.global_model_id.clone(),
|
||||
config.model.clone(),
|
||||
provider_model_mappings,
|
||||
Some(0.0),
|
||||
None,
|
||||
Some(false),
|
||||
Some(false),
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some(false),
|
||||
true,
|
||||
true,
|
||||
Some(json!({"pressure_seed": true})),
|
||||
)?;
|
||||
|
||||
if reader
|
||||
.list_admin_provider_models_by_global_model_id(&config.global_model_id)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|model| model.id == config.model_id)
|
||||
{
|
||||
writer.update_admin_provider_model(&provider_model).await?;
|
||||
} else {
|
||||
writer.create_admin_provider_model(&provider_model).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_api_keys(
|
||||
backends: &DataBackends,
|
||||
config: &Config,
|
||||
operator_user_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
for index in 0..config.api_key_count {
|
||||
seed_api_key(backends, config, operator_user_id, index).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_api_key(
|
||||
backends: &DataBackends,
|
||||
config: &Config,
|
||||
operator_user_id: &str,
|
||||
key_index: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let auth_reader = backends
|
||||
.read()
|
||||
.auth_api_keys()
|
||||
.ok_or("auth api key reader unavailable")?;
|
||||
let auth_writer = backends
|
||||
.write()
|
||||
.auth_api_keys()
|
||||
.ok_or("auth api key writer unavailable")?;
|
||||
let wallet_reader = backends
|
||||
.read()
|
||||
.wallets()
|
||||
.ok_or("wallet reader unavailable")?;
|
||||
|
||||
let api_key_id = pressure_api_key_id(config, key_index);
|
||||
let api_key_value = pressure_api_key_value(config, key_index);
|
||||
|
||||
let existing = auth_reader
|
||||
.find_export_standalone_api_key_by_id(&api_key_id)
|
||||
.await?;
|
||||
if existing.is_none() {
|
||||
auth_writer
|
||||
.create_standalone_api_key(CreateStandaloneApiKeyRecord {
|
||||
user_id: operator_user_id.to_string(),
|
||||
api_key_id: api_key_id.clone(),
|
||||
key_hash: sha256_hex(&api_key_value),
|
||||
key_encrypted: Some(api_key_value),
|
||||
name: Some(format!("Local pressure API key {}", key_index + 1)),
|
||||
allowed_providers: Some(vec![config.provider_id.clone()]),
|
||||
allowed_api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
allowed_models: Some(vec![config.model.clone()]),
|
||||
ip_rules: None,
|
||||
rate_limit: Some(0),
|
||||
concurrent_limit: None,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: 0.0,
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
auth_writer
|
||||
.update_standalone_api_key_basic(
|
||||
aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord {
|
||||
api_key_id: api_key_id.clone(),
|
||||
name: Some(format!("Local pressure API key {}", key_index + 1)),
|
||||
rate_limit_present: true,
|
||||
rate_limit: Some(0),
|
||||
concurrent_limit_present: true,
|
||||
concurrent_limit: None,
|
||||
allowed_providers: Some(Some(vec![config.provider_id.clone()])),
|
||||
allowed_api_formats: Some(Some(vec!["openai:chat".to_string()])),
|
||||
allowed_models: Some(Some(vec![config.model.clone()])),
|
||||
ip_rules: Some(None),
|
||||
expires_at_present: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry_present: true,
|
||||
auto_delete_on_expiry: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
auth_writer
|
||||
.set_standalone_api_key_active(&api_key_id, true)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if wallet_reader
|
||||
.find(WalletLookupKey::ApiKeyId(&api_key_id))
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
wallet_reader
|
||||
.initialize_auth_api_key_wallet(&api_key_id, 0.0, true)
|
||||
.await?;
|
||||
} else {
|
||||
wallet_reader
|
||||
.update_auth_api_key_wallet_limit_mode(&api_key_id, "unlimited")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_operator_user(
|
||||
backends: &DataBackends,
|
||||
config: &Config,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let user_reader = backends.read().users().ok_or("user reader unavailable")?;
|
||||
let wallet_reader = backends
|
||||
.read()
|
||||
.wallets()
|
||||
.ok_or("wallet reader unavailable")?;
|
||||
let username = format!("{}-user", config.operator_id);
|
||||
|
||||
let user = match user_reader.find_user_auth_by_username(&username).await? {
|
||||
Some(user) => user_reader
|
||||
.update_local_auth_user_admin_fields(
|
||||
&user.id,
|
||||
Some("admin".to_string()),
|
||||
true,
|
||||
Some(vec![config.provider_id.clone()]),
|
||||
true,
|
||||
Some(vec!["openai:chat".to_string()]),
|
||||
true,
|
||||
Some(vec![config.model.clone()]),
|
||||
true,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await?
|
||||
.unwrap_or(user),
|
||||
None => user_reader
|
||||
.create_local_auth_user_with_settings(
|
||||
Some(format!("{}@local.pressure", config.operator_id)),
|
||||
true,
|
||||
username,
|
||||
"local-pressure-password-disabled".to_string(),
|
||||
"admin".to_string(),
|
||||
Some(vec![config.provider_id.clone()]),
|
||||
Some(vec!["openai:chat".to_string()]),
|
||||
Some(vec![config.model.clone()]),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.ok_or("failed to create pressure operator user")?,
|
||||
};
|
||||
|
||||
if wallet_reader
|
||||
.find(WalletLookupKey::UserId(&user.id))
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
wallet_reader
|
||||
.initialize_auth_user_wallet(&user.id, 0.0, true)
|
||||
.await?;
|
||||
} else {
|
||||
wallet_reader
|
||||
.update_auth_user_wallet_limit_mode(&user.id, "unlimited")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(user.id)
|
||||
}
|
||||
|
||||
async fn verify_candidate_selection(
|
||||
backends: &DataBackends,
|
||||
config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let reader = backends
|
||||
.read()
|
||||
.minimal_candidate_selection()
|
||||
.ok_or("candidate selection reader unavailable")?;
|
||||
let rows = reader
|
||||
.list_for_exact_api_format_and_requested_model("openai:chat", &config.model)
|
||||
.await?;
|
||||
let has_pressure_row = rows.iter().any(|row| {
|
||||
row.provider_id == config.provider_id
|
||||
&& row.endpoint_id == config.endpoint_id
|
||||
&& row.key_id == config.provider_key_id
|
||||
&& row.global_model_id == config.global_model_id
|
||||
&& row.model_id == config.model_id
|
||||
&& row.provider_is_active
|
||||
&& row.endpoint_is_active
|
||||
&& row.key_is_active
|
||||
&& row.model_is_active
|
||||
&& row.model_is_available
|
||||
});
|
||||
if !has_pressure_row {
|
||||
return Err(format!(
|
||||
"seeded candidate not visible for model {} and openai:chat",
|
||||
config.model
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_outputs(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = config.output_env_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(parent) = config.output_key_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(parent) = config.output_key_list_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::write(&config.output_key_path, format!("{}\n", config.api_key))?;
|
||||
let key_list = (0..config.api_key_count)
|
||||
.map(|index| pressure_api_key_value(config, index))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(&config.output_key_list_path, format!("{key_list}\n"))?;
|
||||
let env_content = format!(
|
||||
concat!(
|
||||
"export AETHER_API_KEY_FILE={key_path}\n",
|
||||
"export AETHER_API_KEY_LIST_FILE={key_list_path}\n",
|
||||
"export AETHER_API_KEY=$(cat {key_path})\n",
|
||||
"export GATEWAY_BASE_URL={gateway_base_url}\n",
|
||||
"export TARGET_URL={gateway_base_url}/v1/chat/completions\n",
|
||||
"export METRICS_URL={gateway_base_url}/_gateway/metrics\n",
|
||||
"export PRESSURE_MODEL={model}\n",
|
||||
"export PRESSURE_MOCK_UPSTREAM_BASE_URL={mock_upstream_base_url}\n",
|
||||
"export PRESSURE_MOCK_UPSTREAM_METRICS_URL=http://127.0.0.1:18181/metrics\n"
|
||||
),
|
||||
key_path = shell_escape(&config.output_key_path.display().to_string()),
|
||||
key_list_path = shell_escape(&config.output_key_list_path.display().to_string()),
|
||||
gateway_base_url = shell_escape(config.gateway_base_url.trim_end_matches('/')),
|
||||
model = shell_escape(&config.model),
|
||||
mock_upstream_base_url = shell_escape(&config.mock_upstream_base_url),
|
||||
);
|
||||
fs::write(&config.output_env_path, env_content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_hex(value: &str) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn pressure_api_key_id(config: &Config, index: usize) -> String {
|
||||
if index == 0 {
|
||||
config.api_key_id.clone()
|
||||
} else {
|
||||
format!("{}-{}", config.api_key_id, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn pressure_api_key_value(config: &Config, index: usize) -> String {
|
||||
if index == 0 {
|
||||
config.api_key.clone()
|
||||
} else {
|
||||
format!("{}-{}", config.api_key, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(value: &str) -> String {
|
||||
if value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | ':' | '_' | '-'))
|
||||
{
|
||||
return value.to_string();
|
||||
}
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn env_value(name: &str) -> Option<String> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], index: &mut usize, name: &str) -> Result<String, String> {
|
||||
*index += 1;
|
||||
args.get(*index)
|
||||
.filter(|value| !value.starts_with("--"))
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn parse_u32(value: &str, name: &str) -> Result<u32, String> {
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("{name} must be an unsigned integer"))
|
||||
}
|
||||
|
||||
fn parse_usize(value: &str, name: &str) -> Result<usize, String> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("{name} must be an unsigned integer"))
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"Usage: cargo run -p aether-integration-tests --bin gateway_pressure_seed -- [options]\n\
|
||||
\n\
|
||||
Options:\n\
|
||||
--database-url URL\n\
|
||||
--output-env PATH\n\
|
||||
--output-key PATH\n\
|
||||
--output-key-list PATH\n\
|
||||
--gateway-base-url URL\n\
|
||||
--mock-upstream-base-url URL\n\
|
||||
--model NAME\n\
|
||||
--api-key VALUE\n\
|
||||
--api-key-count N\n\
|
||||
--provider-api-key VALUE\n\
|
||||
--provider-id ID\n\
|
||||
--endpoint-id ID\n\
|
||||
--provider-key-id ID\n\
|
||||
--global-model-id ID\n\
|
||||
--model-id ID\n\
|
||||
--api-key-id ID\n"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_gateway::tunnel_protocol as protocol;
|
||||
use aether_testkit::{
|
||||
fetch_prometheus_samples, find_metric_value_u64, init_test_runtime_for, run_http_load_probe,
|
||||
HttpLoadProbeConfig, HttpLoadProbeResponseMode, HttpLoadProbeResult, PrometheusSample,
|
||||
TunnelHarness, TunnelHarnessConfig,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
||||
const TUNNEL_RELAY_PATH_PREFIX: &str = "/api/internal/tunnel/relay";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GatewayTunnelBaselineConfig {
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
request_body_bytes: usize,
|
||||
tunnel_connections: usize,
|
||||
outbound_queue_capacity: usize,
|
||||
close_one_connection_after: Option<Duration>,
|
||||
require_acceptance: bool,
|
||||
timeout: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for GatewayTunnelBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_requests: 200,
|
||||
concurrency: 20,
|
||||
request_body_bytes: 6 * 1024 * 1024,
|
||||
tunnel_connections: 4,
|
||||
outbound_queue_capacity: 512,
|
||||
close_one_connection_after: None,
|
||||
require_acceptance: false,
|
||||
timeout: Duration::from_secs(10),
|
||||
output_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GatewayTunnelBaselineReport {
|
||||
suite: &'static str,
|
||||
config: GatewayTunnelEffectiveConfig,
|
||||
scenario: HttpLoadProbeResult,
|
||||
tunnel_metrics: TunnelMetricsSnapshot,
|
||||
acceptance: AcceptanceReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GatewayTunnelEffectiveConfig {
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
request_body_bytes: usize,
|
||||
tunnel_connections: usize,
|
||||
outbound_queue_capacity: usize,
|
||||
close_one_connection_after_ms: Option<u64>,
|
||||
timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TunnelMetricsSnapshot {
|
||||
proxy_connections: u64,
|
||||
active_streams: u64,
|
||||
outbound_queue_depth_total: u64,
|
||||
outbound_queue_depth_max: u64,
|
||||
outbound_queue_capacity_total: u64,
|
||||
outbound_queue_rejected_full_total: u64,
|
||||
outbound_queue_rejected_closed_total: u64,
|
||||
proxy_connection_congested_total: u64,
|
||||
proxy_connection_write_latency_last_us_max: u64,
|
||||
proxy_connection_write_latency_ewma_us_max: u64,
|
||||
body_backpressure_total: u64,
|
||||
flow_window_blocked_ms: u64,
|
||||
connection_health_score: u64,
|
||||
stream_reset_total: u64,
|
||||
stream_reset_reasons: BTreeMap<String, u64>,
|
||||
drain_total: u64,
|
||||
drain_reasons: BTreeMap<String, u64>,
|
||||
scheduler_selected_conn_total: u64,
|
||||
proxy_connections_protocol_v1: u64,
|
||||
proxy_connections_protocol_v2: u64,
|
||||
proxy_connections_protocol_v3: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AcceptanceReport {
|
||||
required: bool,
|
||||
passed: bool,
|
||||
success_rate_bps: u64,
|
||||
min_success_rate_bps: u64,
|
||||
congestion_free: bool,
|
||||
no_queue_full_rejections: bool,
|
||||
reasons: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("gateway-tunnel-stream-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &GatewayTunnelBaselineConfig,
|
||||
) -> Result<GatewayTunnelBaselineReport, Box<dyn std::error::Error>> {
|
||||
let tunnel = TunnelHarness::start(TunnelHarnessConfig {
|
||||
outbound_queue_capacity: config.outbound_queue_capacity,
|
||||
..TunnelHarnessConfig::default()
|
||||
})
|
||||
.await?;
|
||||
let mut peers = connect_protocol_peers(tunnel.base_url(), config.tunnel_connections).await?;
|
||||
let fault_injection = config.close_one_connection_after.map(|delay| {
|
||||
let peer = peers.pop();
|
||||
tokio::spawn(async move {
|
||||
if let Some(peer) = peer {
|
||||
tokio::time::sleep(delay).await;
|
||||
peer.abort();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let result = run_http_load_probe(&HttpLoadProbeConfig {
|
||||
url: format!(
|
||||
"{tunnel_base}{TUNNEL_RELAY_PATH_PREFIX}/node-baseline",
|
||||
tunnel_base = tunnel.base_url()
|
||||
),
|
||||
method: Method::POST,
|
||||
headers: std::collections::BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/octet-stream".to_string(),
|
||||
)]),
|
||||
body: Some(relay_envelope(config.request_body_bytes)),
|
||||
total_requests: config.total_requests,
|
||||
concurrency: config.concurrency,
|
||||
timeout: config.timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let tunnel_metrics = capture_tunnel_metrics(tunnel.base_url()).await?;
|
||||
if let Some(task) = fault_injection {
|
||||
let _ = task.await;
|
||||
}
|
||||
drop(peers);
|
||||
let acceptance = evaluate_acceptance(config, &result, &tunnel_metrics);
|
||||
if config.require_acceptance && !acceptance.passed {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"gateway tunnel acceptance failed: {:?}",
|
||||
acceptance.reasons
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(GatewayTunnelBaselineReport {
|
||||
suite: "gateway_tunnel_stream_baseline",
|
||||
config: GatewayTunnelEffectiveConfig {
|
||||
total_requests: config.total_requests,
|
||||
concurrency: config.concurrency,
|
||||
request_body_bytes: config.request_body_bytes,
|
||||
tunnel_connections: config.tunnel_connections,
|
||||
outbound_queue_capacity: config.outbound_queue_capacity,
|
||||
close_one_connection_after_ms: config
|
||||
.close_one_connection_after
|
||||
.map(|duration| duration.as_millis() as u64),
|
||||
timeout_ms: config.timeout.as_millis() as u64,
|
||||
},
|
||||
scenario: result,
|
||||
tunnel_metrics,
|
||||
acceptance,
|
||||
})
|
||||
}
|
||||
|
||||
fn relay_envelope(request_body_bytes: usize) -> Vec<u8> {
|
||||
let meta = protocol::RequestMeta {
|
||||
method: "POST".to_string(),
|
||||
url: "https://baseline.example/v1/chat/completions".to_string(),
|
||||
headers: std::collections::HashMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
stream: true,
|
||||
request_timeout_ms: None,
|
||||
stream_first_byte_timeout_ms: None,
|
||||
timeout: 30,
|
||||
follow_redirects: None,
|
||||
http1_only: false,
|
||||
provider_id: None,
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
transport_profile: None,
|
||||
};
|
||||
let meta_json = serde_json::to_vec(&meta).expect("tunnel relay metadata should serialize");
|
||||
let body = vec![b'x'; request_body_bytes];
|
||||
let mut envelope = Vec::with_capacity(4 + meta_json.len() + body.len());
|
||||
envelope.extend_from_slice(&(meta_json.len() as u32).to_be_bytes());
|
||||
envelope.extend_from_slice(&meta_json);
|
||||
envelope.extend_from_slice(&body);
|
||||
envelope
|
||||
}
|
||||
|
||||
async fn connect_protocol_peers(
|
||||
tunnel_base_url: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<tokio::task::JoinHandle<()>>, Box<dyn std::error::Error>> {
|
||||
let count = count.max(1);
|
||||
let mut peers = Vec::with_capacity(count);
|
||||
for index in 0..count {
|
||||
peers.push(connect_protocol_peer(tunnel_base_url, index).await?);
|
||||
}
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
async fn connect_protocol_peer(
|
||||
tunnel_base_url: &str,
|
||||
index: usize,
|
||||
) -> Result<tokio::task::JoinHandle<()>, Box<dyn std::error::Error>> {
|
||||
let ws_url = format!(
|
||||
"{}{}",
|
||||
tunnel_base_url.replace("http://", "ws://"),
|
||||
PROXY_TUNNEL_PATH
|
||||
);
|
||||
let request = ws_url.into_client_request()?;
|
||||
let mut request = request;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-node-id", http::HeaderValue::from_static("node-baseline"));
|
||||
request.headers_mut().insert(
|
||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||
http::HeaderValue::from_static(
|
||||
aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION_STR,
|
||||
),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-node-name",
|
||||
http::HeaderValue::from_str(&format!("proxy-baseline-{index}"))?,
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-tunnel-max-streams",
|
||||
http::HeaderValue::from_static("128"),
|
||||
);
|
||||
|
||||
let (socket, _response) = tokio_tungstenite::connect_async(request).await?;
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_hello(&protocol::HelloPayload {
|
||||
protocol_version: aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION,
|
||||
capabilities: vec![
|
||||
"flow-control".to_string(),
|
||||
"reset-stream".to_string(),
|
||||
"graceful-drain".to_string(),
|
||||
],
|
||||
session_id: Some(format!("baseline-session-{index}")),
|
||||
replica_id: Some(format!("baseline-replica-{index}")),
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_settings(&protocol::SettingsPayload {
|
||||
initial_stream_window_bytes: 4 * 1024 * 1024,
|
||||
min_window_update_bytes: 1024 * 1024,
|
||||
drain_deadline_ms: 30_000,
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
Ok(tokio::spawn(async move {
|
||||
while let Some(message) = stream.next().await {
|
||||
let Ok(message) = message else {
|
||||
break;
|
||||
};
|
||||
match message {
|
||||
Message::Binary(data)
|
||||
if handle_binary_frame(&mut sink, data.to_vec()).await.is_err() =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Message::Ping(payload)
|
||||
if sink.send(Message::Pong(payload.clone())).await.is_err() =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = sink.close().await;
|
||||
}))
|
||||
}
|
||||
|
||||
async fn capture_tunnel_metrics(
|
||||
base_url: &str,
|
||||
) -> Result<TunnelMetricsSnapshot, Box<dyn std::error::Error>> {
|
||||
let samples = fetch_prometheus_samples(&format!("{base_url}/metrics"))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok(TunnelMetricsSnapshot {
|
||||
proxy_connections: find_metric_value_u64(&samples, "tunnel_proxy_connections", &[])
|
||||
.unwrap_or_default(),
|
||||
active_streams: find_metric_value_u64(&samples, "tunnel_active_streams", &[])
|
||||
.unwrap_or_default(),
|
||||
outbound_queue_depth_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_outbound_queue_depth_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
outbound_queue_depth_max: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_outbound_queue_depth_max",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
outbound_queue_capacity_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_outbound_queue_capacity_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
outbound_queue_rejected_full_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_outbound_queue_rejected_full_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
outbound_queue_rejected_closed_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_outbound_queue_rejected_closed_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
proxy_connection_congested_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_connection_congested_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
proxy_connection_write_latency_last_us_max: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_connection_write_latency_last_us_max",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
proxy_connection_write_latency_ewma_us_max: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_connection_write_latency_ewma_us_max",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
body_backpressure_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_body_backpressure_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
flow_window_blocked_ms: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_flow_window_blocked_ms",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
connection_health_score: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_connection_health_score",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
stream_reset_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_stream_reset_total",
|
||||
&[("reason", "all")],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
stream_reset_reasons: collect_reason_metrics(&samples, "tunnel_stream_reset_total"),
|
||||
drain_total: find_metric_value_u64(&samples, "tunnel_drain_total", &[("reason", "all")])
|
||||
.unwrap_or_default(),
|
||||
drain_reasons: collect_reason_metrics(&samples, "tunnel_drain_total"),
|
||||
scheduler_selected_conn_total: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_scheduler_selected_conn_total",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
proxy_connections_protocol_v1: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_connections_protocol_v1",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
proxy_connections_protocol_v2: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_connections_protocol_v2",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
proxy_connections_protocol_v3: find_metric_value_u64(
|
||||
&samples,
|
||||
"tunnel_proxy_connections_protocol_v3",
|
||||
&[],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_reason_metrics(
|
||||
samples: &[PrometheusSample],
|
||||
metric_name: &str,
|
||||
) -> BTreeMap<String, u64> {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|sample| {
|
||||
(sample.name == metric_name || sample.name.ends_with(&format!("_{metric_name}")))
|
||||
&& sample
|
||||
.labels
|
||||
.get("reason")
|
||||
.is_some_and(|reason| reason != "all")
|
||||
})
|
||||
.filter_map(|sample| {
|
||||
let reason = sample.labels.get("reason")?.clone();
|
||||
let value = sample.value.parse::<u64>().ok()?;
|
||||
Some((reason, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluate_acceptance(
|
||||
config: &GatewayTunnelBaselineConfig,
|
||||
result: &HttpLoadProbeResult,
|
||||
metrics: &TunnelMetricsSnapshot,
|
||||
) -> AcceptanceReport {
|
||||
let min_success_rate_bps = if config.require_acceptance { 9_950 } else { 1 };
|
||||
let successful_requests = result
|
||||
.status_counts
|
||||
.iter()
|
||||
.filter(|(status, _)| (200u16..300u16).contains(status))
|
||||
.map(|(_, count)| *count)
|
||||
.sum::<usize>();
|
||||
let success_rate_bps = if result.total_requests == 0 {
|
||||
0
|
||||
} else {
|
||||
((successful_requests as u128) * 10_000 / (result.total_requests as u128)) as u64
|
||||
};
|
||||
let congestion_free = metrics.proxy_connection_congested_total == 0;
|
||||
let no_queue_full_rejections = metrics.outbound_queue_rejected_full_total == 0;
|
||||
let mut reasons = Vec::new();
|
||||
if success_rate_bps < min_success_rate_bps {
|
||||
reasons.push(format!(
|
||||
"success rate {} bps below required {} bps",
|
||||
success_rate_bps, min_success_rate_bps
|
||||
));
|
||||
}
|
||||
if !congestion_free {
|
||||
reasons.push(format!(
|
||||
"connection congestion total is {}",
|
||||
metrics.proxy_connection_congested_total
|
||||
));
|
||||
}
|
||||
if !no_queue_full_rejections {
|
||||
reasons.push(format!(
|
||||
"outbound queue full rejections total is {}",
|
||||
metrics.outbound_queue_rejected_full_total
|
||||
));
|
||||
}
|
||||
|
||||
AcceptanceReport {
|
||||
required: config.require_acceptance,
|
||||
passed: reasons.is_empty(),
|
||||
success_rate_bps,
|
||||
min_success_rate_bps,
|
||||
congestion_free,
|
||||
no_queue_full_rejections,
|
||||
reasons,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_binary_frame<S>(
|
||||
sink: &mut S,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), tokio_tungstenite::tungstenite::Error>
|
||||
where
|
||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
let Some(header) = protocol::FrameHeader::parse(&data) else {
|
||||
return Ok(());
|
||||
};
|
||||
match header.msg_type {
|
||||
protocol::PING => {
|
||||
let payload = protocol::frame_payload_by_header(&data, &header).unwrap_or(&[]);
|
||||
sink.send(Message::Binary(protocol::encode_pong(payload).into()))
|
||||
.await?;
|
||||
}
|
||||
protocol::REQUEST_HEADERS => {
|
||||
let payload = protocol::decode_payload(&data, &header).unwrap_or_default();
|
||||
let _ = serde_json::from_slice::<protocol::RequestMeta>(&payload);
|
||||
}
|
||||
protocol::REQUEST_BODY => {
|
||||
let payload = protocol::decode_payload(&data, &header).unwrap_or_default();
|
||||
if !payload.is_empty() {
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_window_update(header.stream_id, payload.len() as u32).into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
if header.flags & protocol::FLAG_END_STREAM == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let response_meta = protocol::ResponseMeta {
|
||||
status: 200,
|
||||
headers: vec![(
|
||||
"content-type".to_string(),
|
||||
"text/plain; charset=utf-8".to_string(),
|
||||
)],
|
||||
};
|
||||
let response_meta_json =
|
||||
serde_json::to_vec(&response_meta).expect("response metadata should serialize");
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(
|
||||
header.stream_id,
|
||||
protocol::RESPONSE_HEADERS,
|
||||
0,
|
||||
&response_meta_json,
|
||||
)
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
for chunk in [
|
||||
b"baseline-".as_slice(),
|
||||
b"tunnel-".as_slice(),
|
||||
b"stream".as_slice(),
|
||||
] {
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(header.stream_id, protocol::RESPONSE_BODY, 0, chunk)
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(header.stream_id, protocol::STREAM_END, 0, &[]).into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<GatewayTunnelBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = GatewayTunnelBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--requests" => config.total_requests = next_value(&mut iter, "--requests")?.parse()?,
|
||||
"--concurrency" => {
|
||||
config.concurrency = next_value(&mut iter, "--concurrency")?.parse()?
|
||||
}
|
||||
"--body-bytes" => {
|
||||
config.request_body_bytes = next_value(&mut iter, "--body-bytes")?.parse()?
|
||||
}
|
||||
"--tunnel-connections" => {
|
||||
config.tunnel_connections =
|
||||
next_value(&mut iter, "--tunnel-connections")?.parse()?
|
||||
}
|
||||
"--outbound-queue-capacity" => {
|
||||
config.outbound_queue_capacity =
|
||||
next_value(&mut iter, "--outbound-queue-capacity")?.parse()?
|
||||
}
|
||||
"--close-one-connection-after-ms" => {
|
||||
config.close_one_connection_after = Some(Duration::from_millis(
|
||||
next_value(&mut iter, "--close-one-connection-after-ms")?.parse()?,
|
||||
))
|
||||
}
|
||||
"--require-acceptance" => {
|
||||
config.require_acceptance = true;
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout =
|
||||
Duration::from_millis(next_value(&mut iter, "--timeout-ms")?.parse()?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.request_body_bytes == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--body-bytes must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if config.tunnel_connections == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--tunnel-connections must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if config.outbound_queue_capacity == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--outbound-queue-capacity must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if config
|
||||
.close_one_connection_after
|
||||
.is_some_and(|duration| duration.is_zero())
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--close-one-connection-after-ms must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin gateway_tunnel_stream_baseline -- [--requests 200] [--concurrency 20] [--body-bytes 6291456] [--tunnel-connections 4] [--outbound-queue-capacity 512] [--close-one-connection-after-ms 1000] [--require-acceptance] [--timeout-ms 10000] [--output /tmp/gateway_tunnel_baseline.json]"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,694 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
binds: Vec<SocketAddr>,
|
||||
chunks: u64,
|
||||
first_byte_delay: Duration,
|
||||
chunk_delay: Duration,
|
||||
payload_bytes: usize,
|
||||
status: StatusCode,
|
||||
assume_stream: bool,
|
||||
}
|
||||
|
||||
const MAX_MOCK_REQUEST_BODY_BYTES: usize = 1024 * 1024;
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
binds: vec!["127.0.0.1:18181"
|
||||
.parse()
|
||||
.expect("default bind address should parse")],
|
||||
chunks: 8,
|
||||
first_byte_delay: Duration::from_millis(0),
|
||||
chunk_delay: Duration::from_millis(20),
|
||||
payload_bytes: 32,
|
||||
status: StatusCode::OK,
|
||||
assume_stream: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Metrics {
|
||||
requests_total: AtomicU64,
|
||||
completed_total: AtomicU64,
|
||||
in_flight: AtomicU64,
|
||||
max_in_flight: AtomicU64,
|
||||
request_body_read_sum_ms: AtomicU64,
|
||||
request_body_read_max_ms: AtomicU64,
|
||||
accepted_to_response_header_sum_ms: AtomicU64,
|
||||
accepted_to_response_header_max_ms: AtomicU64,
|
||||
first_chunk_yield_total: AtomicU64,
|
||||
response_header_to_first_chunk_sum_ms: AtomicU64,
|
||||
response_header_to_first_chunk_max_ms: AtomicU64,
|
||||
binds: BTreeMap<String, Arc<BindMetrics>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct BindMetrics {
|
||||
requests_total: AtomicU64,
|
||||
completed_total: AtomicU64,
|
||||
in_flight: AtomicU64,
|
||||
max_in_flight: AtomicU64,
|
||||
request_body_read_sum_ms: AtomicU64,
|
||||
request_body_read_max_ms: AtomicU64,
|
||||
accepted_to_response_header_sum_ms: AtomicU64,
|
||||
accepted_to_response_header_max_ms: AtomicU64,
|
||||
first_chunk_yield_total: AtomicU64,
|
||||
response_header_to_first_chunk_sum_ms: AtomicU64,
|
||||
response_header_to_first_chunk_max_ms: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct App {
|
||||
config: Config,
|
||||
metrics: Arc<Metrics>,
|
||||
bind_label: Arc<str>,
|
||||
}
|
||||
|
||||
struct RequestCompletionGuard {
|
||||
app: App,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
impl RequestCompletionGuard {
|
||||
fn new(app: App) -> Self {
|
||||
Self {
|
||||
app,
|
||||
completed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn complete(mut self) {
|
||||
self.complete_once();
|
||||
}
|
||||
|
||||
fn complete_once(&mut self) {
|
||||
if self.completed {
|
||||
return;
|
||||
}
|
||||
record_request_completed(&self.app);
|
||||
self.completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RequestCompletionGuard {
|
||||
fn drop(&mut self) {
|
||||
self.complete_once();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let metrics = Arc::new(Metrics::for_binds(&config.binds));
|
||||
|
||||
serve_listeners(&config, metrics).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_listeners(
|
||||
config: &Config,
|
||||
metrics: Arc<Metrics>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut listeners = Vec::with_capacity(config.binds.len());
|
||||
for bind in &config.binds {
|
||||
listeners.push((*bind, tokio::net::TcpListener::bind(bind).await?));
|
||||
}
|
||||
if listeners.len() == 1 {
|
||||
let (bind, listener) = listeners
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| std::io::Error::other("mock upstream listener set is empty"))?;
|
||||
let app = build_router(config.clone(), Arc::clone(&metrics), bind);
|
||||
eprintln!("mock OpenAI upstream listening on http://{bind}");
|
||||
axum::serve(listener, app).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut servers = tokio::task::JoinSet::new();
|
||||
for (bind, listener) in listeners {
|
||||
let app = build_router(config.clone(), Arc::clone(&metrics), bind);
|
||||
eprintln!("mock OpenAI upstream listening on http://{bind}");
|
||||
servers.spawn(async move { axum::serve(listener, app).await });
|
||||
}
|
||||
if let Some(result) = servers.join_next().await {
|
||||
servers.abort_all();
|
||||
let serve_result = result
|
||||
.map_err(|err| std::io::Error::other(format!("mock listener task failed: {err}")))?;
|
||||
serve_result?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_router(config: Config, shared_metrics: Arc<Metrics>, bind: SocketAddr) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/metrics", get(metrics))
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/chat/completions", post(chat_completions))
|
||||
.route("/v1/responses", post(responses))
|
||||
.route("/responses", post(responses))
|
||||
.with_state(App {
|
||||
config,
|
||||
metrics: shared_metrics,
|
||||
bind_label: Arc::from(bind.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
fn for_binds(binds: &[SocketAddr]) -> Self {
|
||||
let mut metrics = Self::default();
|
||||
for bind in binds {
|
||||
metrics
|
||||
.binds
|
||||
.insert(bind.to_string(), Arc::new(BindMetrics::default()));
|
||||
}
|
||||
metrics
|
||||
}
|
||||
|
||||
fn bind_metrics(&self, bind: &str) -> Option<Arc<BindMetrics>> {
|
||||
self.binds.get(bind).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
(StatusCode::OK, "ok\n")
|
||||
}
|
||||
|
||||
async fn metrics(State(app): State<App>) -> Response {
|
||||
let mut body = format!(
|
||||
concat!(
|
||||
"# HELP mock_upstream_requests_total Total requests accepted by the mock upstream.\n",
|
||||
"# TYPE mock_upstream_requests_total counter\n",
|
||||
"mock_upstream_requests_total {}\n",
|
||||
"# HELP mock_upstream_completed_total Total requests completed by the mock upstream.\n",
|
||||
"# TYPE mock_upstream_completed_total counter\n",
|
||||
"mock_upstream_completed_total {}\n",
|
||||
"# HELP mock_upstream_in_flight Current in-flight requests/streams.\n",
|
||||
"# TYPE mock_upstream_in_flight gauge\n",
|
||||
"mock_upstream_in_flight {}\n",
|
||||
"# HELP mock_upstream_max_in_flight Maximum in-flight requests/streams observed.\n",
|
||||
"# TYPE mock_upstream_max_in_flight gauge\n",
|
||||
"mock_upstream_max_in_flight {}\n",
|
||||
"# HELP mock_upstream_first_chunk_yield_total Total first stream chunks yielded by the mock upstream.\n",
|
||||
"# TYPE mock_upstream_first_chunk_yield_total counter\n",
|
||||
"mock_upstream_first_chunk_yield_total {}\n",
|
||||
"# HELP mock_upstream_request_body_read_sum_ms Total milliseconds spent reading request bodies after handler entry.\n",
|
||||
"# TYPE mock_upstream_request_body_read_sum_ms counter\n",
|
||||
"mock_upstream_request_body_read_sum_ms {}\n",
|
||||
"# HELP mock_upstream_request_body_read_max_ms Maximum milliseconds spent reading a request body after handler entry.\n",
|
||||
"# TYPE mock_upstream_request_body_read_max_ms gauge\n",
|
||||
"mock_upstream_request_body_read_max_ms {}\n",
|
||||
"# HELP mock_upstream_accepted_to_response_header_sum_ms Total milliseconds from request acceptance to response construction.\n",
|
||||
"# TYPE mock_upstream_accepted_to_response_header_sum_ms counter\n",
|
||||
"mock_upstream_accepted_to_response_header_sum_ms {}\n",
|
||||
"# HELP mock_upstream_accepted_to_response_header_max_ms Maximum milliseconds from request acceptance to response construction.\n",
|
||||
"# TYPE mock_upstream_accepted_to_response_header_max_ms gauge\n",
|
||||
"mock_upstream_accepted_to_response_header_max_ms {}\n",
|
||||
"# HELP mock_upstream_response_header_to_first_chunk_sum_ms Total milliseconds from response construction to first stream chunk yield.\n",
|
||||
"# TYPE mock_upstream_response_header_to_first_chunk_sum_ms counter\n",
|
||||
"mock_upstream_response_header_to_first_chunk_sum_ms {}\n",
|
||||
"# HELP mock_upstream_response_header_to_first_chunk_max_ms Maximum milliseconds from response construction to first stream chunk yield.\n",
|
||||
"# TYPE mock_upstream_response_header_to_first_chunk_max_ms gauge\n",
|
||||
"mock_upstream_response_header_to_first_chunk_max_ms {}\n"
|
||||
),
|
||||
app.metrics.requests_total.load(Ordering::Acquire),
|
||||
app.metrics.completed_total.load(Ordering::Acquire),
|
||||
app.metrics.in_flight.load(Ordering::Acquire),
|
||||
app.metrics.max_in_flight.load(Ordering::Acquire),
|
||||
app.metrics.first_chunk_yield_total.load(Ordering::Acquire),
|
||||
app.metrics
|
||||
.request_body_read_sum_ms
|
||||
.load(Ordering::Acquire),
|
||||
app.metrics
|
||||
.request_body_read_max_ms
|
||||
.load(Ordering::Acquire),
|
||||
app.metrics
|
||||
.accepted_to_response_header_sum_ms
|
||||
.load(Ordering::Acquire),
|
||||
app.metrics
|
||||
.accepted_to_response_header_max_ms
|
||||
.load(Ordering::Acquire),
|
||||
app.metrics
|
||||
.response_header_to_first_chunk_sum_ms
|
||||
.load(Ordering::Acquire),
|
||||
app.metrics
|
||||
.response_header_to_first_chunk_max_ms
|
||||
.load(Ordering::Acquire),
|
||||
);
|
||||
for (bind, metrics) in &app.metrics.binds {
|
||||
let bind = prometheus_label_value(bind);
|
||||
body.push_str(&format!(
|
||||
concat!(
|
||||
"mock_upstream_requests_total{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_completed_total{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_in_flight{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_max_in_flight{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_first_chunk_yield_total{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_request_body_read_sum_ms{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_request_body_read_max_ms{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_accepted_to_response_header_sum_ms{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_accepted_to_response_header_max_ms{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_response_header_to_first_chunk_sum_ms{{bind=\"{}\"}} {}\n",
|
||||
"mock_upstream_response_header_to_first_chunk_max_ms{{bind=\"{}\"}} {}\n"
|
||||
),
|
||||
bind,
|
||||
metrics.requests_total.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics.completed_total.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics.in_flight.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics.max_in_flight.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics.first_chunk_yield_total.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics.request_body_read_sum_ms.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics.request_body_read_max_ms.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics
|
||||
.accepted_to_response_header_sum_ms
|
||||
.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics
|
||||
.accepted_to_response_header_max_ms
|
||||
.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics
|
||||
.response_header_to_first_chunk_sum_ms
|
||||
.load(Ordering::Acquire),
|
||||
bind,
|
||||
metrics
|
||||
.response_header_to_first_chunk_max_ms
|
||||
.load(Ordering::Acquire),
|
||||
));
|
||||
}
|
||||
(StatusCode::OK, body).into_response()
|
||||
}
|
||||
|
||||
async fn chat_completions(State(app): State<App>, request: axum::extract::Request) -> Response {
|
||||
let request_started_at = record_request_started(&app);
|
||||
if app.config.status != StatusCode::OK {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
record_request_completed(&app);
|
||||
return (app.config.status, "mock upstream error\n").into_response();
|
||||
}
|
||||
if app.config.assume_stream {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
return build_chat_sse_response(app);
|
||||
}
|
||||
|
||||
let body = match read_request_body(&app, request).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
record_request_completed(&app);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let stream = request_wants_stream(&body);
|
||||
if stream {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
return build_chat_sse_response(app);
|
||||
}
|
||||
let payload = json!({
|
||||
"id": "chatcmpl-mock",
|
||||
"object": "chat.completion",
|
||||
"created": current_unix_secs(),
|
||||
"model": request_model(&body).unwrap_or_else(|| "mock-model".to_string()),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": mock_payload(app.config.payload_bytes)
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": app.config.chunks.max(1),
|
||||
"total_tokens": app.config.chunks.max(1) + 1
|
||||
}
|
||||
});
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
record_request_completed(&app);
|
||||
axum::Json(payload).into_response()
|
||||
}
|
||||
|
||||
async fn responses(State(app): State<App>, request: axum::extract::Request) -> Response {
|
||||
let request_started_at = record_request_started(&app);
|
||||
if app.config.status != StatusCode::OK {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
record_request_completed(&app);
|
||||
return (app.config.status, "mock upstream error\n").into_response();
|
||||
}
|
||||
if app.config.assume_stream {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
return build_responses_sse_response(app);
|
||||
}
|
||||
|
||||
let body = match read_request_body(&app, request).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
record_request_completed(&app);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let stream = request_wants_stream(&body);
|
||||
if stream {
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
return build_responses_sse_response(app);
|
||||
}
|
||||
let payload = json!({
|
||||
"id": "resp_mock",
|
||||
"object": "response",
|
||||
"created_at": current_unix_secs(),
|
||||
"model": request_model(&body).unwrap_or_else(|| "mock-model".to_string()),
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_mock",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": mock_payload(app.config.payload_bytes)
|
||||
}]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": app.config.chunks.max(1),
|
||||
"total_tokens": app.config.chunks.max(1) + 1
|
||||
}
|
||||
});
|
||||
record_response_header_created(&app, request_started_at.elapsed());
|
||||
record_request_completed(&app);
|
||||
axum::Json(payload).into_response()
|
||||
}
|
||||
|
||||
fn build_chat_sse_response(app: App) -> Response {
|
||||
let response_created_at = Instant::now();
|
||||
let config = app.config.clone();
|
||||
let mut completion = Some(RequestCompletionGuard::new(app.clone()));
|
||||
let stream = async_stream::stream! {
|
||||
if !config.first_byte_delay.is_zero() {
|
||||
tokio::time::sleep(config.first_byte_delay).await;
|
||||
}
|
||||
for index in 0..config.chunks {
|
||||
if index > 0 && !config.chunk_delay.is_zero() {
|
||||
tokio::time::sleep(config.chunk_delay).await;
|
||||
}
|
||||
let payload = json!({
|
||||
"id": "chatcmpl-mock",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": current_unix_secs(),
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": mock_payload(config.payload_bytes)
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
if index == 0 {
|
||||
record_first_chunk_yield(&app, response_created_at.elapsed());
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(format!("data: {payload}\n\n")));
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
));
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from("data: [DONE]\n\n"));
|
||||
if let Some(completion) = completion.take() {
|
||||
completion.complete();
|
||||
}
|
||||
};
|
||||
sse_response(Body::from_stream(stream))
|
||||
}
|
||||
|
||||
fn build_responses_sse_response(app: App) -> Response {
|
||||
let response_created_at = Instant::now();
|
||||
let config = app.config.clone();
|
||||
let mut completion = Some(RequestCompletionGuard::new(app.clone()));
|
||||
let stream = async_stream::stream! {
|
||||
if !config.first_byte_delay.is_zero() {
|
||||
tokio::time::sleep(config.first_byte_delay).await;
|
||||
}
|
||||
record_first_chunk_yield(&app, response_created_at.elapsed());
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(
|
||||
"event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_mock\",\"status\":\"in_progress\"}}\n\n",
|
||||
));
|
||||
for index in 0..config.chunks {
|
||||
if index > 0 && !config.chunk_delay.is_zero() {
|
||||
tokio::time::sleep(config.chunk_delay).await;
|
||||
}
|
||||
let payload = json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_mock",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": mock_payload(config.payload_bytes)
|
||||
});
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(format!("event: response.output_text.delta\ndata: {payload}\n\n")));
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(
|
||||
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_mock\",\"status\":\"completed\"}}\n\n",
|
||||
));
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from("data: [DONE]\n\n"));
|
||||
if let Some(completion) = completion.take() {
|
||||
completion.complete();
|
||||
}
|
||||
};
|
||||
sse_response(Body::from_stream(stream))
|
||||
}
|
||||
|
||||
fn sse_response(body: Body) -> Response {
|
||||
let mut response = Response::new(body);
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/event-stream; charset=utf-8"),
|
||||
);
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
async fn read_request_body(app: &App, request: axum::extract::Request) -> Result<Bytes, Response> {
|
||||
let body_started_at = Instant::now();
|
||||
let body = to_bytes(request.into_body(), MAX_MOCK_REQUEST_BODY_BYTES)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("mock upstream failed to read request body: {err}\n"),
|
||||
)
|
||||
.into_response()
|
||||
});
|
||||
record_request_body_read(app, body_started_at.elapsed());
|
||||
body
|
||||
}
|
||||
|
||||
fn request_wants_stream(body: &[u8]) -> bool {
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| value.get("stream").and_then(serde_json::Value::as_bool))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn request_model(body: &[u8]) -> Option<String> {
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn mock_payload(bytes: usize) -> String {
|
||||
if bytes == 0 {
|
||||
return String::new();
|
||||
}
|
||||
"x".repeat(bytes)
|
||||
}
|
||||
|
||||
fn record_request_started(app: &App) -> Instant {
|
||||
app.metrics.requests_total.fetch_add(1, Ordering::AcqRel);
|
||||
let in_flight = app.metrics.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
app.metrics
|
||||
.max_in_flight
|
||||
.fetch_max(in_flight, Ordering::AcqRel);
|
||||
if let Some(bind) = app.metrics.bind_metrics(app.bind_label.as_ref()) {
|
||||
bind.requests_total.fetch_add(1, Ordering::AcqRel);
|
||||
let in_flight = bind.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
bind.max_in_flight.fetch_max(in_flight, Ordering::AcqRel);
|
||||
}
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
fn record_request_completed(app: &App) {
|
||||
app.metrics.completed_total.fetch_add(1, Ordering::AcqRel);
|
||||
app.metrics.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
if let Some(bind) = app.metrics.bind_metrics(app.bind_label.as_ref()) {
|
||||
bind.completed_total.fetch_add(1, Ordering::AcqRel);
|
||||
bind.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_first_chunk_yield(app: &App, elapsed: Duration) {
|
||||
let elapsed_ms = elapsed.as_millis() as u64;
|
||||
app.metrics
|
||||
.first_chunk_yield_total
|
||||
.fetch_add(1, Ordering::AcqRel);
|
||||
app.metrics
|
||||
.response_header_to_first_chunk_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
app.metrics
|
||||
.response_header_to_first_chunk_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
if let Some(bind) = app.metrics.bind_metrics(app.bind_label.as_ref()) {
|
||||
bind.first_chunk_yield_total.fetch_add(1, Ordering::AcqRel);
|
||||
bind.response_header_to_first_chunk_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
bind.response_header_to_first_chunk_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_request_body_read(app: &App, elapsed: Duration) {
|
||||
let elapsed_ms = elapsed.as_millis() as u64;
|
||||
app.metrics
|
||||
.request_body_read_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
app.metrics
|
||||
.request_body_read_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
if let Some(bind) = app.metrics.bind_metrics(app.bind_label.as_ref()) {
|
||||
bind.request_body_read_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
bind.request_body_read_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_response_header_created(app: &App, elapsed: Duration) {
|
||||
let elapsed_ms = elapsed.as_millis() as u64;
|
||||
app.metrics
|
||||
.accepted_to_response_header_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
app.metrics
|
||||
.accepted_to_response_header_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
if let Some(bind) = app.metrics.bind_metrics(app.bind_label.as_ref()) {
|
||||
bind.accepted_to_response_header_sum_ms
|
||||
.fetch_add(elapsed_ms, Ordering::AcqRel);
|
||||
bind.accepted_to_response_header_max_ms
|
||||
.fetch_max(elapsed_ms, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn prometheus_label_value(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', r"\\")
|
||||
.replace('\n', r"\n")
|
||||
.replace('"', r#"\""#)
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut config = Config::default();
|
||||
let mut binds_overridden = false;
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--bind" => {
|
||||
if !binds_overridden {
|
||||
config.binds.clear();
|
||||
binds_overridden = true;
|
||||
}
|
||||
config.binds.push(next_value(&mut iter, "--bind")?.parse()?);
|
||||
}
|
||||
"--chunks" => config.chunks = next_value(&mut iter, "--chunks")?.parse()?,
|
||||
"--first-byte-delay-ms" => {
|
||||
config.first_byte_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--first-byte-delay-ms")?.parse()?)
|
||||
}
|
||||
"--chunk-delay-ms" => {
|
||||
config.chunk_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--chunk-delay-ms")?.parse()?)
|
||||
}
|
||||
"--payload-bytes" => {
|
||||
config.payload_bytes = next_value(&mut iter, "--payload-bytes")?.parse()?
|
||||
}
|
||||
"--status" => {
|
||||
let status = next_value(&mut iter, "--status")?.parse::<u16>()?;
|
||||
config.status = StatusCode::from_u16(status)?;
|
||||
}
|
||||
"--assume-stream" => {
|
||||
config.assume_stream = true;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.binds.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"at least one --bind is required",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin mock_openai_upstream -- [--bind 127.0.0.1:18181]... [--chunks 8] [--first-byte-delay-ms 0] [--chunk-delay-ms 20] [--payload-bytes 32] [--status 200] [--assume-stream]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
|
||||
use aether_runtime_state::{
|
||||
RedisClientConfig, RuntimeSemaphore, RuntimeSemaphoreConfig, RuntimeState,
|
||||
};
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, run_multi_url_http_load_probe, BenchmarkRuntimeSampler,
|
||||
BenchmarkRuntimeSnapshot, ExecutionRuntimeHarness, ExecutionRuntimeHarnessConfig,
|
||||
GatewayHarness, GatewayHarnessConfig, HttpLoadProbeConfig, HttpLoadProbeResponseMode,
|
||||
ManagedRedisServer, MultiUrlHttpLoadProbeResult, SpawnedServer, TunnelHarness,
|
||||
TunnelHarnessConfig, GATEWAY_HARNESS_API_KEY,
|
||||
};
|
||||
use axum::body::to_bytes;
|
||||
use axum::extract::Request;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::any;
|
||||
use axum::{Json, Router};
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MultiInstanceAdmissionBaselineConfig {
|
||||
gateway_requests: usize,
|
||||
gateway_concurrency: usize,
|
||||
execution_runtime_requests: usize,
|
||||
execution_runtime_concurrency: usize,
|
||||
tunnel_attempts: usize,
|
||||
tunnel_concurrency: usize,
|
||||
tunnel_hold: Duration,
|
||||
upstream_delay: Duration,
|
||||
request_limit: usize,
|
||||
tunnel_request_limit: usize,
|
||||
timeout: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
redis_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MultiInstanceAdmissionBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gateway_requests: 200,
|
||||
gateway_concurrency: 20,
|
||||
execution_runtime_requests: 200,
|
||||
execution_runtime_concurrency: 20,
|
||||
tunnel_attempts: 40,
|
||||
tunnel_concurrency: 10,
|
||||
tunnel_hold: Duration::from_millis(100),
|
||||
upstream_delay: Duration::from_millis(100),
|
||||
request_limit: 8,
|
||||
tunnel_request_limit: 4,
|
||||
timeout: Duration::from_secs(10),
|
||||
output_path: None,
|
||||
redis_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MultiInstanceAdmissionBaselineReport {
|
||||
suite: &'static str,
|
||||
redis_url: String,
|
||||
gateway_sync: MultiUrlHttpLoadProbeResult,
|
||||
execution_runtime_sync: MultiUrlHttpLoadProbeResult,
|
||||
tunnel_proxy: WebSocketAdmissionProbeResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WebSocketAdmissionProbeResult {
|
||||
target_urls: Vec<String>,
|
||||
target_attempt_counts: BTreeMap<String, usize>,
|
||||
total_attempts: usize,
|
||||
concurrency: usize,
|
||||
completed_attempts: usize,
|
||||
failed_attempts: usize,
|
||||
rejected_attempts: usize,
|
||||
successful_attempts: usize,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
p99_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
status_counts: BTreeMap<u16, usize>,
|
||||
runtime: BenchmarkRuntimeSnapshot,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("multi-instance-admission-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> Result<MultiInstanceAdmissionBaselineReport, Box<dyn std::error::Error>> {
|
||||
let managed_redis = if config.redis_url.is_none() {
|
||||
Some(ManagedRedisServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let redis_url = config
|
||||
.redis_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_redis
|
||||
.as_ref()
|
||||
.map(|server| server.redis_url().to_string())
|
||||
})
|
||||
.expect("redis url should be resolved");
|
||||
|
||||
let upstream = SpawnedServer::start(build_delayed_upstream(config.upstream_delay)).await?;
|
||||
|
||||
let (gateway_urls, _gateways) =
|
||||
start_gateway_pair(&redis_url, upstream.base_url(), config).await?;
|
||||
let (execution_runtime_urls, _runtimes) =
|
||||
start_execution_runtime_pair(&redis_url, upstream.base_url(), config).await?;
|
||||
let (tunnel_urls, _tunnels) = start_tunnel_pair(&redis_url, config).await?;
|
||||
|
||||
let gateway_sync = run_multi_url_http_load_probe(
|
||||
&gateway_sync_probe_config(&gateway_urls, config),
|
||||
&gateway_urls,
|
||||
)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let execution_runtime_sync = run_multi_url_http_load_probe(
|
||||
&execution_runtime_sync_probe_config(&execution_runtime_urls, upstream.base_url(), config),
|
||||
&execution_runtime_urls,
|
||||
)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let tunnel_proxy = run_tunnel_proxy_connection_probe(&tunnel_urls, config)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(MultiInstanceAdmissionBaselineReport {
|
||||
suite: "multi_instance_admission_baseline",
|
||||
redis_url,
|
||||
gateway_sync,
|
||||
execution_runtime_sync,
|
||||
tunnel_proxy,
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_gateway_pair(
|
||||
redis_url: &str,
|
||||
upstream_base_url: &str,
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> Result<(Vec<String>, Vec<GatewayHarness>), Box<dyn std::error::Error>> {
|
||||
let gate_a = distributed_request_gate(
|
||||
"gateway_requests_distributed",
|
||||
config.request_limit,
|
||||
redis_url,
|
||||
"gateway-a",
|
||||
)
|
||||
.await?;
|
||||
let gate_b = distributed_request_gate(
|
||||
"gateway_requests_distributed",
|
||||
config.request_limit,
|
||||
redis_url,
|
||||
"gateway-b",
|
||||
)
|
||||
.await?;
|
||||
let gateway_a = GatewayHarness::start(GatewayHarnessConfig {
|
||||
upstream_base_url: upstream_base_url.to_string(),
|
||||
data_config: None,
|
||||
max_in_flight_requests: None,
|
||||
distributed_request_gate: Some(gate_a),
|
||||
tunnel_instance_id: None,
|
||||
tunnel_relay_base_url: None,
|
||||
})
|
||||
.await?;
|
||||
let gateway_b = GatewayHarness::start(GatewayHarnessConfig {
|
||||
upstream_base_url: upstream_base_url.to_string(),
|
||||
data_config: None,
|
||||
max_in_flight_requests: None,
|
||||
distributed_request_gate: Some(gate_b),
|
||||
tunnel_instance_id: None,
|
||||
tunnel_relay_base_url: None,
|
||||
})
|
||||
.await?;
|
||||
Ok((
|
||||
vec![
|
||||
format!("{}/v1/chat/completions", gateway_a.base_url()),
|
||||
format!("{}/v1/chat/completions", gateway_b.base_url()),
|
||||
],
|
||||
vec![gateway_a, gateway_b],
|
||||
))
|
||||
}
|
||||
|
||||
async fn start_execution_runtime_pair(
|
||||
redis_url: &str,
|
||||
upstream_base_url: &str,
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> Result<(Vec<String>, Vec<ExecutionRuntimeHarness>), Box<dyn std::error::Error>> {
|
||||
let gate_a = distributed_request_gate(
|
||||
"execution_runtime_requests_distributed",
|
||||
config.request_limit,
|
||||
redis_url,
|
||||
"execution-runtime-a",
|
||||
)
|
||||
.await?;
|
||||
let gate_b = distributed_request_gate(
|
||||
"execution_runtime_requests_distributed",
|
||||
config.request_limit,
|
||||
redis_url,
|
||||
"execution-runtime-b",
|
||||
)
|
||||
.await?;
|
||||
let runtime_a = ExecutionRuntimeHarness::start(ExecutionRuntimeHarnessConfig {
|
||||
max_in_flight_requests: None,
|
||||
distributed_request_gate: Some(gate_a),
|
||||
})
|
||||
.await?;
|
||||
let runtime_b = ExecutionRuntimeHarness::start(ExecutionRuntimeHarnessConfig {
|
||||
max_in_flight_requests: None,
|
||||
distributed_request_gate: Some(gate_b),
|
||||
})
|
||||
.await?;
|
||||
let _ = upstream_base_url;
|
||||
Ok((
|
||||
vec![
|
||||
format!("{}/v1/execute/sync", runtime_a.base_url()),
|
||||
format!("{}/v1/execute/sync", runtime_b.base_url()),
|
||||
],
|
||||
vec![runtime_a, runtime_b],
|
||||
))
|
||||
}
|
||||
|
||||
async fn start_tunnel_pair(
|
||||
redis_url: &str,
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> Result<(Vec<String>, Vec<TunnelHarness>), Box<dyn std::error::Error>> {
|
||||
let gate_a = distributed_request_gate(
|
||||
"tunnel_requests_distributed",
|
||||
config.tunnel_request_limit,
|
||||
redis_url,
|
||||
"tunnel-a",
|
||||
)
|
||||
.await?;
|
||||
let gate_b = distributed_request_gate(
|
||||
"tunnel_requests_distributed",
|
||||
config.tunnel_request_limit,
|
||||
redis_url,
|
||||
"tunnel-b",
|
||||
)
|
||||
.await?;
|
||||
let tunnel_a = TunnelHarness::start(TunnelHarnessConfig {
|
||||
distributed_request_gate: Some(gate_a),
|
||||
..TunnelHarnessConfig::default()
|
||||
})
|
||||
.await?;
|
||||
let tunnel_b = TunnelHarness::start(TunnelHarnessConfig {
|
||||
distributed_request_gate: Some(gate_b),
|
||||
..TunnelHarnessConfig::default()
|
||||
})
|
||||
.await?;
|
||||
Ok((
|
||||
vec![
|
||||
format!(
|
||||
"{}{}",
|
||||
tunnel_a.base_url().replace("http://", "ws://"),
|
||||
PROXY_TUNNEL_PATH
|
||||
),
|
||||
format!(
|
||||
"{}{}",
|
||||
tunnel_b.base_url().replace("http://", "ws://"),
|
||||
PROXY_TUNNEL_PATH
|
||||
),
|
||||
],
|
||||
vec![tunnel_a, tunnel_b],
|
||||
))
|
||||
}
|
||||
|
||||
async fn distributed_request_gate(
|
||||
name: &'static str,
|
||||
limit: usize,
|
||||
redis_url: &str,
|
||||
_instance_id: &str,
|
||||
) -> Result<RuntimeSemaphore, Box<dyn std::error::Error>> {
|
||||
let runtime = RuntimeState::redis(
|
||||
RedisClientConfig {
|
||||
url: redis_url.to_string(),
|
||||
key_prefix: Some(format!("aether-baseline-{}-{name}", std::process::id())),
|
||||
},
|
||||
Some(1_000),
|
||||
)
|
||||
.await?;
|
||||
Ok(runtime.semaphore(
|
||||
name,
|
||||
limit,
|
||||
RuntimeSemaphoreConfig {
|
||||
lease_ttl_ms: 30_000,
|
||||
renew_interval_ms: 10_000,
|
||||
command_timeout_ms: Some(1_000),
|
||||
},
|
||||
)?)
|
||||
}
|
||||
|
||||
fn gateway_sync_probe_config(
|
||||
urls: &[String],
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> HttpLoadProbeConfig {
|
||||
let mut probe = chat_probe_config(
|
||||
urls[0].clone(),
|
||||
config.gateway_requests,
|
||||
config.gateway_concurrency,
|
||||
config.timeout,
|
||||
);
|
||||
probe.response_mode = HttpLoadProbeResponseMode::FullBody;
|
||||
probe
|
||||
}
|
||||
|
||||
fn execution_runtime_sync_probe_config(
|
||||
urls: &[String],
|
||||
upstream_base_url: &str,
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> HttpLoadProbeConfig {
|
||||
let _ = urls;
|
||||
HttpLoadProbeConfig {
|
||||
url: urls[0].clone(),
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
body: Some(
|
||||
serde_json::to_vec(&execution_plan(format!(
|
||||
"{upstream_base_url}/v1/chat/completions"
|
||||
)))
|
||||
.expect("execution plan should serialize"),
|
||||
),
|
||||
total_requests: config.execution_runtime_requests,
|
||||
concurrency: config.execution_runtime_concurrency,
|
||||
timeout: config.timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_probe_config(
|
||||
url: String,
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
timeout: Duration,
|
||||
) -> HttpLoadProbeConfig {
|
||||
HttpLoadProbeConfig {
|
||||
url,
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
(
|
||||
"authorization".to_string(),
|
||||
format!("Bearer {GATEWAY_HARNESS_API_KEY}"),
|
||||
),
|
||||
]),
|
||||
body: Some(
|
||||
serde_json::to_vec(&json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": false,
|
||||
}))
|
||||
.expect("chat body should serialize"),
|
||||
),
|
||||
total_requests,
|
||||
concurrency,
|
||||
timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_plan(url: String) -> ExecutionPlan {
|
||||
ExecutionPlan {
|
||||
request_id: "multi-instance-sync-request".to_string(),
|
||||
candidate_id: Some("multi-instance-sync-candidate".to_string()),
|
||||
provider_name: Some("openai".to_string()),
|
||||
provider_id: "provider-baseline".to_string(),
|
||||
endpoint_id: "endpoint-baseline".to_string(),
|
||||
key_id: "key-baseline".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": false,
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
model_name: Some("gpt-5".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(2_000),
|
||||
read_ms: Some(10_000),
|
||||
first_byte_ms: Some(5_000),
|
||||
total_ms: Some(10_000),
|
||||
..ExecutionTimeouts::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_delayed_upstream(delay: Duration) -> Router {
|
||||
Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let delay = delay;
|
||||
async move {
|
||||
let (_parts, body) = request.into_parts();
|
||||
let raw_body = to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.expect("fake upstream body should read");
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&raw_body).unwrap_or_else(|_| json!({}));
|
||||
tokio::time::sleep(delay).await;
|
||||
Json(json!({
|
||||
"id": "chatcmpl-distributed",
|
||||
"object": "chat.completion",
|
||||
"model": payload.get("model").and_then(|value| value.as_str()).unwrap_or("gpt-5"),
|
||||
"choices": [{"message": {"role": "assistant", "content": "hello"}}]
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_tunnel_proxy_connection_probe(
|
||||
urls: &[String],
|
||||
config: &MultiInstanceAdmissionBaselineConfig,
|
||||
) -> Result<WebSocketAdmissionProbeResult, String> {
|
||||
if urls.is_empty() {
|
||||
return Err("tunnel proxy connection probe requires at least one target url".to_string());
|
||||
}
|
||||
let mut runtime_sampler = BenchmarkRuntimeSampler::new();
|
||||
let next_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let latencies_ms = Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(
|
||||
config.tunnel_attempts,
|
||||
)));
|
||||
let target_attempt_counts = Arc::new(tokio::sync::Mutex::new(BTreeMap::<String, usize>::new()));
|
||||
let status_counts = Arc::new(tokio::sync::Mutex::new(BTreeMap::<u16, usize>::new()));
|
||||
let failed_attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let rejected_attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let successful_attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let completed_attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let mut workers = tokio::task::JoinSet::new();
|
||||
for worker_index in 0..config.tunnel_concurrency {
|
||||
let urls = urls.to_vec();
|
||||
let next_attempt = Arc::clone(&next_attempt);
|
||||
let latencies_ms = Arc::clone(&latencies_ms);
|
||||
let target_attempt_counts = Arc::clone(&target_attempt_counts);
|
||||
let status_counts = Arc::clone(&status_counts);
|
||||
let failed_attempts = Arc::clone(&failed_attempts);
|
||||
let rejected_attempts = Arc::clone(&rejected_attempts);
|
||||
let successful_attempts = Arc::clone(&successful_attempts);
|
||||
let completed_attempts = Arc::clone(&completed_attempts);
|
||||
let timeout = config.timeout;
|
||||
let hold = config.tunnel_hold;
|
||||
let total_attempts = config.tunnel_attempts;
|
||||
|
||||
workers.spawn(async move {
|
||||
loop {
|
||||
let current = next_attempt.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
if current >= total_attempts {
|
||||
break;
|
||||
}
|
||||
let url = urls[current % urls.len()].clone();
|
||||
{
|
||||
let mut counts = target_attempt_counts.lock().await;
|
||||
*counts.entry(url.clone()).or_insert(0) += 1;
|
||||
}
|
||||
let mut request = url
|
||||
.into_client_request()
|
||||
.map_err(|err| format!("failed to build websocket request: {err}"))?;
|
||||
request.headers_mut().insert(
|
||||
"x-node-id",
|
||||
format!("baseline-node-{worker_index}-{current}")
|
||||
.parse()
|
||||
.map_err(|err| format!("failed to build x-node-id header: {err}"))?,
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||
aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION_STR
|
||||
.parse()
|
||||
.expect("protocol version header value should be valid"),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-node-name",
|
||||
format!("baseline-node-{worker_index}-{current}")
|
||||
.parse()
|
||||
.map_err(|err| format!("failed to build x-node-name header: {err}"))?,
|
||||
);
|
||||
|
||||
let started_at = Instant::now();
|
||||
match tokio::time::timeout(timeout, tokio_tungstenite::connect_async(request)).await
|
||||
{
|
||||
Ok(Ok((mut ws, _response))) => {
|
||||
{
|
||||
let mut counts = status_counts.lock().await;
|
||||
*counts.entry(101).or_insert(0) += 1;
|
||||
}
|
||||
latencies_ms
|
||||
.lock()
|
||||
.await
|
||||
.push(started_at.elapsed().as_millis() as u64);
|
||||
successful_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
completed_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
tokio::time::sleep(hold).await;
|
||||
let _ = ws.close(None).await;
|
||||
while let Some(message) = ws.next().await {
|
||||
if matches!(message, Ok(Message::Close(_))) || message.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(tokio_tungstenite::tungstenite::Error::Http(response))) => {
|
||||
let status = response.status().as_u16();
|
||||
{
|
||||
let mut counts = status_counts.lock().await;
|
||||
*counts.entry(status).or_insert(0) += 1;
|
||||
}
|
||||
latencies_ms
|
||||
.lock()
|
||||
.await
|
||||
.push(started_at.elapsed().as_millis() as u64);
|
||||
if status == 503 {
|
||||
rejected_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
} else {
|
||||
failed_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
completed_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
latencies_ms
|
||||
.lock()
|
||||
.await
|
||||
.push(started_at.elapsed().as_millis() as u64);
|
||||
failed_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
completed_attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = workers.join_next().await {
|
||||
result
|
||||
.map_err(|err| format!("tunnel admission worker task failed: {err}"))?
|
||||
.map_err(|err| format!("tunnel admission worker failed: {err}"))?;
|
||||
}
|
||||
|
||||
let mut latencies = latencies_ms.lock().await.clone();
|
||||
latencies.sort_unstable();
|
||||
let (p50_ms, p95_ms, p99_ms, max_ms, mean_ms) = summarize_latencies(&latencies);
|
||||
|
||||
let target_attempt_counts = target_attempt_counts.lock().await.clone();
|
||||
let status_counts = status_counts.lock().await.clone();
|
||||
Ok(WebSocketAdmissionProbeResult {
|
||||
target_urls: urls.to_vec(),
|
||||
target_attempt_counts,
|
||||
total_attempts: config.tunnel_attempts,
|
||||
concurrency: config.tunnel_concurrency,
|
||||
completed_attempts: completed_attempts.load(std::sync::atomic::Ordering::Acquire),
|
||||
failed_attempts: failed_attempts.load(std::sync::atomic::Ordering::Acquire),
|
||||
rejected_attempts: rejected_attempts.load(std::sync::atomic::Ordering::Acquire),
|
||||
successful_attempts: successful_attempts.load(std::sync::atomic::Ordering::Acquire),
|
||||
p50_ms,
|
||||
p95_ms,
|
||||
p99_ms,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
status_counts,
|
||||
runtime: runtime_sampler.snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
fn summarize_latencies(latencies: &[u64]) -> (u64, u64, u64, u64, u64) {
|
||||
if latencies.is_empty() {
|
||||
return (0, 0, 0, 0, 0);
|
||||
}
|
||||
let max_ms = *latencies.last().unwrap_or(&0);
|
||||
let mean_ms = latencies.iter().sum::<u64>() / latencies.len() as u64;
|
||||
let p50_ms = percentile(latencies, 50);
|
||||
let p95_ms = percentile(latencies, 95);
|
||||
let p99_ms = percentile(latencies, 99);
|
||||
(p50_ms, p95_ms, p99_ms, max_ms, mean_ms)
|
||||
}
|
||||
|
||||
fn percentile(latencies: &[u64], percentile: u8) -> u64 {
|
||||
if latencies.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let last_index = latencies.len() - 1;
|
||||
let rank = ((last_index as f64) * (percentile as f64 / 100.0)).round() as usize;
|
||||
latencies[rank.min(last_index)]
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<MultiInstanceAdmissionBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = MultiInstanceAdmissionBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--gateway-requests" => {
|
||||
config.gateway_requests = next_value(&mut iter, "--gateway-requests")?.parse()?
|
||||
}
|
||||
"--gateway-concurrency" => {
|
||||
config.gateway_concurrency =
|
||||
next_value(&mut iter, "--gateway-concurrency")?.parse()?
|
||||
}
|
||||
"--execution-runtime-requests" | "--executor-requests" => {
|
||||
config.execution_runtime_requests = next_value(&mut iter, arg.as_str())?.parse()?
|
||||
}
|
||||
"--execution-runtime-concurrency" | "--executor-concurrency" => {
|
||||
config.execution_runtime_concurrency =
|
||||
next_value(&mut iter, arg.as_str())?.parse()?
|
||||
}
|
||||
"--tunnel-attempts" => {
|
||||
config.tunnel_attempts = next_value(&mut iter, "--tunnel-attempts")?.parse()?
|
||||
}
|
||||
"--tunnel-concurrency" => {
|
||||
config.tunnel_concurrency =
|
||||
next_value(&mut iter, "--tunnel-concurrency")?.parse()?
|
||||
}
|
||||
"--tunnel-hold-ms" => {
|
||||
config.tunnel_hold =
|
||||
Duration::from_millis(next_value(&mut iter, "--tunnel-hold-ms")?.parse()?)
|
||||
}
|
||||
"--upstream-delay-ms" => {
|
||||
config.upstream_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--upstream-delay-ms")?.parse()?)
|
||||
}
|
||||
"--request-limit" => {
|
||||
config.request_limit = next_value(&mut iter, "--request-limit")?.parse()?
|
||||
}
|
||||
"--tunnel-request-limit" => {
|
||||
config.tunnel_request_limit =
|
||||
next_value(&mut iter, "--tunnel-request-limit")?.parse()?
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout =
|
||||
Duration::from_millis(next_value(&mut iter, "--timeout-ms")?.parse()?)
|
||||
}
|
||||
"--redis-url" => config.redis_url = Some(next_value(&mut iter, "--redis-url")?),
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin multi_instance_admission_baseline -- [--gateway-requests 200] [--gateway-concurrency 20] [--execution-runtime-requests 200] [--execution-runtime-concurrency 20] [--tunnel-attempts 40] [--tunnel-concurrency 10] [--tunnel-hold-ms 100] [--upstream-delay-ms 100] [--request-limit 8] [--tunnel-request-limit 4] [--redis-url redis://127.0.0.1:6379/0] [--output /tmp/multi_instance_admission_baseline.json]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_gateway::tunnel_protocol as protocol;
|
||||
use aether_gateway::GatewayDataConfig;
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, prepare_aether_postgres_schema, reserve_local_port, run_http_load_probe,
|
||||
wait_until, GatewayHarness, GatewayHarnessConfig, HttpLoadProbeConfig,
|
||||
HttpLoadProbeResponseMode, HttpLoadProbeResult, ManagedPostgresServer, ManagedRedisServer,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const PROXY_TUNNEL_PATH: &str = "/api/internal/proxy-tunnel";
|
||||
const TUNNEL_RELAY_PATH_PREFIX: &str = "/api/internal/tunnel/relay";
|
||||
const NODE_ID: &str = "node-owner-relay-baseline";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MultiInstanceOwnerRelayBaselineConfig {
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
timeout: Duration,
|
||||
chunk_delay: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
redis_url: Option<String>,
|
||||
postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MultiInstanceOwnerRelayBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_requests: 200,
|
||||
concurrency: 20,
|
||||
timeout: Duration::from_secs(10),
|
||||
chunk_delay: Duration::ZERO,
|
||||
output_path: None,
|
||||
redis_url: None,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MultiInstanceOwnerRelayBaselineReport {
|
||||
suite: &'static str,
|
||||
redis_url: String,
|
||||
postgres_url: String,
|
||||
owner_instance_id: &'static str,
|
||||
forwarder_instance_id: &'static str,
|
||||
direct_owner_relay: HttpLoadProbeResult,
|
||||
remote_owner_relay: HttpLoadProbeResult,
|
||||
relay_overhead_ms: RelayOverheadSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RelayOverheadSnapshot {
|
||||
p50_delta_ms: i64,
|
||||
p95_delta_ms: i64,
|
||||
max_delta_ms: i64,
|
||||
mean_delta_ms: i64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("multi-instance-owner-relay-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &MultiInstanceOwnerRelayBaselineConfig,
|
||||
) -> Result<MultiInstanceOwnerRelayBaselineReport, Box<dyn std::error::Error>> {
|
||||
let managed_redis = if config.redis_url.is_none() {
|
||||
Some(ManagedRedisServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let redis_url = config
|
||||
.redis_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_redis
|
||||
.as_ref()
|
||||
.map(|server| server.redis_url().to_string())
|
||||
})
|
||||
.expect("redis url should be resolved");
|
||||
|
||||
let managed_postgres = if config.postgres_url.is_none() {
|
||||
Some(ManagedPostgresServer::start().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let postgres_url = config
|
||||
.postgres_url
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
managed_postgres
|
||||
.as_ref()
|
||||
.map(|server| server.database_url().to_string())
|
||||
})
|
||||
.expect("postgres url should be resolved");
|
||||
|
||||
prepare_aether_postgres_schema(&postgres_url).await?;
|
||||
|
||||
let key_prefix = format!("aether-owner-relay-baseline-{}", std::process::id());
|
||||
let shared_data = GatewayDataConfig::from_postgres_url(postgres_url.clone(), false)
|
||||
.with_redis_url(redis_url.clone(), Some(key_prefix));
|
||||
|
||||
let owner_port = reserve_local_port()?;
|
||||
let forwarder_port = reserve_local_port()?;
|
||||
let owner_base_url = format!("http://127.0.0.1:{owner_port}");
|
||||
let forwarder_base_url = format!("http://127.0.0.1:{forwarder_port}");
|
||||
|
||||
let owner_gateway = GatewayHarness::start_on_port(
|
||||
GatewayHarnessConfig {
|
||||
upstream_base_url: "http://127.0.0.1:1".to_string(),
|
||||
data_config: Some(shared_data.clone()),
|
||||
max_in_flight_requests: None,
|
||||
distributed_request_gate: None,
|
||||
tunnel_instance_id: Some("gateway-owner".to_string()),
|
||||
tunnel_relay_base_url: Some(owner_base_url.clone()),
|
||||
},
|
||||
owner_port,
|
||||
)
|
||||
.await?;
|
||||
let forwarder_gateway = GatewayHarness::start_on_port(
|
||||
GatewayHarnessConfig {
|
||||
upstream_base_url: "http://127.0.0.1:1".to_string(),
|
||||
data_config: Some(shared_data),
|
||||
max_in_flight_requests: None,
|
||||
distributed_request_gate: None,
|
||||
tunnel_instance_id: Some("gateway-forwarder".to_string()),
|
||||
tunnel_relay_base_url: Some(forwarder_base_url.clone()),
|
||||
},
|
||||
forwarder_port,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let peer = connect_protocol_peer(owner_gateway.base_url(), config.chunk_delay).await?;
|
||||
|
||||
wait_for_owner_attachment(&forwarder_base_url).await?;
|
||||
|
||||
let direct_owner_relay = run_http_load_probe(&HttpLoadProbeConfig {
|
||||
url: format!(
|
||||
"{owner_base}{TUNNEL_RELAY_PATH_PREFIX}/{NODE_ID}",
|
||||
owner_base = owner_gateway.base_url()
|
||||
),
|
||||
method: Method::POST,
|
||||
headers: relay_headers(),
|
||||
body: Some(relay_envelope()),
|
||||
total_requests: config.total_requests,
|
||||
concurrency: config.concurrency,
|
||||
timeout: config.timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let remote_owner_relay = run_http_load_probe(&HttpLoadProbeConfig {
|
||||
url: format!(
|
||||
"{forwarder_base}{TUNNEL_RELAY_PATH_PREFIX}/{NODE_ID}",
|
||||
forwarder_base = forwarder_gateway.base_url()
|
||||
),
|
||||
method: Method::POST,
|
||||
headers: relay_headers(),
|
||||
body: Some(relay_envelope()),
|
||||
total_requests: config.total_requests,
|
||||
concurrency: config.concurrency,
|
||||
timeout: config.timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
drop(peer);
|
||||
drop(forwarder_gateway);
|
||||
drop(owner_gateway);
|
||||
|
||||
Ok(MultiInstanceOwnerRelayBaselineReport {
|
||||
suite: "multi_instance_owner_relay_baseline",
|
||||
redis_url,
|
||||
postgres_url,
|
||||
owner_instance_id: "gateway-owner",
|
||||
forwarder_instance_id: "gateway-forwarder",
|
||||
relay_overhead_ms: RelayOverheadSnapshot {
|
||||
p50_delta_ms: remote_owner_relay.p50_ms as i64 - direct_owner_relay.p50_ms as i64,
|
||||
p95_delta_ms: remote_owner_relay.p95_ms as i64 - direct_owner_relay.p95_ms as i64,
|
||||
max_delta_ms: remote_owner_relay.max_ms as i64 - direct_owner_relay.max_ms as i64,
|
||||
mean_delta_ms: remote_owner_relay.mean_ms as i64 - direct_owner_relay.mean_ms as i64,
|
||||
},
|
||||
direct_owner_relay,
|
||||
remote_owner_relay,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_owner_attachment(forwarder_base_url: &str) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.map_err(|err| format!("failed to build readiness client: {err}"))?;
|
||||
let target_url = format!("{forwarder_base_url}{TUNNEL_RELAY_PATH_PREFIX}/{NODE_ID}");
|
||||
let ready = wait_until(Duration::from_secs(10), Duration::from_millis(100), || {
|
||||
let client = client.clone();
|
||||
let target_url = target_url.clone();
|
||||
async move {
|
||||
let response = client
|
||||
.post(target_url)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.body(relay_envelope())
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => match response.text().await {
|
||||
Ok(body) => body == "owner-relay-ok",
|
||||
Err(_) => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if ready {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("timed out waiting for owner attachment propagation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_headers() -> std::collections::BTreeMap<String, String> {
|
||||
std::collections::BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/octet-stream".to_string(),
|
||||
)])
|
||||
}
|
||||
|
||||
fn relay_envelope() -> Vec<u8> {
|
||||
let meta = protocol::RequestMeta {
|
||||
method: "POST".to_string(),
|
||||
url: "https://owner-relay.example/v1/chat/completions".to_string(),
|
||||
headers: std::collections::HashMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
stream: false,
|
||||
request_timeout_ms: None,
|
||||
stream_first_byte_timeout_ms: None,
|
||||
timeout: 30,
|
||||
follow_redirects: None,
|
||||
http1_only: false,
|
||||
provider_id: None,
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
transport_profile: None,
|
||||
};
|
||||
let meta_json = serde_json::to_vec(&meta).expect("owner relay metadata should serialize");
|
||||
let body = br#"{"model":"gpt-5","messages":[{"role":"user","content":"owner relay"}]}"#;
|
||||
let mut envelope = Vec::with_capacity(4 + meta_json.len() + body.len());
|
||||
envelope.extend_from_slice(&(meta_json.len() as u32).to_be_bytes());
|
||||
envelope.extend_from_slice(&meta_json);
|
||||
envelope.extend_from_slice(body);
|
||||
envelope
|
||||
}
|
||||
|
||||
async fn connect_protocol_peer(
|
||||
gateway_base_url: &str,
|
||||
chunk_delay: Duration,
|
||||
) -> Result<tokio::task::JoinHandle<()>, Box<dyn std::error::Error>> {
|
||||
let ws_url = format!(
|
||||
"{}{}",
|
||||
gateway_base_url.replace("http://", "ws://"),
|
||||
PROXY_TUNNEL_PATH
|
||||
);
|
||||
let request = ws_url.into_client_request()?;
|
||||
let mut request = request;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-node-id", http::HeaderValue::from_static(NODE_ID));
|
||||
request.headers_mut().insert(
|
||||
aether_contracts::tunnel::TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||
http::HeaderValue::from_static(
|
||||
aether_contracts::tunnel::CURRENT_TUNNEL_PROTOCOL_VERSION_STR,
|
||||
),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-node-name",
|
||||
http::HeaderValue::from_static("proxy-owner-relay-baseline"),
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"x-tunnel-max-streams",
|
||||
http::HeaderValue::from_static("256"),
|
||||
);
|
||||
|
||||
let (socket, _response) = tokio_tungstenite::connect_async(request).await?;
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
Ok(tokio::spawn(async move {
|
||||
while let Some(message) = stream.next().await {
|
||||
let Ok(message) = message else {
|
||||
break;
|
||||
};
|
||||
match message {
|
||||
Message::Binary(data)
|
||||
if handle_binary_frame(&mut sink, data.to_vec(), chunk_delay)
|
||||
.await
|
||||
.is_err() =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Message::Ping(payload)
|
||||
if sink.send(Message::Pong(payload.clone())).await.is_err() =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = sink.close().await;
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_binary_frame<S>(
|
||||
sink: &mut S,
|
||||
data: Vec<u8>,
|
||||
chunk_delay: Duration,
|
||||
) -> Result<(), tokio_tungstenite::tungstenite::Error>
|
||||
where
|
||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
let Some(header) = protocol::FrameHeader::parse(&data) else {
|
||||
return Ok(());
|
||||
};
|
||||
match header.msg_type {
|
||||
protocol::PING => {
|
||||
let payload = protocol::frame_payload_by_header(&data, &header).unwrap_or(&[]);
|
||||
sink.send(Message::Binary(protocol::encode_pong(payload).into()))
|
||||
.await?;
|
||||
}
|
||||
protocol::REQUEST_HEADERS => {
|
||||
let payload = protocol::decode_payload(&data, &header).unwrap_or_default();
|
||||
let _ = serde_json::from_slice::<protocol::RequestMeta>(&payload);
|
||||
}
|
||||
protocol::REQUEST_BODY if header.flags & protocol::FLAG_END_STREAM != 0 => {
|
||||
let response_meta = protocol::ResponseMeta {
|
||||
status: 200,
|
||||
headers: vec![(
|
||||
"content-type".to_string(),
|
||||
"text/plain; charset=utf-8".to_string(),
|
||||
)],
|
||||
};
|
||||
let response_meta_json =
|
||||
serde_json::to_vec(&response_meta).expect("response metadata should serialize");
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(
|
||||
header.stream_id,
|
||||
protocol::RESPONSE_HEADERS,
|
||||
0,
|
||||
&response_meta_json,
|
||||
)
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
for chunk in [b"owner-".as_slice(), b"relay-".as_slice(), b"ok".as_slice()] {
|
||||
if !chunk_delay.is_zero() {
|
||||
tokio::time::sleep(chunk_delay).await;
|
||||
}
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(header.stream_id, protocol::RESPONSE_BODY, 0, chunk)
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
sink.send(Message::Binary(
|
||||
protocol::encode_frame(header.stream_id, protocol::STREAM_END, 0, &[]).into(),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<MultiInstanceOwnerRelayBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = MultiInstanceOwnerRelayBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--requests" => config.total_requests = next_value(&mut iter, "--requests")?.parse()?,
|
||||
"--concurrency" => {
|
||||
config.concurrency = next_value(&mut iter, "--concurrency")?.parse()?
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout =
|
||||
Duration::from_millis(next_value(&mut iter, "--timeout-ms")?.parse()?)
|
||||
}
|
||||
"--chunk-delay-ms" => {
|
||||
config.chunk_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--chunk-delay-ms")?.parse()?)
|
||||
}
|
||||
"--redis-url" => config.redis_url = Some(next_value(&mut iter, "--redis-url")?),
|
||||
"--postgres-url" => {
|
||||
config.postgres_url = Some(next_value(&mut iter, "--postgres-url")?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.total_requests == 0 || config.concurrency == 0 || config.timeout.is_zero() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"owner relay baseline numeric settings must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
println!(
|
||||
"usage: cargo run -p aether-integration-tests --bin multi_instance_owner_relay_baseline -- [--requests 200] [--concurrency 20] [--timeout-ms 10000] [--chunk-delay-ms 0] [--redis-url redis://127.0.0.1:6379/0] [--postgres-url postgres://127.0.0.1:5432/postgres] [--output /tmp/multi_instance_owner_relay_baseline.json]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, run_http_load_probe, ExecutionRuntimeHarness,
|
||||
ExecutionRuntimeHarnessConfig, GatewayHarness, GatewayHarnessConfig, HttpLoadProbeConfig,
|
||||
HttpLoadProbeResponseMode, HttpLoadProbeResult, SpawnedServer, GATEWAY_HARNESS_API_KEY,
|
||||
};
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Json, Router};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SingleInstanceBaselineConfig {
|
||||
sync_requests: usize,
|
||||
sync_concurrency: usize,
|
||||
stream_requests: usize,
|
||||
stream_concurrency: usize,
|
||||
timeout: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for SingleInstanceBaselineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sync_requests: 200,
|
||||
sync_concurrency: 20,
|
||||
stream_requests: 100,
|
||||
stream_concurrency: 10,
|
||||
timeout: Duration::from_secs(10),
|
||||
output_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NamedBaselineResult {
|
||||
name: String,
|
||||
result: HttpLoadProbeResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SingleInstanceBaselineReport {
|
||||
suite: &'static str,
|
||||
scenarios: Vec<NamedBaselineResult>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("single-instance-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let report = run_suite(&config).await?;
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
config: &SingleInstanceBaselineConfig,
|
||||
) -> Result<SingleInstanceBaselineReport, Box<dyn std::error::Error>> {
|
||||
let upstream = SpawnedServer::start(build_fake_upstream()).await?;
|
||||
let gateway = GatewayHarness::start(GatewayHarnessConfig::new(upstream.base_url())).await?;
|
||||
let runtime = ExecutionRuntimeHarness::start(ExecutionRuntimeHarnessConfig::default()).await?;
|
||||
|
||||
let gateway_sync = run_http_load_probe(&gateway_sync_probe_config(gateway.base_url(), config))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let gateway_stream =
|
||||
run_http_load_probe(&gateway_stream_probe_config(gateway.base_url(), config))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let execution_runtime_sync = run_http_load_probe(&execution_runtime_sync_probe_config(
|
||||
runtime.base_url(),
|
||||
upstream.base_url(),
|
||||
config,
|
||||
))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let execution_runtime_stream = run_http_load_probe(&execution_runtime_stream_probe_config(
|
||||
runtime.base_url(),
|
||||
upstream.base_url(),
|
||||
config,
|
||||
))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(SingleInstanceBaselineReport {
|
||||
suite: "single_instance_baseline",
|
||||
scenarios: vec![
|
||||
NamedBaselineResult {
|
||||
name: "gateway_proxy_sync".to_string(),
|
||||
result: gateway_sync,
|
||||
},
|
||||
NamedBaselineResult {
|
||||
name: "gateway_proxy_stream".to_string(),
|
||||
result: gateway_stream,
|
||||
},
|
||||
NamedBaselineResult {
|
||||
name: "execution_runtime_sync".to_string(),
|
||||
result: execution_runtime_sync,
|
||||
},
|
||||
NamedBaselineResult {
|
||||
name: "execution_runtime_stream".to_string(),
|
||||
result: execution_runtime_stream,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn gateway_sync_probe_config(
|
||||
gateway_base_url: &str,
|
||||
config: &SingleInstanceBaselineConfig,
|
||||
) -> HttpLoadProbeConfig {
|
||||
let mut probe = chat_probe_config(
|
||||
format!("{gateway_base_url}/v1/chat/completions"),
|
||||
false,
|
||||
config.sync_requests,
|
||||
config.sync_concurrency,
|
||||
config.timeout,
|
||||
);
|
||||
probe.response_mode = HttpLoadProbeResponseMode::FullBody;
|
||||
probe
|
||||
}
|
||||
|
||||
fn gateway_stream_probe_config(
|
||||
gateway_base_url: &str,
|
||||
config: &SingleInstanceBaselineConfig,
|
||||
) -> HttpLoadProbeConfig {
|
||||
let mut probe = chat_probe_config(
|
||||
format!("{gateway_base_url}/v1/chat/completions"),
|
||||
true,
|
||||
config.stream_requests,
|
||||
config.stream_concurrency,
|
||||
config.timeout,
|
||||
);
|
||||
probe.response_mode = HttpLoadProbeResponseMode::FullBody;
|
||||
probe
|
||||
}
|
||||
|
||||
fn execution_runtime_sync_probe_config(
|
||||
runtime_base_url: &str,
|
||||
upstream_base_url: &str,
|
||||
config: &SingleInstanceBaselineConfig,
|
||||
) -> HttpLoadProbeConfig {
|
||||
execution_probe_config(
|
||||
format!("{runtime_base_url}/v1/execute/sync"),
|
||||
execution_plan(format!("{upstream_base_url}/v1/chat/completions"), false),
|
||||
config.sync_requests,
|
||||
config.sync_concurrency,
|
||||
config.timeout,
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_runtime_stream_probe_config(
|
||||
runtime_base_url: &str,
|
||||
upstream_base_url: &str,
|
||||
config: &SingleInstanceBaselineConfig,
|
||||
) -> HttpLoadProbeConfig {
|
||||
execution_probe_config(
|
||||
format!("{runtime_base_url}/v1/execute/stream"),
|
||||
execution_plan(format!("{upstream_base_url}/v1/chat/completions"), true),
|
||||
config.stream_requests,
|
||||
config.stream_concurrency,
|
||||
config.timeout,
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_probe_config(
|
||||
url: String,
|
||||
plan: ExecutionPlan,
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
timeout: Duration,
|
||||
) -> HttpLoadProbeConfig {
|
||||
HttpLoadProbeConfig {
|
||||
url,
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
body: Some(
|
||||
serde_json::to_vec(&plan).expect("execution plan should serialize for load probe"),
|
||||
),
|
||||
total_requests,
|
||||
concurrency,
|
||||
timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_probe_config(
|
||||
url: String,
|
||||
stream: bool,
|
||||
total_requests: usize,
|
||||
concurrency: usize,
|
||||
timeout: Duration,
|
||||
) -> HttpLoadProbeConfig {
|
||||
HttpLoadProbeConfig {
|
||||
url,
|
||||
method: Method::POST,
|
||||
headers: BTreeMap::from([
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
(
|
||||
"authorization".to_string(),
|
||||
format!("Bearer {GATEWAY_HARNESS_API_KEY}"),
|
||||
),
|
||||
]),
|
||||
body: Some(
|
||||
serde_json::to_vec(&json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": stream,
|
||||
}))
|
||||
.expect("chat body should serialize"),
|
||||
),
|
||||
total_requests,
|
||||
concurrency,
|
||||
timeout,
|
||||
response_mode: HttpLoadProbeResponseMode::FullBody,
|
||||
..HttpLoadProbeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_plan(url: String, stream: bool) -> ExecutionPlan {
|
||||
ExecutionPlan {
|
||||
request_id: if stream {
|
||||
"baseline-stream-request".to_string()
|
||||
} else {
|
||||
"baseline-sync-request".to_string()
|
||||
},
|
||||
candidate_id: Some(if stream {
|
||||
"baseline-stream-candidate".to_string()
|
||||
} else {
|
||||
"baseline-sync-candidate".to_string()
|
||||
}),
|
||||
provider_name: Some("openai".to_string()),
|
||||
provider_id: "provider-baseline".to_string(),
|
||||
endpoint_id: "endpoint-baseline".to_string(),
|
||||
key_id: "key-baseline".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": stream,
|
||||
})),
|
||||
stream,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
model_name: Some("gpt-5".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(2_000),
|
||||
read_ms: Some(10_000),
|
||||
first_byte_ms: Some(5_000),
|
||||
total_ms: Some(10_000),
|
||||
..ExecutionTimeouts::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fake_upstream() -> Router {
|
||||
Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
any(|request: Request| async move {
|
||||
let (_parts, body) = request.into_parts();
|
||||
let raw_body = to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.expect("fake upstream body should read");
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&raw_body).unwrap_or_else(|_| json!({}));
|
||||
let stream = payload
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
if stream {
|
||||
let body = futures_util::stream::iter([
|
||||
Ok::<_, Infallible>(Bytes::from_static(
|
||||
b"data: {\"id\":\"chunk-1\",\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n",
|
||||
)),
|
||||
Ok::<_, Infallible>(Bytes::from_static(
|
||||
b"data: {\"id\":\"chunk-2\",\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
|
||||
)),
|
||||
Ok::<_, Infallible>(Bytes::from_static(b"data: [DONE]\n\n")),
|
||||
]);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(http::header::CONTENT_TYPE, "text/event-stream")
|
||||
.body(Body::from_stream(body))
|
||||
.expect("fake upstream stream response should build")
|
||||
} else {
|
||||
Json(json!({
|
||||
"id": "chatcmpl-baseline",
|
||||
"object": "chat.completion",
|
||||
"model": payload.get("model").and_then(|value| value.as_str()).unwrap_or("gpt-5"),
|
||||
"choices": [{"message": {"role": "assistant", "content": "hello"}}]
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
args: Vec<String>,
|
||||
) -> Result<SingleInstanceBaselineConfig, Box<dyn std::error::Error>> {
|
||||
let mut config = SingleInstanceBaselineConfig::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--sync-requests" => {
|
||||
config.sync_requests = next_value(&mut iter, "--sync-requests")?.parse()?
|
||||
}
|
||||
"--sync-concurrency" => {
|
||||
config.sync_concurrency = next_value(&mut iter, "--sync-concurrency")?.parse()?
|
||||
}
|
||||
"--stream-requests" => {
|
||||
config.stream_requests = next_value(&mut iter, "--stream-requests")?.parse()?
|
||||
}
|
||||
"--stream-concurrency" => {
|
||||
config.stream_concurrency =
|
||||
next_value(&mut iter, "--stream-concurrency")?.parse()?
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
config.timeout =
|
||||
Duration::from_millis(next_value(&mut iter, "--timeout-ms")?.parse()?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin single_instance_baseline -- [--sync-requests 200] [--sync-concurrency 20] [--stream-requests 100] [--stream-concurrency 10] [--timeout-ms 10000] [--output /tmp/baseline.json]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_data::repository::usage::SqlxUsageReadRepository;
|
||||
use aether_data_contracts::repository::usage::{
|
||||
ApiKeyLastUsedDelta, ManagementTokenCounterDelta, ProxyNodeCounterDelta,
|
||||
};
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, prepare_aether_postgres_schema, ManagedPostgresServer,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const PROXY_NODE_ID: &str = "proxy-node-hotspot";
|
||||
const MANAGEMENT_TOKEN_ID: &str = "management-token-hotspot";
|
||||
const API_KEY_ID: &str = "api-key-last-used-hotspot";
|
||||
const USER_ID: &str = "usage-aux-hotspot-user";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
max_connections: u32,
|
||||
flush_batch_size: usize,
|
||||
flush_interval: Duration,
|
||||
monitor_interval: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: 2_000,
|
||||
concurrency: 100,
|
||||
max_connections: 64,
|
||||
flush_batch_size: 1_000,
|
||||
flush_interval: Duration::from_millis(100),
|
||||
monitor_interval: Duration::from_millis(100),
|
||||
output_path: None,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
suite: &'static str,
|
||||
config: ReportConfig,
|
||||
duration_ms: u64,
|
||||
throughput_rps: u64,
|
||||
completed_requests: usize,
|
||||
failed_requests: usize,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
flush: FlushReport,
|
||||
counters: CounterReport,
|
||||
lock_monitor: LockMonitorReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReportConfig {
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
max_connections: u32,
|
||||
flush_batch_size: usize,
|
||||
flush_interval_ms: u64,
|
||||
monitor_interval_ms: u64,
|
||||
managed_postgres: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct FlushReport {
|
||||
calls: usize,
|
||||
rows_claimed: usize,
|
||||
proxy_node_targets: usize,
|
||||
management_token_targets: usize,
|
||||
api_key_last_used_targets: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CounterReport {
|
||||
outbox_pending_rows: i64,
|
||||
outbox_processed_rows: i64,
|
||||
proxy_total_requests: i64,
|
||||
proxy_failed_requests: i64,
|
||||
proxy_dns_failures: i64,
|
||||
proxy_stream_errors: i64,
|
||||
expected_proxy_total_requests: i64,
|
||||
expected_proxy_failed_requests: i64,
|
||||
expected_proxy_dns_failures: i64,
|
||||
expected_proxy_stream_errors: i64,
|
||||
management_token_usage_count: i64,
|
||||
expected_management_token_usage_count: i64,
|
||||
api_key_last_used_at: Option<i64>,
|
||||
expected_min_api_key_last_used_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Copy, Default)]
|
||||
struct LockMonitorReport {
|
||||
samples: usize,
|
||||
max_lock_waiters: i64,
|
||||
max_proxy_node_update_waiters: i64,
|
||||
max_management_token_update_waiters: i64,
|
||||
max_api_key_update_waiters: i64,
|
||||
max_oldest_lock_wait_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct LockSample {
|
||||
lock_waiters: i64,
|
||||
proxy_node_update_waiters: i64,
|
||||
management_token_update_waiters: i64,
|
||||
api_key_update_waiters: i64,
|
||||
oldest_lock_wait_ms: i64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("usage-aux-counter-hotspot-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
|
||||
let managed_postgres;
|
||||
let database_url;
|
||||
let _server;
|
||||
if let Some(url) = config.postgres_url.as_ref() {
|
||||
managed_postgres = false;
|
||||
database_url = url.clone();
|
||||
_server = None;
|
||||
} else {
|
||||
managed_postgres = true;
|
||||
let server = ManagedPostgresServer::start().await?;
|
||||
database_url = server.database_url().to_string();
|
||||
_server = Some(server);
|
||||
}
|
||||
|
||||
prepare_aether_postgres_schema(&database_url).await?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.min_connections(1)
|
||||
.max_connections(config.max_connections)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
seed_hotspot_rows(&pool).await?;
|
||||
|
||||
let repository = SqlxUsageReadRepository::new(pool.clone());
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let flush_report = Arc::new(Mutex::new(FlushReport::default()));
|
||||
let lock_report = Arc::new(Mutex::new(LockMonitorReport::default()));
|
||||
|
||||
let flush_handle = spawn_flush_loop(
|
||||
repository.clone(),
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&flush_report),
|
||||
config.flush_batch_size,
|
||||
config.flush_interval,
|
||||
);
|
||||
let monitor_handle = spawn_lock_monitor(
|
||||
pool.clone(),
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&lock_report),
|
||||
config.monitor_interval,
|
||||
);
|
||||
|
||||
let started_at = Instant::now();
|
||||
let load_result =
|
||||
run_aux_counter_load(repository.clone(), config.requests, config.concurrency).await;
|
||||
wait_for_outbox_drain(&pool, &repository, config.flush_batch_size).await?;
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
|
||||
stop.store(true, Ordering::Release);
|
||||
flush_handle.await??;
|
||||
monitor_handle.await??;
|
||||
|
||||
let mut flush = flush_report.lock().await;
|
||||
let final_flush = repository
|
||||
.flush_usage_counter_deltas(config.flush_batch_size)
|
||||
.await?;
|
||||
flush.calls += 1;
|
||||
flush.rows_claimed += final_flush.rows_claimed;
|
||||
flush.proxy_node_targets += final_flush.proxy_node_targets;
|
||||
flush.management_token_targets += final_flush.management_token_targets;
|
||||
flush.api_key_last_used_targets += final_flush.api_key_last_used_targets;
|
||||
drop(flush);
|
||||
|
||||
let counters = read_counters(&pool, config.requests).await?;
|
||||
let latencies = load_result.latencies.lock().await.clone();
|
||||
let (p50_ms, p95_ms, max_ms, mean_ms) = summarize_latencies(latencies);
|
||||
let completed_requests = load_result.completed.load(Ordering::Acquire);
|
||||
let throughput_rps = if duration_ms == 0 {
|
||||
completed_requests as u64
|
||||
} else {
|
||||
((completed_requests as u64) * 1_000) / duration_ms.max(1)
|
||||
};
|
||||
|
||||
let report = Report {
|
||||
suite: "usage_aux_counter_hotspot_baseline",
|
||||
config: ReportConfig {
|
||||
requests: config.requests,
|
||||
concurrency: config.concurrency,
|
||||
max_connections: config.max_connections,
|
||||
flush_batch_size: config.flush_batch_size,
|
||||
flush_interval_ms: config.flush_interval.as_millis() as u64,
|
||||
monitor_interval_ms: config.monitor_interval.as_millis() as u64,
|
||||
managed_postgres,
|
||||
},
|
||||
duration_ms,
|
||||
throughput_rps,
|
||||
completed_requests,
|
||||
failed_requests: load_result.failed.load(Ordering::Acquire),
|
||||
p50_ms,
|
||||
p95_ms,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
flush: Arc::try_unwrap(flush_report)
|
||||
.unwrap_or_else(|_| panic!("flush report still referenced"))
|
||||
.into_inner(),
|
||||
counters,
|
||||
lock_monitor: Arc::try_unwrap(lock_report)
|
||||
.unwrap_or_else(|_| panic!("lock report still referenced"))
|
||||
.into_inner(),
|
||||
};
|
||||
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LoadResult {
|
||||
completed: Arc<AtomicUsize>,
|
||||
failed: Arc<AtomicUsize>,
|
||||
latencies: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
async fn run_aux_counter_load(
|
||||
repository: SqlxUsageReadRepository,
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
) -> LoadResult {
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let failed = Arc::new(AtomicUsize::new(0));
|
||||
let latencies = Arc::new(Mutex::new(Vec::with_capacity(requests)));
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
|
||||
for _ in 0..concurrency {
|
||||
let repository = repository.clone();
|
||||
let next = Arc::clone(&next);
|
||||
let completed = Arc::clone(&completed);
|
||||
let failed = Arc::clone(&failed);
|
||||
let latencies = Arc::clone(&latencies);
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
let index = next.fetch_add(1, Ordering::AcqRel);
|
||||
if index >= requests {
|
||||
break;
|
||||
}
|
||||
|
||||
let started_at = Instant::now();
|
||||
let result = enqueue_aux_counter_deltas(&repository, index).await;
|
||||
latencies
|
||||
.lock()
|
||||
.await
|
||||
.push(started_at.elapsed().as_millis() as u64);
|
||||
completed.fetch_add(1, Ordering::AcqRel);
|
||||
if result.is_err() {
|
||||
failed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if result.is_err() {
|
||||
failed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
LoadResult {
|
||||
completed,
|
||||
failed,
|
||||
latencies,
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue_aux_counter_deltas(
|
||||
repository: &SqlxUsageReadRepository,
|
||||
index: usize,
|
||||
) -> Result<(), aether_data::DataLayerError> {
|
||||
repository
|
||||
.enqueue_proxy_node_counter_delta(proxy_delta_for_index(index))
|
||||
.await?;
|
||||
repository
|
||||
.enqueue_management_token_counter_delta(ManagementTokenCounterDelta {
|
||||
token_id: MANAGEMENT_TOKEN_ID.to_string(),
|
||||
usage_count_delta: 1,
|
||||
last_used_at_unix_secs: Some(now_unix_secs().saturating_add(index as u64)),
|
||||
last_used_ip: Some(format!("10.0.0.{}", (index % 250) + 1)),
|
||||
})
|
||||
.await?;
|
||||
repository
|
||||
.enqueue_api_key_last_used_delta(ApiKeyLastUsedDelta {
|
||||
api_key_id: API_KEY_ID.to_string(),
|
||||
last_used_at_unix_secs: now_unix_secs().saturating_add(index as u64),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn proxy_delta_for_index(index: usize) -> ProxyNodeCounterDelta {
|
||||
ProxyNodeCounterDelta {
|
||||
node_id: PROXY_NODE_ID.to_string(),
|
||||
total_requests_delta: 1,
|
||||
failed_requests_delta: if index.is_multiple_of(10) { 1 } else { 0 },
|
||||
dns_failures_delta: if index.is_multiple_of(25) { 1 } else { 0 },
|
||||
stream_errors_delta: if index.is_multiple_of(40) { 1 } else { 0 },
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_flush_loop(
|
||||
repository: SqlxUsageReadRepository,
|
||||
stop: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<FlushReport>>,
|
||||
batch_size: usize,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<Result<(), aether_data::DataLayerError>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let summary = repository.flush_usage_counter_deltas(batch_size).await?;
|
||||
let mut report = report.lock().await;
|
||||
report.calls += 1;
|
||||
report.rows_claimed += summary.rows_claimed;
|
||||
report.proxy_node_targets += summary.proxy_node_targets;
|
||||
report.management_token_targets += summary.management_token_targets;
|
||||
report.api_key_last_used_targets += summary.api_key_last_used_targets;
|
||||
drop(report);
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_lock_monitor(
|
||||
pool: PgPool,
|
||||
stop: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<LockMonitorReport>>,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<Result<(), sqlx::Error>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let sample = read_lock_sample(&pool).await?;
|
||||
let mut report = report.lock().await;
|
||||
report.samples += 1;
|
||||
report.max_lock_waiters = report.max_lock_waiters.max(sample.lock_waiters);
|
||||
report.max_proxy_node_update_waiters = report
|
||||
.max_proxy_node_update_waiters
|
||||
.max(sample.proxy_node_update_waiters);
|
||||
report.max_management_token_update_waiters = report
|
||||
.max_management_token_update_waiters
|
||||
.max(sample.management_token_update_waiters);
|
||||
report.max_api_key_update_waiters = report
|
||||
.max_api_key_update_waiters
|
||||
.max(sample.api_key_update_waiters);
|
||||
report.max_oldest_lock_wait_ms = report
|
||||
.max_oldest_lock_wait_ms
|
||||
.max(sample.oldest_lock_wait_ms);
|
||||
drop(report);
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_outbox_drain(
|
||||
pool: &PgPool,
|
||||
repository: &SqlxUsageReadRepository,
|
||||
batch_size: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let summary = repository.flush_usage_counter_deltas(batch_size).await?;
|
||||
let pending: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NULL",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if pending == 0 && summary.rows_claimed == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("usage counter outbox did not drain; pending={pending}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_hotspot_rows(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (id, username, email_verified)
|
||||
VALUES ($1, 'usage-aux-hotspot', TRUE)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(USER_ID)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_keys (id, user_id, key_hash, name, is_active)
|
||||
VALUES ($1, $2, 'usage-aux-hotspot-hash', 'usage aux hotspot', TRUE)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
last_used_at = NULL
|
||||
"#,
|
||||
)
|
||||
.bind(API_KEY_ID)
|
||||
.bind(USER_ID)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO management_tokens (
|
||||
id, user_id, name, token_hash, token_prefix, usage_count
|
||||
)
|
||||
VALUES ($1, $2, 'usage aux hotspot token', 'usage-aux-hotspot-token-hash', 'usageaux', 0)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
usage_count = 0,
|
||||
last_used_at = NULL,
|
||||
last_used_ip = NULL
|
||||
"#,
|
||||
)
|
||||
.bind(MANAGEMENT_TOKEN_ID)
|
||||
.bind(USER_ID)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO proxy_nodes (
|
||||
id, name, ip, port, status, total_requests, failed_requests,
|
||||
dns_failures, stream_errors
|
||||
)
|
||||
VALUES ($1, 'usage aux hotspot proxy', '127.0.0.1', 8080, 'online', 0, 0, 0, 0)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
total_requests = 0,
|
||||
failed_requests = 0,
|
||||
dns_failures = 0,
|
||||
stream_errors = 0
|
||||
"#,
|
||||
)
|
||||
.bind(PROXY_NODE_ID)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM usage_counter_deltas
|
||||
WHERE target_id IN ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(PROXY_NODE_ID)
|
||||
.bind(MANAGEMENT_TOKEN_ID)
|
||||
.bind(API_KEY_ID)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_lock_sample(pool: &PgPool) -> Result<LockSample, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE wait_event_type = 'Lock')::BIGINT AS lock_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE proxy_nodes%'
|
||||
)::BIGINT AS proxy_node_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE management_tokens%'
|
||||
)::BIGINT AS management_token_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE api_keys%'
|
||||
)::BIGINT AS api_key_update_waiters,
|
||||
COALESCE(
|
||||
MAX(EXTRACT(EPOCH FROM (NOW() - query_start)) * 1000)
|
||||
FILTER (WHERE wait_event_type = 'Lock'),
|
||||
0
|
||||
)::BIGINT AS oldest_lock_wait_ms
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(LockSample {
|
||||
lock_waiters: row.try_get("lock_waiters")?,
|
||||
proxy_node_update_waiters: row.try_get("proxy_node_update_waiters")?,
|
||||
management_token_update_waiters: row.try_get("management_token_update_waiters")?,
|
||||
api_key_update_waiters: row.try_get("api_key_update_waiters")?,
|
||||
oldest_lock_wait_ms: row.try_get("oldest_lock_wait_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_counters(pool: &PgPool, requests: usize) -> Result<CounterReport, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NULL) AS outbox_pending_rows,
|
||||
(SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NOT NULL) AS outbox_processed_rows,
|
||||
(SELECT total_requests::BIGINT FROM proxy_nodes WHERE id = $1) AS proxy_total_requests,
|
||||
(SELECT failed_requests::BIGINT FROM proxy_nodes WHERE id = $1) AS proxy_failed_requests,
|
||||
(SELECT dns_failures::BIGINT FROM proxy_nodes WHERE id = $1) AS proxy_dns_failures,
|
||||
(SELECT stream_errors::BIGINT FROM proxy_nodes WHERE id = $1) AS proxy_stream_errors,
|
||||
(SELECT usage_count::BIGINT FROM management_tokens WHERE id = $2) AS management_token_usage_count,
|
||||
(
|
||||
SELECT EXTRACT(EPOCH FROM last_used_at)::BIGINT
|
||||
FROM api_keys
|
||||
WHERE id = $3
|
||||
) AS api_key_last_used_at
|
||||
"#,
|
||||
)
|
||||
.bind(PROXY_NODE_ID)
|
||||
.bind(MANAGEMENT_TOKEN_ID)
|
||||
.bind(API_KEY_ID)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(CounterReport {
|
||||
outbox_pending_rows: row.try_get("outbox_pending_rows")?,
|
||||
outbox_processed_rows: row.try_get("outbox_processed_rows")?,
|
||||
proxy_total_requests: row.try_get("proxy_total_requests")?,
|
||||
proxy_failed_requests: row.try_get("proxy_failed_requests")?,
|
||||
proxy_dns_failures: row.try_get("proxy_dns_failures")?,
|
||||
proxy_stream_errors: row.try_get("proxy_stream_errors")?,
|
||||
expected_proxy_total_requests: requests as i64,
|
||||
expected_proxy_failed_requests: count_every(requests, 10),
|
||||
expected_proxy_dns_failures: count_every(requests, 25),
|
||||
expected_proxy_stream_errors: count_every(requests, 40),
|
||||
management_token_usage_count: row.try_get("management_token_usage_count")?,
|
||||
expected_management_token_usage_count: requests as i64,
|
||||
api_key_last_used_at: row.try_get("api_key_last_used_at")?,
|
||||
expected_min_api_key_last_used_at: now_unix_secs().saturating_sub(5) as i64,
|
||||
})
|
||||
}
|
||||
|
||||
fn count_every(requests: usize, interval: usize) -> i64 {
|
||||
if requests == 0 {
|
||||
return 0;
|
||||
}
|
||||
((requests - 1) / interval + 1) as i64
|
||||
}
|
||||
|
||||
fn summarize_latencies(mut latencies: Vec<u64>) -> (u64, u64, u64, u64) {
|
||||
if latencies.is_empty() {
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
latencies.sort_unstable();
|
||||
let p50 = percentile(&latencies, 50);
|
||||
let p95 = percentile(&latencies, 95);
|
||||
let max = *latencies.last().unwrap_or(&0);
|
||||
let mean = latencies.iter().sum::<u64>() / latencies.len() as u64;
|
||||
(p50, p95, max, mean)
|
||||
}
|
||||
|
||||
fn percentile(latencies: &[u64], percentile: usize) -> u64 {
|
||||
let index = ((latencies.len() - 1) * percentile) / 100;
|
||||
latencies[index]
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut config = Config::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--requests" => config.requests = next_value(&mut iter, "--requests")?.parse()?,
|
||||
"--concurrency" => {
|
||||
config.concurrency = next_value(&mut iter, "--concurrency")?.parse()?
|
||||
}
|
||||
"--max-connections" => {
|
||||
config.max_connections = next_value(&mut iter, "--max-connections")?.parse()?
|
||||
}
|
||||
"--flush-batch-size" => {
|
||||
config.flush_batch_size = next_value(&mut iter, "--flush-batch-size")?.parse()?
|
||||
}
|
||||
"--flush-interval-ms" => {
|
||||
config.flush_interval =
|
||||
Duration::from_millis(next_value(&mut iter, "--flush-interval-ms")?.parse()?)
|
||||
}
|
||||
"--monitor-interval-ms" => {
|
||||
config.monitor_interval =
|
||||
Duration::from_millis(next_value(&mut iter, "--monitor-interval-ms")?.parse()?)
|
||||
}
|
||||
"--postgres-url" => {
|
||||
config.postgres_url = Some(next_value(&mut iter, "--postgres-url")?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.requests == 0 || config.concurrency == 0 || config.max_connections == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"requests, concurrency, and max-connections must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin usage_aux_counter_hotspot_baseline -- [--requests 2000] [--concurrency 100] [--max-connections 64] [--flush-batch-size 1000] [--flush-interval-ms 100] [--monitor-interval-ms 100] [--postgres-url postgres://...] [--output /tmp/usage_aux_counter_hotspot.json]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_data::repository::usage::SqlxUsageReadRepository;
|
||||
use aether_data_contracts::repository::usage::UpsertUsageRecord;
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, prepare_aether_postgres_schema, ManagedPostgresServer,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
max_connections: u32,
|
||||
flush_batch_size: usize,
|
||||
flush_interval: Duration,
|
||||
monitor_interval: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: 2_000,
|
||||
concurrency: 100,
|
||||
max_connections: 64,
|
||||
flush_batch_size: 1_000,
|
||||
flush_interval: Duration::from_millis(100),
|
||||
monitor_interval: Duration::from_millis(100),
|
||||
output_path: None,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
suite: &'static str,
|
||||
config: ReportConfig,
|
||||
duration_ms: u64,
|
||||
throughput_rps: u64,
|
||||
completed_requests: usize,
|
||||
failed_requests: usize,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
flush: FlushReport,
|
||||
counters: CounterReport,
|
||||
lock_monitor: LockMonitorReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReportConfig {
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
max_connections: u32,
|
||||
flush_batch_size: usize,
|
||||
flush_interval_ms: u64,
|
||||
monitor_interval_ms: u64,
|
||||
managed_postgres: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct FlushReport {
|
||||
calls: usize,
|
||||
rows_claimed: usize,
|
||||
api_key_targets: usize,
|
||||
provider_api_key_targets: usize,
|
||||
model_targets: usize,
|
||||
provider_monthly_targets: usize,
|
||||
proxy_node_targets: usize,
|
||||
management_token_targets: usize,
|
||||
api_key_last_used_targets: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CounterReport {
|
||||
usage_rows: i64,
|
||||
outbox_pending_rows: i64,
|
||||
outbox_processed_rows: i64,
|
||||
api_key_total_requests: i64,
|
||||
api_key_total_tokens: i64,
|
||||
provider_key_request_count: i64,
|
||||
provider_key_success_count: i64,
|
||||
global_model_usage_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Copy, Default)]
|
||||
struct LockMonitorReport {
|
||||
samples: usize,
|
||||
max_lock_waiters: i64,
|
||||
max_api_key_update_waiters: i64,
|
||||
max_provider_key_update_waiters: i64,
|
||||
max_global_model_update_waiters: i64,
|
||||
max_provider_update_waiters: i64,
|
||||
max_oldest_lock_wait_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct LockSample {
|
||||
lock_waiters: i64,
|
||||
api_key_update_waiters: i64,
|
||||
provider_key_update_waiters: i64,
|
||||
global_model_update_waiters: i64,
|
||||
provider_update_waiters: i64,
|
||||
oldest_lock_wait_ms: i64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("usage-counter-hotspot-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
|
||||
let managed_postgres;
|
||||
let database_url;
|
||||
let _server;
|
||||
if let Some(url) = config.postgres_url.as_ref() {
|
||||
managed_postgres = false;
|
||||
database_url = url.clone();
|
||||
_server = None;
|
||||
} else {
|
||||
managed_postgres = true;
|
||||
let server = ManagedPostgresServer::start().await?;
|
||||
database_url = server.database_url().to_string();
|
||||
_server = Some(server);
|
||||
}
|
||||
|
||||
prepare_aether_postgres_schema(&database_url).await?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.min_connections(1)
|
||||
.max_connections(config.max_connections)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
seed_hotspot_rows(&pool).await?;
|
||||
|
||||
let repository = SqlxUsageReadRepository::new(pool.clone());
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let flush_report = Arc::new(Mutex::new(FlushReport::default()));
|
||||
let lock_report = Arc::new(Mutex::new(LockMonitorReport::default()));
|
||||
|
||||
let flush_handle = spawn_flush_loop(
|
||||
repository.clone(),
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&flush_report),
|
||||
config.flush_batch_size,
|
||||
config.flush_interval,
|
||||
);
|
||||
let monitor_handle = spawn_lock_monitor(
|
||||
pool.clone(),
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&lock_report),
|
||||
config.monitor_interval,
|
||||
);
|
||||
|
||||
let started_at = Instant::now();
|
||||
let load_result = run_usage_load(repository, config.requests, config.concurrency).await;
|
||||
wait_for_outbox_drain(
|
||||
&pool,
|
||||
&SqlxUsageReadRepository::new(pool.clone()),
|
||||
config.flush_batch_size,
|
||||
)
|
||||
.await?;
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
|
||||
stop.store(true, Ordering::Release);
|
||||
flush_handle.await??;
|
||||
monitor_handle.await??;
|
||||
|
||||
let mut flush = flush_report.lock().await;
|
||||
let final_flush = SqlxUsageReadRepository::new(pool.clone())
|
||||
.flush_usage_counter_deltas(config.flush_batch_size)
|
||||
.await?;
|
||||
flush.calls += 1;
|
||||
flush.rows_claimed += final_flush.rows_claimed;
|
||||
flush.api_key_targets += final_flush.api_key_targets;
|
||||
flush.provider_api_key_targets += final_flush.provider_api_key_targets;
|
||||
flush.model_targets += final_flush.model_targets;
|
||||
flush.provider_monthly_targets += final_flush.provider_monthly_targets;
|
||||
flush.proxy_node_targets += final_flush.proxy_node_targets;
|
||||
flush.management_token_targets += final_flush.management_token_targets;
|
||||
flush.api_key_last_used_targets += final_flush.api_key_last_used_targets;
|
||||
drop(flush);
|
||||
|
||||
let counters = read_counters(&pool).await?;
|
||||
let latencies = load_result.latencies.lock().await.clone();
|
||||
let (p50_ms, p95_ms, max_ms, mean_ms) = summarize_latencies(latencies);
|
||||
let completed_requests = load_result.completed.load(Ordering::Acquire);
|
||||
let throughput_rps = if duration_ms == 0 {
|
||||
completed_requests as u64
|
||||
} else {
|
||||
((completed_requests as u64) * 1_000) / duration_ms.max(1)
|
||||
};
|
||||
|
||||
let report = Report {
|
||||
suite: "usage_counter_hotspot_baseline",
|
||||
config: ReportConfig {
|
||||
requests: config.requests,
|
||||
concurrency: config.concurrency,
|
||||
max_connections: config.max_connections,
|
||||
flush_batch_size: config.flush_batch_size,
|
||||
flush_interval_ms: config.flush_interval.as_millis() as u64,
|
||||
monitor_interval_ms: config.monitor_interval.as_millis() as u64,
|
||||
managed_postgres,
|
||||
},
|
||||
duration_ms,
|
||||
throughput_rps,
|
||||
completed_requests,
|
||||
failed_requests: load_result.failed.load(Ordering::Acquire),
|
||||
p50_ms,
|
||||
p95_ms,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
flush: Arc::try_unwrap(flush_report)
|
||||
.unwrap_or_else(|_| panic!("flush report still referenced"))
|
||||
.into_inner(),
|
||||
counters,
|
||||
lock_monitor: Arc::try_unwrap(lock_report)
|
||||
.unwrap_or_else(|_| panic!("lock report still referenced"))
|
||||
.into_inner(),
|
||||
};
|
||||
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LoadResult {
|
||||
completed: Arc<AtomicUsize>,
|
||||
failed: Arc<AtomicUsize>,
|
||||
latencies: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
async fn run_usage_load(
|
||||
repository: SqlxUsageReadRepository,
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
) -> LoadResult {
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let failed = Arc::new(AtomicUsize::new(0));
|
||||
let latencies = Arc::new(Mutex::new(Vec::with_capacity(requests)));
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
|
||||
for _ in 0..concurrency {
|
||||
let repository = repository.clone();
|
||||
let next = Arc::clone(&next);
|
||||
let completed = Arc::clone(&completed);
|
||||
let failed = Arc::clone(&failed);
|
||||
let latencies = Arc::clone(&latencies);
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
let index = next.fetch_add(1, Ordering::AcqRel);
|
||||
if index >= requests {
|
||||
break;
|
||||
}
|
||||
let started_at = Instant::now();
|
||||
let result = repository.upsert(usage_record(index)).await;
|
||||
latencies
|
||||
.lock()
|
||||
.await
|
||||
.push(started_at.elapsed().as_millis() as u64);
|
||||
completed.fetch_add(1, Ordering::AcqRel);
|
||||
if result.is_err() {
|
||||
failed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if result.is_err() {
|
||||
failed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
LoadResult {
|
||||
completed,
|
||||
failed,
|
||||
latencies,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_flush_loop(
|
||||
repository: SqlxUsageReadRepository,
|
||||
stop: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<FlushReport>>,
|
||||
batch_size: usize,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<Result<(), aether_data::DataLayerError>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let summary = repository.flush_usage_counter_deltas(batch_size).await?;
|
||||
let mut report = report.lock().await;
|
||||
report.calls += 1;
|
||||
report.rows_claimed += summary.rows_claimed;
|
||||
report.api_key_targets += summary.api_key_targets;
|
||||
report.provider_api_key_targets += summary.provider_api_key_targets;
|
||||
report.model_targets += summary.model_targets;
|
||||
report.provider_monthly_targets += summary.provider_monthly_targets;
|
||||
report.proxy_node_targets += summary.proxy_node_targets;
|
||||
report.management_token_targets += summary.management_token_targets;
|
||||
report.api_key_last_used_targets += summary.api_key_last_used_targets;
|
||||
drop(report);
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_lock_monitor(
|
||||
pool: PgPool,
|
||||
stop: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<LockMonitorReport>>,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<Result<(), sqlx::Error>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let sample = read_lock_sample(&pool).await?;
|
||||
let mut report = report.lock().await;
|
||||
report.samples += 1;
|
||||
report.max_lock_waiters = report.max_lock_waiters.max(sample.lock_waiters);
|
||||
report.max_api_key_update_waiters = report
|
||||
.max_api_key_update_waiters
|
||||
.max(sample.api_key_update_waiters);
|
||||
report.max_provider_key_update_waiters = report
|
||||
.max_provider_key_update_waiters
|
||||
.max(sample.provider_key_update_waiters);
|
||||
report.max_global_model_update_waiters = report
|
||||
.max_global_model_update_waiters
|
||||
.max(sample.global_model_update_waiters);
|
||||
report.max_provider_update_waiters = report
|
||||
.max_provider_update_waiters
|
||||
.max(sample.provider_update_waiters);
|
||||
report.max_oldest_lock_wait_ms = report
|
||||
.max_oldest_lock_wait_ms
|
||||
.max(sample.oldest_lock_wait_ms);
|
||||
drop(report);
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_outbox_drain(
|
||||
pool: &PgPool,
|
||||
repository: &SqlxUsageReadRepository,
|
||||
batch_size: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let summary = repository.flush_usage_counter_deltas(batch_size).await?;
|
||||
let pending: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NULL",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if pending == 0 && summary.rows_claimed == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("usage counter outbox did not drain; pending={pending}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_hotspot_rows(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (id, username, email_verified)
|
||||
VALUES ('user-hotspot', 'usage-hotspot', true)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_keys (id, user_id, key_hash, name, is_active, total_requests, total_tokens, total_cost_usd)
|
||||
VALUES ('api-key-hotspot', 'user-hotspot', 'hash-hotspot', 'hotspot key', true, 0, 0, 0)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
total_requests = 0,
|
||||
total_tokens = 0,
|
||||
total_cost_usd = 0,
|
||||
last_used_at = NULL
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO providers (id, name, provider_type, monthly_used_usd)
|
||||
VALUES ('provider-hotspot', 'Hotspot Provider', 'openai', 0)
|
||||
ON CONFLICT (id) DO UPDATE SET monthly_used_usd = 0
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO provider_api_keys (
|
||||
id, provider_id, name, total_tokens, total_cost_usd, request_count,
|
||||
success_count, error_count, total_response_time_ms
|
||||
)
|
||||
VALUES ('provider-key-hotspot', 'provider-hotspot', 'Hotspot Provider Key', 0, 0, 0, 0, 0, 0)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
total_tokens = 0,
|
||||
total_cost_usd = 0,
|
||||
request_count = 0,
|
||||
success_count = 0,
|
||||
error_count = 0,
|
||||
total_response_time_ms = 0,
|
||||
last_used_at = NULL
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO global_models (id, name, display_name, enabled, is_active, usage_count)
|
||||
VALUES ('model-hotspot', 'gpt-5', 'gpt-5', true, true, 0)
|
||||
ON CONFLICT (id) DO UPDATE SET usage_count = 0
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn usage_record(index: usize) -> UpsertUsageRecord {
|
||||
let now_ms = now_unix_ms().saturating_add(index as u64);
|
||||
let now_secs = now_ms / 1_000;
|
||||
UpsertUsageRecord {
|
||||
request_id: format!("usage-hotspot-{index:08}"),
|
||||
user_id: Some("user-hotspot".to_string()),
|
||||
api_key_id: Some("api-key-hotspot".to_string()),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
provider_name: "openai".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
target_model: None,
|
||||
provider_id: Some("provider-hotspot".to_string()),
|
||||
provider_endpoint_id: None,
|
||||
provider_api_key_id: Some("provider-key-hotspot".to_string()),
|
||||
request_type: Some("chat".to_string()),
|
||||
api_format: Some("openai:chat".to_string()),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
endpoint_api_format: Some("openai:chat".to_string()),
|
||||
provider_api_family: Some("openai".to_string()),
|
||||
provider_endpoint_kind: Some("chat".to_string()),
|
||||
has_format_conversion: Some(false),
|
||||
is_stream: Some(false),
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(20),
|
||||
total_tokens: Some(30),
|
||||
cache_creation_input_tokens: None,
|
||||
cache_creation_ephemeral_5m_input_tokens: None,
|
||||
cache_creation_ephemeral_1h_input_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_cost_usd: None,
|
||||
cache_read_cost_usd: None,
|
||||
output_price_per_1m: None,
|
||||
total_cost_usd: Some(0.001),
|
||||
actual_total_cost_usd: Some(0.001),
|
||||
status_code: Some(200),
|
||||
error_message: None,
|
||||
error_category: None,
|
||||
response_time_ms: Some(100),
|
||||
first_byte_time_ms: Some(20),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
request_headers: None,
|
||||
request_body: Some(json!({"model": "gpt-5"})),
|
||||
request_body_ref: None,
|
||||
request_body_state: None,
|
||||
provider_request_headers: None,
|
||||
provider_request_body: None,
|
||||
provider_request_body_ref: None,
|
||||
provider_request_body_state: None,
|
||||
response_headers: None,
|
||||
response_body: Some(json!({"id": format!("chatcmpl-{index}")})),
|
||||
response_body_ref: None,
|
||||
response_body_state: None,
|
||||
client_response_headers: None,
|
||||
client_response_body: None,
|
||||
client_response_body_ref: None,
|
||||
client_response_body_state: None,
|
||||
candidate_id: Some(format!("candidate-{index:08}")),
|
||||
candidate_index: Some(0),
|
||||
key_name: None,
|
||||
planner_kind: Some("hotspot_baseline".to_string()),
|
||||
route_family: Some("openai".to_string()),
|
||||
route_kind: Some("chat".to_string()),
|
||||
execution_path: Some("testkit".to_string()),
|
||||
local_execution_runtime_miss_reason: None,
|
||||
request_metadata: None,
|
||||
finalized_at_unix_secs: None,
|
||||
created_at_unix_ms: Some(now_ms),
|
||||
updated_at_unix_secs: now_secs,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_lock_sample(pool: &PgPool) -> Result<LockSample, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE wait_event_type = 'Lock')::BIGINT AS lock_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE api_keys%'
|
||||
)::BIGINT AS api_key_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE provider_api_keys%'
|
||||
)::BIGINT AS provider_key_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE global_models%'
|
||||
)::BIGINT AS global_model_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE providers%'
|
||||
)::BIGINT AS provider_update_waiters,
|
||||
COALESCE(
|
||||
MAX(EXTRACT(EPOCH FROM (NOW() - query_start)) * 1000)
|
||||
FILTER (WHERE wait_event_type = 'Lock'),
|
||||
0
|
||||
)::BIGINT AS oldest_lock_wait_ms
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(LockSample {
|
||||
lock_waiters: row.try_get("lock_waiters")?,
|
||||
api_key_update_waiters: row.try_get("api_key_update_waiters")?,
|
||||
provider_key_update_waiters: row.try_get("provider_key_update_waiters")?,
|
||||
global_model_update_waiters: row.try_get("global_model_update_waiters")?,
|
||||
provider_update_waiters: row.try_get("provider_update_waiters")?,
|
||||
oldest_lock_wait_ms: row.try_get("oldest_lock_wait_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_counters(pool: &PgPool) -> Result<CounterReport, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*)::BIGINT FROM usage) AS usage_rows,
|
||||
(SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NULL) AS outbox_pending_rows,
|
||||
(SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NOT NULL) AS outbox_processed_rows,
|
||||
(SELECT COALESCE(total_requests, 0)::BIGINT FROM api_keys WHERE id = 'api-key-hotspot') AS api_key_total_requests,
|
||||
(SELECT COALESCE(total_tokens, 0)::BIGINT FROM api_keys WHERE id = 'api-key-hotspot') AS api_key_total_tokens,
|
||||
(SELECT COALESCE(request_count, 0)::BIGINT FROM provider_api_keys WHERE id = 'provider-key-hotspot') AS provider_key_request_count,
|
||||
(SELECT COALESCE(success_count, 0)::BIGINT FROM provider_api_keys WHERE id = 'provider-key-hotspot') AS provider_key_success_count,
|
||||
(SELECT COALESCE(usage_count, 0)::BIGINT FROM global_models WHERE name = 'gpt-5' LIMIT 1) AS global_model_usage_count
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(CounterReport {
|
||||
usage_rows: row.try_get("usage_rows")?,
|
||||
outbox_pending_rows: row.try_get("outbox_pending_rows")?,
|
||||
outbox_processed_rows: row.try_get("outbox_processed_rows")?,
|
||||
api_key_total_requests: row.try_get("api_key_total_requests")?,
|
||||
api_key_total_tokens: row.try_get("api_key_total_tokens")?,
|
||||
provider_key_request_count: row.try_get("provider_key_request_count")?,
|
||||
provider_key_success_count: row.try_get("provider_key_success_count")?,
|
||||
global_model_usage_count: row.try_get("global_model_usage_count")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn summarize_latencies(mut latencies: Vec<u64>) -> (u64, u64, u64, u64) {
|
||||
if latencies.is_empty() {
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
latencies.sort_unstable();
|
||||
let p50 = percentile(&latencies, 50);
|
||||
let p95 = percentile(&latencies, 95);
|
||||
let max = *latencies.last().unwrap_or(&0);
|
||||
let mean = latencies.iter().sum::<u64>() / latencies.len() as u64;
|
||||
(p50, p95, max, mean)
|
||||
}
|
||||
|
||||
fn percentile(latencies: &[u64], percentile: usize) -> u64 {
|
||||
let index = ((latencies.len() - 1) * percentile) / 100;
|
||||
latencies[index]
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut config = Config::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--requests" => config.requests = next_value(&mut iter, "--requests")?.parse()?,
|
||||
"--concurrency" => {
|
||||
config.concurrency = next_value(&mut iter, "--concurrency")?.parse()?
|
||||
}
|
||||
"--max-connections" => {
|
||||
config.max_connections = next_value(&mut iter, "--max-connections")?.parse()?
|
||||
}
|
||||
"--flush-batch-size" => {
|
||||
config.flush_batch_size = next_value(&mut iter, "--flush-batch-size")?.parse()?
|
||||
}
|
||||
"--flush-interval-ms" => {
|
||||
config.flush_interval =
|
||||
Duration::from_millis(next_value(&mut iter, "--flush-interval-ms")?.parse()?)
|
||||
}
|
||||
"--monitor-interval-ms" => {
|
||||
config.monitor_interval =
|
||||
Duration::from_millis(next_value(&mut iter, "--monitor-interval-ms")?.parse()?)
|
||||
}
|
||||
"--postgres-url" => {
|
||||
config.postgres_url = Some(next_value(&mut iter, "--postgres-url")?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.requests == 0 || config.concurrency == 0 || config.max_connections == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"requests, concurrency, and max-connections must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin usage_counter_hotspot_baseline -- [--requests 2000] [--concurrency 100] [--max-connections 64] [--flush-batch-size 1000] [--flush-interval-ms 100] [--monitor-interval-ms 100] [--postgres-url postgres://...] [--output /tmp/usage_counter_hotspot.json]"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
// Gateway-backed benchmark scenarios live outside the reusable testkit.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_data::repository::settlement::SqlxSettlementRepository;
|
||||
use aether_data::repository::usage::SqlxUsageReadRepository;
|
||||
use aether_data_contracts::repository::settlement::{
|
||||
SettlementWriteRepository, UsageSettlementInput,
|
||||
};
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, prepare_aether_postgres_schema, ManagedPostgresServer,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const PROVIDER_ID: &str = "provider-hotspot";
|
||||
const REQUEST_PREFIX: &str = "settlement-hotspot";
|
||||
const COST_PER_REQUEST_USD: f64 = 0.001;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
max_connections: u32,
|
||||
flush_batch_size: usize,
|
||||
flush_interval: Duration,
|
||||
monitor_interval: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
postgres_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: 2_000,
|
||||
concurrency: 100,
|
||||
max_connections: 64,
|
||||
flush_batch_size: 1_000,
|
||||
flush_interval: Duration::from_millis(100),
|
||||
monitor_interval: Duration::from_millis(100),
|
||||
output_path: None,
|
||||
postgres_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
suite: &'static str,
|
||||
config: ReportConfig,
|
||||
duration_ms: u64,
|
||||
throughput_rps: u64,
|
||||
completed_requests: usize,
|
||||
failed_requests: usize,
|
||||
p50_ms: u64,
|
||||
p95_ms: u64,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
flush: FlushReport,
|
||||
counters: CounterReport,
|
||||
lock_monitor: LockMonitorReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReportConfig {
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
max_connections: u32,
|
||||
flush_batch_size: usize,
|
||||
flush_interval_ms: u64,
|
||||
monitor_interval_ms: u64,
|
||||
managed_postgres: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct FlushReport {
|
||||
calls: usize,
|
||||
rows_claimed: usize,
|
||||
api_key_targets: usize,
|
||||
provider_api_key_targets: usize,
|
||||
model_targets: usize,
|
||||
provider_monthly_targets: usize,
|
||||
proxy_node_targets: usize,
|
||||
management_token_targets: usize,
|
||||
api_key_last_used_targets: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CounterReport {
|
||||
usage_rows: i64,
|
||||
settled_usage_rows: i64,
|
||||
settlement_snapshot_rows: i64,
|
||||
outbox_pending_rows: i64,
|
||||
outbox_processed_rows: i64,
|
||||
provider_monthly_outbox_rows: i64,
|
||||
provider_monthly_used_usd: f64,
|
||||
expected_provider_monthly_used_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Copy, Default)]
|
||||
struct LockMonitorReport {
|
||||
samples: usize,
|
||||
max_lock_waiters: i64,
|
||||
max_usage_update_waiters: i64,
|
||||
max_wallet_update_waiters: i64,
|
||||
max_provider_update_waiters: i64,
|
||||
max_oldest_lock_wait_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct LockSample {
|
||||
lock_waiters: i64,
|
||||
usage_update_waiters: i64,
|
||||
wallet_update_waiters: i64,
|
||||
provider_update_waiters: i64,
|
||||
oldest_lock_wait_ms: i64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_test_runtime_for("usage-settlement-hotspot-baseline");
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
|
||||
let managed_postgres;
|
||||
let database_url;
|
||||
let _server;
|
||||
if let Some(url) = config.postgres_url.as_ref() {
|
||||
managed_postgres = false;
|
||||
database_url = url.clone();
|
||||
_server = None;
|
||||
} else {
|
||||
managed_postgres = true;
|
||||
let server = ManagedPostgresServer::start().await?;
|
||||
database_url = server.database_url().to_string();
|
||||
_server = Some(server);
|
||||
}
|
||||
|
||||
prepare_aether_postgres_schema(&database_url).await?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.min_connections(1)
|
||||
.max_connections(config.max_connections)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
seed_settlement_rows(&pool, config.requests).await?;
|
||||
|
||||
let usage_repository = SqlxUsageReadRepository::new(pool.clone());
|
||||
let settlement_repository = SqlxSettlementRepository::new(pool.clone());
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let flush_report = Arc::new(Mutex::new(FlushReport::default()));
|
||||
let lock_report = Arc::new(Mutex::new(LockMonitorReport::default()));
|
||||
|
||||
let flush_handle = spawn_flush_loop(
|
||||
usage_repository.clone(),
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&flush_report),
|
||||
config.flush_batch_size,
|
||||
config.flush_interval,
|
||||
);
|
||||
let monitor_handle = spawn_lock_monitor(
|
||||
pool.clone(),
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&lock_report),
|
||||
config.monitor_interval,
|
||||
);
|
||||
|
||||
let started_at = Instant::now();
|
||||
let load_result =
|
||||
run_settlement_load(settlement_repository, config.requests, config.concurrency).await;
|
||||
wait_for_outbox_drain(&pool, &usage_repository, config.flush_batch_size).await?;
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
|
||||
stop.store(true, Ordering::Release);
|
||||
flush_handle.await??;
|
||||
monitor_handle.await??;
|
||||
|
||||
let mut flush = flush_report.lock().await;
|
||||
let final_flush = usage_repository
|
||||
.flush_usage_counter_deltas(config.flush_batch_size)
|
||||
.await?;
|
||||
flush.calls += 1;
|
||||
flush.rows_claimed += final_flush.rows_claimed;
|
||||
flush.api_key_targets += final_flush.api_key_targets;
|
||||
flush.provider_api_key_targets += final_flush.provider_api_key_targets;
|
||||
flush.model_targets += final_flush.model_targets;
|
||||
flush.provider_monthly_targets += final_flush.provider_monthly_targets;
|
||||
flush.proxy_node_targets += final_flush.proxy_node_targets;
|
||||
flush.management_token_targets += final_flush.management_token_targets;
|
||||
flush.api_key_last_used_targets += final_flush.api_key_last_used_targets;
|
||||
drop(flush);
|
||||
|
||||
let counters = read_counters(&pool, config.requests).await?;
|
||||
let latencies = load_result.latencies.lock().await.clone();
|
||||
let (p50_ms, p95_ms, max_ms, mean_ms) = summarize_latencies(latencies);
|
||||
let completed_requests = load_result.completed.load(Ordering::Acquire);
|
||||
let throughput_rps = if duration_ms == 0 {
|
||||
completed_requests as u64
|
||||
} else {
|
||||
((completed_requests as u64) * 1_000) / duration_ms.max(1)
|
||||
};
|
||||
|
||||
let report = Report {
|
||||
suite: "usage_settlement_hotspot_baseline",
|
||||
config: ReportConfig {
|
||||
requests: config.requests,
|
||||
concurrency: config.concurrency,
|
||||
max_connections: config.max_connections,
|
||||
flush_batch_size: config.flush_batch_size,
|
||||
flush_interval_ms: config.flush_interval.as_millis() as u64,
|
||||
monitor_interval_ms: config.monitor_interval.as_millis() as u64,
|
||||
managed_postgres,
|
||||
},
|
||||
duration_ms,
|
||||
throughput_rps,
|
||||
completed_requests,
|
||||
failed_requests: load_result.failed.load(Ordering::Acquire),
|
||||
p50_ms,
|
||||
p95_ms,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
flush: Arc::try_unwrap(flush_report)
|
||||
.unwrap_or_else(|_| panic!("flush report still referenced"))
|
||||
.into_inner(),
|
||||
counters,
|
||||
lock_monitor: Arc::try_unwrap(lock_report)
|
||||
.unwrap_or_else(|_| panic!("lock report still referenced"))
|
||||
.into_inner(),
|
||||
};
|
||||
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LoadResult {
|
||||
completed: Arc<AtomicUsize>,
|
||||
failed: Arc<AtomicUsize>,
|
||||
latencies: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
async fn run_settlement_load(
|
||||
repository: SqlxSettlementRepository,
|
||||
requests: usize,
|
||||
concurrency: usize,
|
||||
) -> LoadResult {
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let failed = Arc::new(AtomicUsize::new(0));
|
||||
let latencies = Arc::new(Mutex::new(Vec::with_capacity(requests)));
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
|
||||
for _ in 0..concurrency {
|
||||
let repository = repository.clone();
|
||||
let next = Arc::clone(&next);
|
||||
let completed = Arc::clone(&completed);
|
||||
let failed = Arc::clone(&failed);
|
||||
let latencies = Arc::clone(&latencies);
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
let index = next.fetch_add(1, Ordering::AcqRel);
|
||||
if index >= requests {
|
||||
break;
|
||||
}
|
||||
let started_at = Instant::now();
|
||||
let result = repository.settle_usage(settlement_input(index)).await;
|
||||
latencies
|
||||
.lock()
|
||||
.await
|
||||
.push(started_at.elapsed().as_millis() as u64);
|
||||
completed.fetch_add(1, Ordering::AcqRel);
|
||||
match result {
|
||||
Ok(Some(settlement)) if settlement.billing_status == "settled" => {}
|
||||
_ => {
|
||||
failed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if result.is_err() {
|
||||
failed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
LoadResult {
|
||||
completed,
|
||||
failed,
|
||||
latencies,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_flush_loop(
|
||||
repository: SqlxUsageReadRepository,
|
||||
stop: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<FlushReport>>,
|
||||
batch_size: usize,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<Result<(), aether_data::DataLayerError>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let summary = repository.flush_usage_counter_deltas(batch_size).await?;
|
||||
let mut report = report.lock().await;
|
||||
report.calls += 1;
|
||||
report.rows_claimed += summary.rows_claimed;
|
||||
report.api_key_targets += summary.api_key_targets;
|
||||
report.provider_api_key_targets += summary.provider_api_key_targets;
|
||||
report.model_targets += summary.model_targets;
|
||||
report.provider_monthly_targets += summary.provider_monthly_targets;
|
||||
report.proxy_node_targets += summary.proxy_node_targets;
|
||||
report.management_token_targets += summary.management_token_targets;
|
||||
report.api_key_last_used_targets += summary.api_key_last_used_targets;
|
||||
drop(report);
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_lock_monitor(
|
||||
pool: PgPool,
|
||||
stop: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<LockMonitorReport>>,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<Result<(), sqlx::Error>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
let sample = read_lock_sample(&pool).await?;
|
||||
let mut report = report.lock().await;
|
||||
report.samples += 1;
|
||||
report.max_lock_waiters = report.max_lock_waiters.max(sample.lock_waiters);
|
||||
report.max_usage_update_waiters = report
|
||||
.max_usage_update_waiters
|
||||
.max(sample.usage_update_waiters);
|
||||
report.max_wallet_update_waiters = report
|
||||
.max_wallet_update_waiters
|
||||
.max(sample.wallet_update_waiters);
|
||||
report.max_provider_update_waiters = report
|
||||
.max_provider_update_waiters
|
||||
.max(sample.provider_update_waiters);
|
||||
report.max_oldest_lock_wait_ms = report
|
||||
.max_oldest_lock_wait_ms
|
||||
.max(sample.oldest_lock_wait_ms);
|
||||
drop(report);
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_outbox_drain(
|
||||
pool: &PgPool,
|
||||
repository: &SqlxUsageReadRepository,
|
||||
batch_size: usize,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let summary = repository.flush_usage_counter_deltas(batch_size).await?;
|
||||
let pending: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM usage_counter_deltas WHERE processed_at IS NULL",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if pending == 0 && summary.rows_claimed == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("usage counter outbox did not drain; pending={pending}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_settlement_rows(pool: &PgPool, requests: usize) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO providers (id, name, provider_type, monthly_used_usd)
|
||||
VALUES ($1, 'Hotspot Provider', 'openai', 0)
|
||||
ON CONFLICT (id) DO UPDATE SET monthly_used_usd = 0
|
||||
"#,
|
||||
)
|
||||
.bind(PROVIDER_ID)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM usage_counter_deltas
|
||||
WHERE request_id LIKE $1
|
||||
"#,
|
||||
)
|
||||
.bind(format!("{REQUEST_PREFIX}-%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM usage_settlement_snapshots
|
||||
WHERE request_id LIKE $1
|
||||
"#,
|
||||
)
|
||||
.bind(format!("{REQUEST_PREFIX}-%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM "usage"
|
||||
WHERE request_id LIKE $1
|
||||
"#,
|
||||
)
|
||||
.bind(format!("{REQUEST_PREFIX}-%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO "usage" (
|
||||
id,
|
||||
request_id,
|
||||
provider_name,
|
||||
model,
|
||||
provider_id,
|
||||
status,
|
||||
billing_status,
|
||||
total_cost_usd,
|
||||
actual_total_cost_usd,
|
||||
created_at_unix_ms,
|
||||
updated_at_unix_secs
|
||||
)
|
||||
SELECT
|
||||
'settlement-usage-' || LPAD(gs::TEXT, 8, '0'),
|
||||
$2 || '-' || LPAD(gs::TEXT, 8, '0'),
|
||||
'Hotspot Provider',
|
||||
'gpt-5',
|
||||
$3,
|
||||
'completed',
|
||||
'pending',
|
||||
0,
|
||||
$4,
|
||||
$5 + gs,
|
||||
$6
|
||||
FROM generate_series(0, $1::INTEGER - 1) AS gs
|
||||
"#,
|
||||
)
|
||||
.bind(i32::try_from(requests).unwrap_or(i32::MAX))
|
||||
.bind(REQUEST_PREFIX)
|
||||
.bind(PROVIDER_ID)
|
||||
.bind(COST_PER_REQUEST_USD)
|
||||
.bind(now_unix_ms() as i64)
|
||||
.bind(now_unix_secs() as i64)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn settlement_input(index: usize) -> UsageSettlementInput {
|
||||
UsageSettlementInput {
|
||||
request_id: format!("{REQUEST_PREFIX}-{index:08}"),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
api_key_is_standalone: false,
|
||||
provider_id: Some(PROVIDER_ID.to_string()),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 0.0,
|
||||
actual_total_cost_usd: COST_PER_REQUEST_USD,
|
||||
finalized_at_unix_secs: Some(now_unix_secs().saturating_add(index as u64)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_lock_sample(pool: &PgPool) -> Result<LockSample, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE wait_event_type = 'Lock')::BIGINT AS lock_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE '%UPDATE "usage"%'
|
||||
)::BIGINT AS usage_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE '%UPDATE wallets%'
|
||||
)::BIGINT AS wallet_update_waiters,
|
||||
COUNT(*) FILTER (
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE '%UPDATE providers%'
|
||||
)::BIGINT AS provider_update_waiters,
|
||||
COALESCE(
|
||||
MAX(EXTRACT(EPOCH FROM (NOW() - query_start)) * 1000)
|
||||
FILTER (WHERE wait_event_type = 'Lock'),
|
||||
0
|
||||
)::BIGINT AS oldest_lock_wait_ms
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(LockSample {
|
||||
lock_waiters: row.try_get("lock_waiters")?,
|
||||
usage_update_waiters: row.try_get("usage_update_waiters")?,
|
||||
wallet_update_waiters: row.try_get("wallet_update_waiters")?,
|
||||
provider_update_waiters: row.try_get("provider_update_waiters")?,
|
||||
oldest_lock_wait_ms: row.try_get("oldest_lock_wait_ms")?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_counters(pool: &PgPool, requests: usize) -> Result<CounterReport, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*)::BIGINT FROM "usage" WHERE request_id LIKE $1) AS usage_rows,
|
||||
(
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM "usage"
|
||||
WHERE request_id LIKE $1 AND billing_status = 'settled'
|
||||
) AS settled_usage_rows,
|
||||
(
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM usage_settlement_snapshots
|
||||
WHERE request_id LIKE $1
|
||||
) AS settlement_snapshot_rows,
|
||||
(
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM usage_counter_deltas
|
||||
WHERE request_id LIKE $1 AND processed_at IS NULL
|
||||
) AS outbox_pending_rows,
|
||||
(
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM usage_counter_deltas
|
||||
WHERE request_id LIKE $1 AND processed_at IS NOT NULL
|
||||
) AS outbox_processed_rows,
|
||||
(
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM usage_counter_deltas
|
||||
WHERE request_id LIKE $1 AND kind = 'provider_monthly'
|
||||
) AS provider_monthly_outbox_rows,
|
||||
(
|
||||
SELECT CAST(monthly_used_usd AS DOUBLE PRECISION)
|
||||
FROM providers
|
||||
WHERE id = $2
|
||||
) AS provider_monthly_used_usd
|
||||
"#,
|
||||
)
|
||||
.bind(format!("{REQUEST_PREFIX}-%"))
|
||||
.bind(PROVIDER_ID)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(CounterReport {
|
||||
usage_rows: row.try_get("usage_rows")?,
|
||||
settled_usage_rows: row.try_get("settled_usage_rows")?,
|
||||
settlement_snapshot_rows: row.try_get("settlement_snapshot_rows")?,
|
||||
outbox_pending_rows: row.try_get("outbox_pending_rows")?,
|
||||
outbox_processed_rows: row.try_get("outbox_processed_rows")?,
|
||||
provider_monthly_outbox_rows: row.try_get("provider_monthly_outbox_rows")?,
|
||||
provider_monthly_used_usd: row.try_get("provider_monthly_used_usd")?,
|
||||
expected_provider_monthly_used_usd: (requests as f64) * COST_PER_REQUEST_USD,
|
||||
})
|
||||
}
|
||||
|
||||
fn summarize_latencies(mut latencies: Vec<u64>) -> (u64, u64, u64, u64) {
|
||||
if latencies.is_empty() {
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
latencies.sort_unstable();
|
||||
let p50 = percentile(&latencies, 50);
|
||||
let p95 = percentile(&latencies, 95);
|
||||
let max = *latencies.last().unwrap_or(&0);
|
||||
let mean = latencies.iter().sum::<u64>() / latencies.len() as u64;
|
||||
(p50, p95, max, mean)
|
||||
}
|
||||
|
||||
fn percentile(latencies: &[u64], percentile: usize) -> u64 {
|
||||
let index = ((latencies.len() - 1) * percentile) / 100;
|
||||
latencies[index]
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut config = Config::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--requests" => config.requests = next_value(&mut iter, "--requests")?.parse()?,
|
||||
"--concurrency" => {
|
||||
config.concurrency = next_value(&mut iter, "--concurrency")?.parse()?
|
||||
}
|
||||
"--max-connections" => {
|
||||
config.max_connections = next_value(&mut iter, "--max-connections")?.parse()?
|
||||
}
|
||||
"--flush-batch-size" => {
|
||||
config.flush_batch_size = next_value(&mut iter, "--flush-batch-size")?.parse()?
|
||||
}
|
||||
"--flush-interval-ms" => {
|
||||
config.flush_interval =
|
||||
Duration::from_millis(next_value(&mut iter, "--flush-interval-ms")?.parse()?)
|
||||
}
|
||||
"--monitor-interval-ms" => {
|
||||
config.monitor_interval =
|
||||
Duration::from_millis(next_value(&mut iter, "--monitor-interval-ms")?.parse()?)
|
||||
}
|
||||
"--postgres-url" => {
|
||||
config.postgres_url = Some(next_value(&mut iter, "--postgres-url")?)
|
||||
}
|
||||
"--output" => {
|
||||
config.output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?))
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.requests == 0 || config.concurrency == 0 || config.max_connections == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"requests, concurrency, and max-connections must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-integration-tests --bin usage_settlement_hotspot_baseline -- [--requests 2000] [--concurrency 100] [--max-connections 64] [--flush-batch-size 1000] [--flush-interval-ms 100] [--monitor-interval-ms 100] [--postgres-url postgres://...] [--output /tmp/usage_settlement_hotspot.json]"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user