refactor(workspace): enforce layered crate boundaries

This commit is contained in:
elky
2026-07-15 23:47:19 +08:00
parent a728c090a9
commit 8616fe6ee2
969 changed files with 40187 additions and 27240 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
use std::time::Duration;
use aether_loadtools::{run_http_load_probe, HttpLoadProbeConfig};
use reqwest::Method;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = parse_args(std::env::args().skip(1).collect())?;
let result = run_http_load_probe(&config)
.await
.map_err(std::io::Error::other)?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
fn parse_args(args: Vec<String>) -> Result<HttpLoadProbeConfig, Box<dyn std::error::Error>> {
let mut url: Option<String> = None;
let mut warmup_url: Option<String> = None;
let mut total_requests: Option<usize> = None;
let mut concurrency: Option<usize> = None;
let mut warmup_connections: usize = 0;
let mut timeout_ms: Option<u64> = None;
let mut connect_timeout_ms: Option<u64> = None;
let mut client_shards: Option<usize> = None;
let mut pool_max_idle_per_host: Option<usize> = None;
let mut start_ramp_ms: u64 = 0;
let mut first_body_hold_ms: u64 = 0;
let mut method = Method::GET;
let mut headers = std::collections::BTreeMap::new();
let mut body: Option<Vec<u8>> = None;
let mut response_mode = aether_loadtools::HttpLoadProbeResponseMode::HeadersOnly;
let mut http1_only = false;
let mut http2_prior_knowledge = false;
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--url" => url = Some(next_value(&mut iter, "--url")?),
"--warmup-url" => warmup_url = Some(next_value(&mut iter, "--warmup-url")?),
"--requests" => total_requests = Some(next_value(&mut iter, "--requests")?.parse()?),
"--concurrency" => concurrency = Some(next_value(&mut iter, "--concurrency")?.parse()?),
"--warmup-connections" => {
warmup_connections = next_value(&mut iter, "--warmup-connections")?.parse()?
}
"--timeout-ms" => timeout_ms = Some(next_value(&mut iter, "--timeout-ms")?.parse()?),
"--connect-timeout-ms" => {
connect_timeout_ms = Some(next_value(&mut iter, "--connect-timeout-ms")?.parse()?)
}
"--client-shards" => {
client_shards = Some(next_value(&mut iter, "--client-shards")?.parse()?)
}
"--pool-max-idle-per-host" => {
pool_max_idle_per_host =
Some(next_value(&mut iter, "--pool-max-idle-per-host")?.parse()?)
}
"--start-ramp-ms" => {
start_ramp_ms = next_value(&mut iter, "--start-ramp-ms")?.parse()?
}
"--first-body-hold-ms" => {
first_body_hold_ms = next_value(&mut iter, "--first-body-hold-ms")?.parse()?
}
"--http1-only" => http1_only = true,
"--http2-prior-knowledge" => http2_prior_knowledge = true,
"--method" => {
method = Method::from_bytes(next_value(&mut iter, "--method")?.as_bytes())?
}
"--header" | "-H" => {
let (name, value) = parse_header_arg(&next_value(&mut iter, "--header")?)?;
headers.insert(name, value);
}
"--body" => body = Some(next_value(&mut iter, "--body")?.into_bytes()),
"--body-file" => body = Some(std::fs::read(next_value(&mut iter, "--body-file")?)?),
"--response-mode" => {
response_mode = parse_response_mode(&next_value(&mut iter, "--response-mode")?)?
}
"--help" | "-h" => {
print_usage();
std::process::exit(0);
}
other => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown argument: {other}"),
)
.into());
}
}
}
let mut config = HttpLoadProbeConfig {
url: url.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing required --url")
})?,
total_requests: total_requests.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing required --requests",
)
})?,
concurrency: concurrency.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing required --concurrency",
)
})?,
method,
headers,
body,
response_mode,
..HttpLoadProbeConfig::default()
};
config.warmup_url = warmup_url;
config.warmup_connections = warmup_connections;
if let Some(timeout_ms) = timeout_ms {
config.timeout = Duration::from_millis(timeout_ms);
}
config.connect_timeout = connect_timeout_ms.map(Duration::from_millis);
if let Some(client_shards) = client_shards {
config.client_shards = client_shards;
}
config.pool_max_idle_per_host = pool_max_idle_per_host;
config.start_ramp = Duration::from_millis(start_ramp_ms);
config.first_body_hold = Duration::from_millis(first_body_hold_ms);
config.http1_only = http1_only;
config.http2_prior_knowledge = http2_prior_knowledge;
config
.validate()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
Ok(config)
}
fn parse_header_arg(value: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
let (name, value) = value
.split_once(':')
.or_else(|| value.split_once('='))
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--header expects `Name: value` or `Name=value`",
)
})?;
let name = name.trim();
if name.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--header name cannot be empty",
)
.into());
}
Ok((name.to_string(), value.trim().to_string()))
}
fn parse_response_mode(
value: &str,
) -> Result<aether_loadtools::HttpLoadProbeResponseMode, Box<dyn std::error::Error>> {
match value.trim().to_ascii_lowercase().as_str() {
"headers" | "headers-only" | "header" => {
Ok(aether_loadtools::HttpLoadProbeResponseMode::HeadersOnly)
}
"first-body-byte" | "first-body" | "first-byte" | "first-chunk" => {
Ok(aether_loadtools::HttpLoadProbeResponseMode::FirstBodyByte)
}
"full" | "full-body" | "body" => Ok(aether_loadtools::HttpLoadProbeResponseMode::FullBody),
other => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"unsupported --response-mode {other}; expected headers, first-body-byte, or full"
),
)
.into()),
}
}
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-loadtools --bin http_load_probe -- --url <URL> --requests <N> --concurrency <N> [--warmup-url <URL>] [--warmup-connections N] [--method GET] [--timeout-ms 30000] [--connect-timeout-ms 10000] [--client-shards 1] [--pool-max-idle-per-host N] [--start-ramp-ms 0] [--first-body-hold-ms 0] [--http1-only | --http2-prior-knowledge] [-H 'Name: value'] [--body JSON | --body-file path] [--response-mode headers|first-body-byte|full]"
);
}
@@ -0,0 +1,435 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};
use aether_loadtools::{init_load_runtime_for, ManagedRedisServer};
use aether_runtime_state::{
RedisClientConfig, RedisConsumerGroup, RedisConsumerName, RedisStreamName,
RedisStreamReclaimConfig, RedisStreamRunner, RedisStreamRunnerConfig,
};
use serde::Serialize;
#[derive(Debug, Clone)]
struct RedisWorkerBaselineConfig {
append_total: usize,
append_concurrency: usize,
reclaim_total: usize,
reclaim_min_idle: Duration,
output_path: Option<PathBuf>,
redis_url: Option<String>,
}
impl Default for RedisWorkerBaselineConfig {
fn default() -> Self {
Self {
append_total: 1_000,
append_concurrency: 20,
reclaim_total: 128,
reclaim_min_idle: Duration::from_millis(100),
output_path: None,
redis_url: None,
}
}
}
#[derive(Debug, 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, Serialize)]
struct RedisWorkerBaselineReport {
suite: &'static str,
redis_url: String,
append: OperationSummary,
read_group: OperationSummary,
reclaim: OperationSummary,
ack: OperationSummary,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_load_runtime_for("redis-worker-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: &RedisWorkerBaselineConfig,
) -> Result<RedisWorkerBaselineReport, 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 redis_config = RedisClientConfig {
url: redis_url.clone(),
key_prefix: Some(format!("aether-baseline-{}", std::process::id())),
};
let keyspace = redis_config.keyspace();
let stream = keyspace.stream_name("worker-baseline");
let group = RedisConsumerGroup("worker-group".to_string());
let consumer_a = RedisConsumerName("consumer-a".to_string());
let consumer_b = RedisConsumerName("consumer-b".to_string());
let runner = RedisStreamRunner::from_config(
redis_config,
RedisStreamRunnerConfig {
command_timeout_ms: Some(2_000),
read_block_ms: Some(10),
read_count: 64,
},
)
.await?;
runner
.ensure_consumer_group(&stream, &group, "0-0")
.await
.map_err(std::io::Error::other)?;
let append = benchmark_append(&runner, &stream, config).await?;
let (read_group, drained_ids) =
benchmark_read_group(&runner, &stream, &group, &consumer_a, config).await?;
let ack_read = benchmark_ack(&runner, &stream, &group, &drained_ids).await?;
let (reclaim, reclaimed_ids) =
benchmark_reclaim(&runner, &stream, &group, &consumer_a, &consumer_b, config).await?;
let ack_reclaim = benchmark_ack(&runner, &stream, &group, &reclaimed_ids).await?;
Ok(RedisWorkerBaselineReport {
suite: "redis_worker_baseline",
redis_url,
append,
read_group,
reclaim,
ack: combine_summaries(&ack_read, &ack_reclaim),
})
}
async fn benchmark_append(
runner: &RedisStreamRunner,
stream: &RedisStreamName,
config: &RedisWorkerBaselineConfig,
) -> Result<OperationSummary, Box<dyn std::error::Error>> {
let next = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let latencies = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(
config.append_total,
)));
let failed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut tasks = tokio::task::JoinSet::new();
for _ in 0..config.append_concurrency {
let runner = runner.clone();
let stream = stream.clone();
let next = next.clone();
let latencies = latencies.clone();
let failed = failed.clone();
let total = config.append_total;
tasks.spawn(async move {
loop {
let current = next.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
if current >= total {
break;
}
let started = Instant::now();
let result = runner
.append_json(
&stream,
"payload",
&serde_json::json!({
"job_id": current,
"kind": "baseline",
}),
)
.await;
latencies
.lock()
.await
.push(started.elapsed().as_millis() as u64);
if result.is_err() {
failed.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
}
}
});
}
while let Some(result) = tasks.join_next().await {
result.map_err(std::io::Error::other)?;
}
let samples = latencies.lock().await.clone();
Ok(summarize_operation(
samples,
config.append_total,
failed.load(std::sync::atomic::Ordering::Acquire),
))
}
async fn benchmark_read_group(
runner: &RedisStreamRunner,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
consumer: &RedisConsumerName,
config: &RedisWorkerBaselineConfig,
) -> Result<(OperationSummary, Vec<String>), Box<dyn std::error::Error>> {
let mut latencies = Vec::new();
let mut failed = 0usize;
let mut ids = Vec::with_capacity(config.append_total);
while ids.len() < config.append_total {
let started = Instant::now();
match runner.read_group(stream, group, consumer).await {
Ok(entries) => {
latencies.push(started.elapsed().as_millis() as u64);
ids.extend(entries.into_iter().map(|entry| entry.id));
}
Err(_) => {
latencies.push(started.elapsed().as_millis() as u64);
failed += 1;
}
}
}
let summary = summarize_operation(latencies, ids.len(), failed);
Ok((summary, ids))
}
async fn benchmark_reclaim(
runner: &RedisStreamRunner,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
consumer_a: &RedisConsumerName,
consumer_b: &RedisConsumerName,
config: &RedisWorkerBaselineConfig,
) -> Result<(OperationSummary, Vec<String>), Box<dyn std::error::Error>> {
for index in 0..config.reclaim_total {
runner
.append_json(
stream,
"payload",
&serde_json::json!({
"job_id": format!("reclaim-{index}"),
"kind": "baseline",
}),
)
.await
.map_err(std::io::Error::other)?;
}
let mut pending_ids = Vec::with_capacity(config.reclaim_total);
while pending_ids.len() < config.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.reclaim_min_idle + Duration::from_millis(20)).await;
let mut latencies = Vec::new();
let mut failed = 0usize;
let mut reclaimed_ids = Vec::with_capacity(config.reclaim_total);
let mut next_start_id = "0-0".to_string();
while reclaimed_ids.len() < config.reclaim_total {
let started = Instant::now();
match runner
.claim_stale(
stream,
group,
consumer_b,
&next_start_id,
RedisStreamReclaimConfig {
min_idle_ms: config.reclaim_min_idle.as_millis() as u64,
count: 64,
},
)
.await
{
Ok(result) => {
latencies.push(started.elapsed().as_millis() as u64);
next_start_id = result.next_start_id;
reclaimed_ids.extend(result.entries.into_iter().map(|entry| entry.id));
if next_start_id == "0-0" && reclaimed_ids.len() < config.reclaim_total {
failed += 1;
break;
}
}
Err(_) => {
latencies.push(started.elapsed().as_millis() as u64);
failed += 1;
}
}
}
let summary = summarize_operation(latencies, reclaimed_ids.len(), failed);
Ok((summary, reclaimed_ids))
}
async fn benchmark_ack(
runner: &RedisStreamRunner,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
ids: &[String],
) -> Result<OperationSummary, Box<dyn std::error::Error>> {
let mut latencies = Vec::new();
let mut failed = 0usize;
let mut acked = 0usize;
for chunk in ids.chunks(64) {
let started = Instant::now();
match runner.ack(stream, group, chunk).await {
Ok(count) => {
latencies.push(started.elapsed().as_millis() as u64);
acked += count;
}
Err(_) => {
latencies.push(started.elapsed().as_millis() as u64);
failed += 1;
}
}
}
Ok(summarize_operation(latencies, acked, failed))
}
fn summarize_operation(
latencies: Vec<u64>,
total_items: usize,
failed_calls: usize,
) -> OperationSummary {
if latencies.is_empty() {
return OperationSummary {
total_calls: 0,
total_items,
failed_calls,
p50_ms: 0,
p95_ms: 0,
max_ms: 0,
mean_ms: 0,
};
}
let mut sorted = latencies;
sorted.sort_unstable();
let total_calls = sorted.len();
let max_ms = *sorted.last().unwrap_or(&0);
let mean_ms = sorted.iter().sum::<u64>() / total_calls as u64;
let p50_ms = percentile(&sorted, 50);
let p95_ms = percentile(&sorted, 95);
OperationSummary {
total_calls,
total_items,
failed_calls,
p50_ms,
p95_ms,
max_ms,
mean_ms,
}
}
fn combine_summaries(first: &OperationSummary, second: &OperationSummary) -> OperationSummary {
let total_calls = first.total_calls + second.total_calls;
let total_items = first.total_items + second.total_items;
let failed_calls = first.failed_calls + second.failed_calls;
let max_ms = first.max_ms.max(second.max_ms);
let mean_ms = if total_calls == 0 {
0
} else {
((first.mean_ms * first.total_calls as u64) + (second.mean_ms * second.total_calls as u64))
/ total_calls as u64
};
OperationSummary {
total_calls,
total_items,
failed_calls,
p50_ms: first.p50_ms.min(second.p50_ms),
p95_ms: first.p95_ms.max(second.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<RedisWorkerBaselineConfig, Box<dyn std::error::Error>> {
let mut config = RedisWorkerBaselineConfig::default();
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--append-total" => {
config.append_total = next_value(&mut iter, "--append-total")?.parse()?
}
"--append-concurrency" => {
config.append_concurrency =
next_value(&mut iter, "--append-concurrency")?.parse()?
}
"--reclaim-total" => {
config.reclaim_total = next_value(&mut iter, "--reclaim-total")?.parse()?
}
"--reclaim-min-idle-ms" => {
config.reclaim_min_idle =
Duration::from_millis(next_value(&mut iter, "--reclaim-min-idle-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-loadtools --bin redis_worker_baseline -- [--append-total 1000] [--append-concurrency 20] [--reclaim-total 128] [--reclaim-min-idle-ms 100] [--redis-url redis://127.0.0.1:6379/0] [--output /tmp/redis_worker_baseline.json]"
);
}
@@ -0,0 +1,549 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use aether_loadtools::{init_load_runtime_for, ManagedRedisServer};
use aether_runtime_state::{
DataLayerError, RedisClientConfig, RedisRuntimeDiagnostics, RuntimeQueueStore,
RuntimeSemaphoreConfig, RuntimeState,
};
use futures_util::stream::{self, StreamExt};
use serde::Serialize;
#[derive(Debug, Clone)]
struct RuntimeRedisPressureConfig {
kv_total: usize,
kv_concurrency: usize,
lock_total: usize,
lock_concurrency: usize,
semaphore_total: usize,
semaphore_concurrency: usize,
stream_total: usize,
stream_concurrency: usize,
blocking_probe_total: usize,
blocking_probe_concurrency: usize,
command_timeout_ms: u64,
output_path: Option<PathBuf>,
redis_url: Option<String>,
}
impl Default for RuntimeRedisPressureConfig {
fn default() -> Self {
Self {
kv_total: 20_000,
kv_concurrency: 200,
lock_total: 10_000,
lock_concurrency: 100,
semaphore_total: 5_000,
semaphore_concurrency: 100,
stream_total: 10_000,
stream_concurrency: 100,
blocking_probe_total: 1_000,
blocking_probe_concurrency: 100,
command_timeout_ms: 2_000,
output_path: None,
redis_url: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
struct OperationSummary {
total_calls: usize,
total_items: usize,
failed_calls: usize,
p50_ms: u64,
p95_ms: u64,
p99_ms: u64,
max_ms: u64,
mean_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
struct RuntimeRedisPressureReport {
suite: &'static str,
redis_url: String,
total_connections_before: Option<u64>,
total_connections_after: Option<u64>,
total_connections_delta: Option<i64>,
connected_clients_after: Option<u64>,
diagnostics_after: RedisRuntimeDiagnostics,
kv: OperationSummary,
lock: OperationSummary,
semaphore: OperationSummary,
stream_append: OperationSummary,
stream_read_group: OperationSummary,
stream_ack: OperationSummary,
blocking_fast_lane_probe: OperationSummary,
}
#[derive(Default)]
struct SummaryCollector {
latencies_ms: tokio::sync::Mutex<Vec<u64>>,
total_calls: AtomicUsize,
total_items: AtomicUsize,
failed_calls: 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, Ordering::AcqRel);
self.total_items.fetch_add(items, Ordering::AcqRel);
if failed {
self.failed_calls.fetch_add(1, Ordering::AcqRel);
}
}
async fn summarize(&self) -> OperationSummary {
let mut latencies = self.latencies_ms.lock().await.clone();
latencies.sort_unstable();
let total_calls = self.total_calls.load(Ordering::Acquire);
OperationSummary {
total_calls,
total_items: self.total_items.load(Ordering::Acquire),
failed_calls: self.failed_calls.load(Ordering::Acquire),
p50_ms: percentile(&latencies, 50),
p95_ms: percentile(&latencies, 95),
p99_ms: percentile(&latencies, 99),
max_ms: latencies.last().copied().unwrap_or_default(),
mean_ms: if total_calls == 0 {
0
} else {
latencies.iter().sum::<u64>() / total_calls as u64
},
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_load_runtime_for("runtime-redis-pressure");
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: &RuntimeRedisPressureConfig,
) -> Result<RuntimeRedisPressureReport, 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 resolve");
let runtime = Arc::new(
RuntimeState::redis(
RedisClientConfig {
url: redis_url.clone(),
key_prefix: Some(format!("aether-runtime-pressure-{}", std::process::id())),
},
Some(config.command_timeout_ms),
)
.await?,
);
let before = runtime
.redis_diagnostics()
.await?
.expect("redis diagnostics should be available");
let kv = benchmark_kv(runtime.clone(), config).await;
let lock = benchmark_lock(runtime.clone(), config).await;
let semaphore = benchmark_semaphore(runtime.clone(), config).await?;
let (stream_append, stream_read_group, stream_ack) =
benchmark_stream(runtime.clone(), config).await;
let blocking_fast_lane_probe = benchmark_blocking_fast_lane(runtime.clone(), config).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
let after = runtime
.redis_diagnostics()
.await?
.expect("redis diagnostics should be available");
let total_connections_delta = match (
before.total_connections_received,
after.total_connections_received,
) {
(Some(before), Some(after)) => Some(after as i64 - before as i64),
_ => None,
};
Ok(RuntimeRedisPressureReport {
suite: "runtime_redis_pressure",
redis_url,
total_connections_before: before.total_connections_received,
total_connections_after: after.total_connections_received,
total_connections_delta,
connected_clients_after: after.connected_clients,
diagnostics_after: after,
kv,
lock,
semaphore,
stream_append,
stream_read_group,
stream_ack,
blocking_fast_lane_probe,
})
}
async fn benchmark_kv(
runtime: Arc<RuntimeState>,
config: &RuntimeRedisPressureConfig,
) -> OperationSummary {
let collector = Arc::new(SummaryCollector::default());
stream::iter(0..config.kv_total)
.for_each_concurrent(config.kv_concurrency, |index| {
let runtime = runtime.clone();
let collector = collector.clone();
async move {
let started = Instant::now();
let value = format!("value-{index}");
let key = format!("pressure:kv:{index}");
let result = async {
runtime
.kv_set(&key, value.clone(), Some(Duration::from_secs(60)))
.await?;
let actual = runtime.kv_get(&key).await?;
if actual.as_deref() != Some(value.as_str()) {
return Err(DataLayerError::UnexpectedValue(format!(
"kv pressure mismatch for {key}"
)));
}
runtime.kv_delete(&key).await?;
Ok::<usize, DataLayerError>(3)
}
.await;
let failed = result.is_err();
collector
.record(started.elapsed(), result.unwrap_or(0), failed)
.await;
}
})
.await;
collector.summarize().await
}
async fn benchmark_lock(
runtime: Arc<RuntimeState>,
config: &RuntimeRedisPressureConfig,
) -> OperationSummary {
let collector = Arc::new(SummaryCollector::default());
stream::iter(0..config.lock_total)
.for_each_concurrent(config.lock_concurrency, |index| {
let runtime = runtime.clone();
let collector = collector.clone();
async move {
let started = Instant::now();
let key = format!("pressure:lock:{index}");
let owner = format!("owner-{index}");
let result = async {
let Some(lease) = runtime
.lock_try_acquire(&key, &owner, Duration::from_secs(30))
.await?
else {
return Err(DataLayerError::UnexpectedValue(format!(
"lock pressure acquire returned none for {key}"
)));
};
if !runtime.lock_renew(&lease, Duration::from_secs(30)).await? {
return Err(DataLayerError::UnexpectedValue(format!(
"lock pressure renew returned false for {key}"
)));
}
if !runtime.lock_release(&lease).await? {
return Err(DataLayerError::UnexpectedValue(format!(
"lock pressure release returned false for {key}"
)));
}
Ok::<usize, DataLayerError>(3)
}
.await;
let failed = result.is_err();
collector
.record(started.elapsed(), result.unwrap_or(0), failed)
.await;
}
})
.await;
collector.summarize().await
}
async fn benchmark_semaphore(
runtime: Arc<RuntimeState>,
config: &RuntimeRedisPressureConfig,
) -> Result<OperationSummary, Box<dyn std::error::Error>> {
let collector = Arc::new(SummaryCollector::default());
let semaphore = Arc::new(runtime.semaphore(
"redis_pressure",
config.semaphore_concurrency.saturating_mul(4).max(1),
RuntimeSemaphoreConfig {
lease_ttl_ms: 10_000,
renew_interval_ms: 5_000,
command_timeout_ms: Some(config.command_timeout_ms),
},
)?);
stream::iter(0..config.semaphore_total)
.for_each_concurrent(config.semaphore_concurrency, |_| {
let semaphore = semaphore.clone();
let collector = collector.clone();
async move {
let started = Instant::now();
let result = semaphore.try_acquire().await;
let failed = result.is_err();
drop(result);
collector
.record(started.elapsed(), usize::from(!failed), failed)
.await;
}
})
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(collector.summarize().await)
}
async fn benchmark_stream(
runtime: Arc<RuntimeState>,
config: &RuntimeRedisPressureConfig,
) -> (OperationSummary, OperationSummary, OperationSummary) {
let stream_name = "pressure-stream";
let group = "pressure-workers";
let consumer = "consumer-a";
RuntimeQueueStore::ensure_consumer_group(runtime.as_ref(), stream_name, group, "0-0")
.await
.expect("stream consumer group should initialize");
let append = Arc::new(SummaryCollector::default());
stream::iter(0..config.stream_total)
.for_each_concurrent(config.stream_concurrency, |index| {
let runtime = runtime.clone();
let append = append.clone();
async move {
let started = Instant::now();
let mut fields = BTreeMap::new();
fields.insert("payload".to_string(), format!("stream-value-{index}"));
let result = RuntimeQueueStore::append_fields_with_maxlen(
runtime.as_ref(),
stream_name,
&fields,
Some(config.stream_total.saturating_mul(2)),
)
.await;
append
.record(
started.elapsed(),
usize::from(result.is_ok()),
result.is_err(),
)
.await;
}
})
.await;
let read = SummaryCollector::default();
let mut ids = Vec::with_capacity(config.stream_total);
while ids.len() < config.stream_total {
let started = Instant::now();
match RuntimeQueueStore::read_group(
runtime.as_ref(),
stream_name,
group,
consumer,
128,
Some(10),
)
.await
{
Ok(entries) => {
let item_count = entries.len();
ids.extend(entries.into_iter().map(|entry| entry.id));
read.record(started.elapsed(), item_count, false).await;
}
Err(_) => {
read.record(started.elapsed(), 0, true).await;
}
}
}
let ack = SummaryCollector::default();
for chunk in ids.chunks(128) {
let started = Instant::now();
match RuntimeQueueStore::ack(runtime.as_ref(), stream_name, group, chunk).await {
Ok(count) => ack.record(started.elapsed(), count, false).await,
Err(_) => ack.record(started.elapsed(), 0, true).await,
}
}
(
append.summarize().await,
read.summarize().await,
ack.summarize().await,
)
}
async fn benchmark_blocking_fast_lane(
runtime: Arc<RuntimeState>,
config: &RuntimeRedisPressureConfig,
) -> Result<OperationSummary, Box<dyn std::error::Error>> {
let stream_name = "pressure-blocking-empty";
let group = "pressure-blocking-workers";
RuntimeQueueStore::ensure_consumer_group(runtime.as_ref(), stream_name, group, "0-0").await?;
let blocking_runtime = runtime.clone();
let blocking = tokio::spawn(async move {
RuntimeQueueStore::read_group(
blocking_runtime.as_ref(),
stream_name,
group,
"blocked-consumer",
1,
Some(1_000),
)
.await
});
tokio::time::sleep(Duration::from_millis(50)).await;
let summary = benchmark_fast_lane_probe(runtime, config).await;
let _ = blocking.await?;
Ok(summary)
}
async fn benchmark_fast_lane_probe(
runtime: Arc<RuntimeState>,
config: &RuntimeRedisPressureConfig,
) -> OperationSummary {
let collector = Arc::new(SummaryCollector::default());
stream::iter(0..config.blocking_probe_total)
.for_each_concurrent(config.blocking_probe_concurrency, |index| {
let runtime = runtime.clone();
let collector = collector.clone();
async move {
let started = Instant::now();
let key = format!("pressure:blocking-probe:{index}");
let result = async {
runtime
.kv_set(&key, "ok", Some(Duration::from_secs(30)))
.await?;
let ok = runtime.kv_get(&key).await?.as_deref() == Some("ok");
if !ok {
return Err(DataLayerError::UnexpectedValue(format!(
"blocking fast lane probe mismatch for {key}"
)));
}
Ok::<usize, DataLayerError>(2)
}
.await;
let failed = result.is_err();
collector
.record(started.elapsed(), result.unwrap_or(0), failed)
.await;
}
})
.await;
collector.summarize().await
}
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<RuntimeRedisPressureConfig, Box<dyn std::error::Error>> {
let mut config = RuntimeRedisPressureConfig::default();
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--kv-total" => config.kv_total = next_value(&mut iter, "--kv-total")?.parse()?,
"--kv-concurrency" => {
config.kv_concurrency = next_value(&mut iter, "--kv-concurrency")?.parse()?
}
"--lock-total" => config.lock_total = next_value(&mut iter, "--lock-total")?.parse()?,
"--lock-concurrency" => {
config.lock_concurrency = next_value(&mut iter, "--lock-concurrency")?.parse()?
}
"--semaphore-total" => {
config.semaphore_total = next_value(&mut iter, "--semaphore-total")?.parse()?
}
"--semaphore-concurrency" => {
config.semaphore_concurrency =
next_value(&mut iter, "--semaphore-concurrency")?.parse()?
}
"--stream-total" => {
config.stream_total = next_value(&mut iter, "--stream-total")?.parse()?
}
"--stream-concurrency" => {
config.stream_concurrency =
next_value(&mut iter, "--stream-concurrency")?.parse()?
}
"--blocking-probe-total" => {
config.blocking_probe_total =
next_value(&mut iter, "--blocking-probe-total")?.parse()?
}
"--blocking-probe-concurrency" => {
config.blocking_probe_concurrency =
next_value(&mut iter, "--blocking-probe-concurrency")?.parse()?
}
"--command-timeout-ms" => {
config.command_timeout_ms =
next_value(&mut iter, "--command-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-loadtools --bin runtime_redis_pressure -- [--kv-total 20000] [--kv-concurrency 200] [--lock-total 10000] [--lock-concurrency 100] [--semaphore-total 5000] [--semaphore-concurrency 100] [--stream-total 10000] [--stream-concurrency 100] [--blocking-probe-total 1000] [--blocking-probe-concurrency 100] [--command-timeout-ms 2000] [--redis-url redis://127.0.0.1:6379/0] [--output /tmp/runtime_redis_pressure.json]"
);
}