mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
feat: 引入 aether-runtime/cache/data/http/testkit 基础 crate,完善并发门控与审计系统
新增 crate: - aether-runtime: 服务运行时基础设施(并发门控、分布式并发、指标、队列、优雅关闭、tracing) - aether-cache: 通用 TTL 缓存与命名空间抽象 - aether-data: 数据访问层(PostgreSQL/Redis 后端、repository 模式) - aether-http: HTTP 客户端封装(重试、配置) - aether-testkit: 集成测试工具集(gateway/executor/hub/proxy fixture、等待、负载测试) gateway 扩展: - 引入 audit 模块(shadow 执行审计、决策链路追踪、请求审计 bundle) - 引入 cache 模块(AuthContext 缓存、direct-plan bypass 缓存) - 引入 data 模块(auth/candidates/config/usage/video_tasks 数据访问) - 集成 ConcurrencyGate/DistributedConcurrencyGate 请求门控 - 新增本地 auth 拒绝、过载响应构建器 - 补充 control/auth_cache/video/concurrency 集成测试 aether-proxy 扩展: - AppState 集成 stream_gate / distributed_stream_gate 并发门控 - 新增 ProxyAdmissionError 及准入拒绝流程 - stream_handler 补充门控饱和/不可用场景测试 - 配置与注册客户端逻辑完善 aether-hub 扩展: - main.rs 引入运行时初始化、指标端点、健康检查 - local_relay 重构为 lib.rs 暴露公共接口
This commit is contained in:
@@ -0,0 +1,696 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_data::postgres::{
|
||||
PostgresLeaseClaimOptions, PostgresLeaseClaimSpec, PostgresLeaseRunnerConfig,
|
||||
PostgresPoolConfig, PostgresTransactionOptions,
|
||||
};
|
||||
use aether_data::redis::{RedisClientConfig, RedisLockRunnerConfig};
|
||||
use aether_data::{PostgresBackend, RedisBackend};
|
||||
use aether_testkit::{
|
||||
init_test_runtime_for, reserve_local_port, HubHarness, HubHarnessConfig, ManagedPostgresServer,
|
||||
ManagedRedisServer,
|
||||
};
|
||||
use futures_util::{FutureExt, StreamExt};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
#[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,
|
||||
hub_attempts: usize,
|
||||
hub_concurrency: usize,
|
||||
hub_hold: Duration,
|
||||
hub_restart_delay: Duration,
|
||||
hub_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),
|
||||
hub_attempts: 60,
|
||||
hub_concurrency: 4,
|
||||
hub_hold: Duration::from_millis(50),
|
||||
hub_restart_delay: Duration::from_millis(200),
|
||||
hub_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,
|
||||
max_ms: u64,
|
||||
mean_ms: u64,
|
||||
phase_counts: PhaseCounts,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FailureRecoveryBaselineReport {
|
||||
suite: &'static str,
|
||||
redis_url: String,
|
||||
postgres_url: String,
|
||||
redis_restart: RecoverySummary,
|
||||
postgres_slow_query: PostgresSlowQueryRecoveryReport,
|
||||
hub_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>) -> RecoverySummary {
|
||||
let mut latencies = self.latencies_ms.lock().await.clone();
|
||||
latencies.sort_unstable();
|
||||
let (p50_ms, p95_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,
|
||||
max_ms,
|
||||
mean_ms,
|
||||
phase_counts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 hub_restart = benchmark_hub_restart_recovery(config).await?;
|
||||
|
||||
Ok(FailureRecoveryBaselineReport {
|
||||
suite: "failure_recovery_baseline",
|
||||
redis_url,
|
||||
postgres_url,
|
||||
redis_restart,
|
||||
postgres_slow_query,
|
||||
hub_restart,
|
||||
})
|
||||
}
|
||||
|
||||
async fn benchmark_redis_restart_recovery(
|
||||
redis_server: Arc<Mutex<ManagedRedisServer>>,
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<RecoverySummary, Box<dyn std::error::Error>> {
|
||||
let redis_url = redis_server.lock().await.redis_url().to_string();
|
||||
let backend = RedisBackend::from_config(RedisClientConfig {
|
||||
url: redis_url,
|
||||
key_prefix: Some(format!("aether-failure-recovery-{}", std::process::id())),
|
||||
})?;
|
||||
let runner = backend.lock_runner(RedisLockRunnerConfig {
|
||||
command_timeout_ms: Some(250),
|
||||
default_ttl_ms: 1_000,
|
||||
})?;
|
||||
let keyspace = backend.keyspace();
|
||||
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))
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn benchmark_postgres_slow_query_recovery(
|
||||
postgres_url: &str,
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<PostgresSlowQueryRecoveryReport, Box<dyn std::error::Error>> {
|
||||
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?;
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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_hub_restart_recovery(
|
||||
config: &FailureRecoveryBaselineConfig,
|
||||
) -> Result<RecoverySummary, Box<dyn std::error::Error>> {
|
||||
let port = reserve_local_port()?;
|
||||
let hub_config = HubHarnessConfig::default();
|
||||
let initial_hub = HubHarness::start_on_port(hub_config.clone(), port).await?;
|
||||
let ws_url = format!("ws://127.0.0.1:{port}/proxy");
|
||||
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 hub_restart_delay = config.hub_restart_delay;
|
||||
let hub_downtime = config.hub_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(hub_restart_delay).await;
|
||||
phase_for_restart.store(1, Ordering::Release);
|
||||
*restart_started_for_task.lock().await = Some(Instant::now());
|
||||
drop(initial_hub);
|
||||
tokio::time::sleep(hub_downtime).await;
|
||||
let restarted_hub = start_hub_on_port_retry(hub_config, port).await?;
|
||||
phase_for_restart.store(2, Ordering::Release);
|
||||
let _ = done_rx.await;
|
||||
drop(restarted_hub);
|
||||
Ok::<(), String>(())
|
||||
});
|
||||
|
||||
let mut workers = tokio::task::JoinSet::new();
|
||||
for worker_index in 0..config.hub_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.hub_hold;
|
||||
let total_attempts = config.hub_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(
|
||||
"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!("hub recovery worker task failed: {err}"))?
|
||||
.map_err(|err| format!("hub recovery worker failed: {err}"))?;
|
||||
}
|
||||
let _ = done_tx.send(());
|
||||
restart_task
|
||||
.await
|
||||
.map_err(|err| format!("hub restart task failed: {err}"))?
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(collector
|
||||
.summarize(load_optional_atomic_u64(&recovered_after_restart_ms))
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn start_hub_on_port_retry(
|
||||
config: HubHarnessConfig,
|
||||
port: u16,
|
||||
) -> Result<HubHarness, String> {
|
||||
let mut attempts = 0usize;
|
||||
loop {
|
||||
match HubHarness::start_on_port(config.clone(), port).await {
|
||||
Ok(hub) => return Ok(hub),
|
||||
Err(err) => {
|
||||
attempts += 1;
|
||||
if attempts >= 20 {
|
||||
return Err(format!("failed to restart hub 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) {
|
||||
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<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()?)
|
||||
}
|
||||
"--hub-attempts" => {
|
||||
config.hub_attempts = next_value(&mut iter, "--hub-attempts")?.parse()?
|
||||
}
|
||||
"--hub-concurrency" => {
|
||||
config.hub_concurrency = next_value(&mut iter, "--hub-concurrency")?.parse()?
|
||||
}
|
||||
"--hub-hold-ms" => {
|
||||
config.hub_hold =
|
||||
Duration::from_millis(next_value(&mut iter, "--hub-hold-ms")?.parse()?)
|
||||
}
|
||||
"--hub-restart-delay-ms" => {
|
||||
config.hub_restart_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--hub-restart-delay-ms")?.parse()?)
|
||||
}
|
||||
"--hub-downtime-ms" => {
|
||||
config.hub_downtime =
|
||||
Duration::from_millis(next_value(&mut iter, "--hub-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.hub_attempts == 0
|
||||
|| config.hub_concurrency == 0
|
||||
|| config.timeout.is_zero()
|
||||
{
|
||||
return Err("all failure recovery baseline numeric settings must be positive".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user