#![allow(dead_code)]
use std::collections::VecDeque;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct SoakSnapshot {
pub elapsed_secs: u64,
pub ops_completed: u64,
pub ops_per_sec: f64,
pub pool_idle: u32,
pub pool_active: u32,
pub pool_max: u32,
pub rss_bytes: u64,
pub fd_count: u32,
pub thread_count: u32,
pub p99_latency_us: u64,
pub error_count: u64,
}
impl SoakSnapshot {
pub fn to_csv_line(&self) -> String {
format!(
"{},{},{},{:.2},{},{},{},{},{},{},{},{}\n",
self.elapsed_secs,
self.ops_completed,
self.ops_per_sec,
self.pool_idle,
self.pool_active,
self.pool_max,
self.rss_bytes,
self.fd_count,
self.thread_count,
self.p99_latency_us,
self.error_count,
chrono::Utc::now().to_rfc3339()
)
}
pub fn csv_header() -> &'static str {
"elapsed_secs,ops_completed,ops_per_sec,pool_idle,pool_active,pool_max,rss_bytes,fd_count,thread_count,p99_latency_us,error_count,timestamp\n"
}
}
pub struct SoakMonitor {
start: Instant,
duration: Duration,
sample_interval: Duration,
ops: Arc<AtomicU64>,
errors: Arc<AtomicU64>,
latency_window: Arc<std::sync::Mutex<VecDeque<u64>>>,
snapshots: Vec<SoakSnapshot>,
first_rss: Option<u64>,
first_fd: Option<u32>,
first_pool_active: Option<u32>,
}
impl SoakMonitor {
pub fn new(duration: Duration, sample_interval: Duration) -> Self {
Self {
start: Instant::now(),
duration,
sample_interval,
ops: Arc::new(AtomicU64::new(0)),
errors: Arc::new(AtomicU64::new(0)),
latency_window: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000))),
snapshots: Vec::new(),
first_rss: None,
first_fd: None,
first_pool_active: None,
}
}
pub fn ops_counter(&self) -> Arc<AtomicU64> {
self.ops.clone()
}
pub fn errors_counter(&self) -> Arc<AtomicU64> {
self.errors.clone()
}
pub fn latency_window(&self) -> Arc<std::sync::Mutex<VecDeque<u64>>> {
self.latency_window.clone()
}
pub fn is_finished(&self) -> bool {
self.start.elapsed() >= self.duration
}
pub fn elapsed(&self) -> Duration {
self.start.elapsed()
}
pub fn remaining(&self) -> Duration {
self.duration.saturating_sub(self.start.elapsed())
}
pub fn snapshot(&mut self, pool_status: (u32, u32, u32)) -> SoakSnapshot {
let elapsed_secs = self.start.elapsed().as_secs();
let ops_completed = self.ops.load(Ordering::Relaxed);
let error_count = self.errors.load(Ordering::Relaxed);
let prev_ops = self.snapshots.last().map(|s| s.ops_completed).unwrap_or(0);
let prev_elapsed = self.snapshots.last().map(|s| s.elapsed_secs).unwrap_or(0);
let dt = elapsed_secs.saturating_sub(prev_elapsed);
let ops_per_sec = if dt > 0 {
(ops_completed - prev_ops) as f64 / dt as f64
} else {
0.0
};
let p99_latency_us = {
let window = self.latency_window.lock().unwrap();
if window.is_empty() {
0
} else {
let mut sorted: Vec<u64> = window.iter().copied().collect();
sorted.sort_unstable();
let idx = ((sorted.len() as f64) * 0.99) as usize;
sorted[idx.min(sorted.len() - 1)]
}
};
let snap = SoakSnapshot {
elapsed_secs,
ops_completed,
ops_per_sec,
pool_idle: pool_status.0,
pool_active: pool_status.1,
pool_max: pool_status.2,
rss_bytes: read_rss_bytes(),
fd_count: read_fd_count(),
thread_count: read_thread_count(),
p99_latency_us,
error_count,
};
if self.first_rss.is_none() {
self.first_rss = Some(snap.rss_bytes);
}
if self.first_fd.is_none() {
self.first_fd = Some(snap.fd_count);
}
if self.first_pool_active.is_none() {
self.first_pool_active = Some(snap.pool_active);
}
self.snapshots.push(snap.clone());
snap
}
pub fn snapshots(&self) -> &[SoakSnapshot] {
&self.snapshots
}
pub fn export_csv(&self, path: &str) -> std::io::Result<()> {
let path = std::path::Path::new(path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut content = String::from(SoakSnapshot::csv_header());
for snap in &self.snapshots {
content.push_str(&snap.to_csv_line());
}
fs::write(path, content)
}
pub fn detect_regressions(&self) -> Vec<SoakRegression> {
let mut issues = Vec::new();
if self.snapshots.len() < 3 {
return issues;
}
let first = &self.snapshots[0];
let last = self.snapshots.last().unwrap();
if let Some(first_rss) = self.first_rss {
let delta = last.rss_bytes.saturating_sub(first_rss);
if delta > 50 * 1024 * 1024 {
issues.push(SoakRegression::MemoryLeak {
delta_bytes: delta,
duration_secs: last.elapsed_secs,
});
}
}
if let Some(first_fd) = self.first_fd {
let delta = last.fd_count.saturating_sub(first_fd);
if delta > 10 {
issues.push(SoakRegression::FdLeak {
delta,
duration_secs: last.elapsed_secs,
});
}
}
if last.pool_active != last.pool_idle {
issues.push(SoakRegression::PoolLeak {
final_active: last.pool_active,
final_idle: last.pool_idle,
});
}
let throughput_last = if last.ops_per_sec < 1.0 && self.snapshots.len() >= 2 {
&self.snapshots[self.snapshots.len() - 2]
} else {
last
};
let first_ops = first.ops_per_sec.max(1.0);
let last_ops = throughput_last.ops_per_sec;
if last_ops < first_ops * 0.9 {
issues.push(SoakRegression::ThroughputDecay {
initial: first_ops,
final_ops: last_ops,
decay_pct: (1.0 - last_ops / first_ops) * 100.0,
});
}
if first.p99_latency_us > 0 && last.p99_latency_us > first.p99_latency_us * 2 {
issues.push(SoakRegression::LatencyRegression {
initial_us: first.p99_latency_us,
final_us: last.p99_latency_us,
});
}
if last.error_count > 0 {
issues.push(SoakRegression::ErrorsObserved {
count: last.error_count,
});
}
issues
}
}
#[derive(Debug)]
pub enum SoakRegression {
MemoryLeak {
delta_bytes: u64,
duration_secs: u64,
},
FdLeak { delta: u32, duration_secs: u64 },
PoolLeak { final_active: u32, final_idle: u32 },
ThroughputDecay {
initial: f64,
final_ops: f64,
decay_pct: f64,
},
LatencyRegression { initial_us: u64, final_us: u64 },
ErrorsObserved { count: u64 },
}
impl std::fmt::Display for SoakRegression {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SoakRegression::MemoryLeak {
delta_bytes,
duration_secs,
} => write!(
f,
"MemoryLeak: RSS grew {} bytes over {}s",
delta_bytes, duration_secs
),
SoakRegression::FdLeak {
delta,
duration_secs,
} => write!(f, "FdLeak: fd_count grew {} over {}s", delta, duration_secs),
SoakRegression::PoolLeak {
final_active,
final_idle,
} => write!(
f,
"PoolLeak: final active={} but idle={} (should be equal)",
final_active, final_idle
),
SoakRegression::ThroughputDecay {
initial,
final_ops,
decay_pct,
} => write!(
f,
"ThroughputDecay: {:.0} -> {:.0} ops/s ({:.1}% decay)",
initial, final_ops, decay_pct
),
SoakRegression::LatencyRegression {
initial_us,
final_us,
} => write!(
f,
"LatencyRegression: p99 {}us -> {}us ({}x)",
initial_us,
final_us,
final_us / initial_us.max(&1u64)
),
SoakRegression::ErrorsObserved { count } => {
write!(f, "ErrorsObserved: {} errors during soak", count)
}
}
}
}
fn read_rss_bytes() -> u64 {
#[cfg(target_os = "linux")]
{
if let Ok(content) = fs::read_to_string("/proc/self/status") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if let Ok(kb) = parts[0].parse::<u64>() {
return kb * 1024;
}
}
}
}
0
}
#[cfg(not(target_os = "linux"))]
{
0
}
}
fn read_fd_count() -> u32 {
#[cfg(target_os = "linux")]
{
fs::read_dir("/proc/self/fd")
.map(|entries| entries.count() as u32)
.unwrap_or(0)
}
#[cfg(not(target_os = "linux"))]
{
0
}
}
fn read_thread_count() -> u32 {
#[cfg(target_os = "linux")]
{
if let Ok(content) = fs::read_to_string("/proc/self/status") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("Threads:") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if let Ok(n) = parts[0].parse::<u32>() {
return n;
}
}
}
}
0
}
#[cfg(not(target_os = "linux"))]
{
0
}
}
pub fn record_latency(window: &Arc<std::sync::Mutex<VecDeque<u64>>>, latency_us: u64) {
let mut w = window.lock().unwrap();
if w.len() >= 1000 {
w.pop_front();
}
w.push_back(latency_us);
}
pub fn parse_duration_from_args() -> Duration {
if let Ok(val) = std::env::var("SOAK_DURATION") {
if let Some(d) = parse_duration_str(&val) {
return d;
}
eprintln!("[soak] 警告:SOAK_DURATION={} 无法解析,使用默认 60s", val);
}
let args: Vec<String> = std::env::args().collect();
for arg in &args {
if let Some(rest) = arg.strip_prefix("--soak-duration=") {
return parse_duration_str(rest).unwrap_or(Duration::from_secs(60));
}
}
Duration::from_secs(60)
}
fn parse_duration_str(s: &str) -> Option<Duration> {
let s = s.trim().to_lowercase();
if let Ok(secs) = s.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str.parse().ok()?;
let secs = match unit {
"s" => num,
"m" => num.saturating_mul(60),
"h" => num.saturating_mul(3600),
"d" => num.saturating_mul(86400),
_ => return None,
};
Some(Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_str() {
assert_eq!(parse_duration_str("60s"), Some(Duration::from_secs(60)));
assert_eq!(parse_duration_str("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_duration_str("2h"), Some(Duration::from_secs(7200)));
assert_eq!(parse_duration_str("1d"), Some(Duration::from_secs(86400)));
assert_eq!(parse_duration_str("invalid"), None);
}
#[test]
fn test_snapshot_csv() {
let snap = SoakSnapshot {
elapsed_secs: 60,
ops_completed: 1000,
ops_per_sec: 16.66,
pool_idle: 5,
pool_active: 5,
pool_max: 20,
rss_bytes: 10 * 1024 * 1024,
fd_count: 10,
thread_count: 8,
p99_latency_us: 500,
error_count: 0,
};
let line = snap.to_csv_line();
assert!(line.starts_with("60,1000,"));
assert!(line.ends_with('\n'));
assert_eq!(SoakSnapshot::csv_header().matches(',').count(), 11);
assert_eq!(line.matches(',').count(), 11);
}
#[test]
fn test_regression_detection() {
let mut monitor = SoakMonitor::new(Duration::from_secs(60), Duration::from_secs(10));
monitor.first_rss = Some(10 * 1024 * 1024);
monitor.first_fd = Some(5);
monitor.first_pool_active = Some(5);
monitor.snapshots.push(SoakSnapshot {
elapsed_secs: 0,
ops_completed: 0,
ops_per_sec: 100.0,
pool_idle: 5,
pool_active: 5,
pool_max: 20,
rss_bytes: 10 * 1024 * 1024,
fd_count: 5,
thread_count: 4,
p99_latency_us: 100,
error_count: 0,
});
monitor.snapshots.push(SoakSnapshot {
elapsed_secs: 30,
ops_completed: 3000,
ops_per_sec: 100.0,
pool_idle: 5,
pool_active: 5,
pool_max: 20,
rss_bytes: 30 * 1024 * 1024,
fd_count: 8,
thread_count: 4,
p99_latency_us: 150,
error_count: 0,
});
monitor.snapshots.push(SoakSnapshot {
elapsed_secs: 60,
ops_completed: 5000,
ops_per_sec: 66.0, pool_idle: 3,
pool_active: 5, pool_max: 20,
rss_bytes: 80 * 1024 * 1024, fd_count: 20, thread_count: 4,
p99_latency_us: 300, error_count: 3,
});
let issues = monitor.detect_regressions();
assert_eq!(issues.len(), 6);
assert!(issues
.iter()
.any(|i| matches!(i, SoakRegression::MemoryLeak { .. })));
assert!(issues
.iter()
.any(|i| matches!(i, SoakRegression::FdLeak { .. })));
assert!(issues
.iter()
.any(|i| matches!(i, SoakRegression::PoolLeak { .. })));
assert!(issues
.iter()
.any(|i| matches!(i, SoakRegression::ThroughputDecay { .. })));
assert!(issues
.iter()
.any(|i| matches!(i, SoakRegression::LatencyRegression { .. })));
assert!(issues
.iter()
.any(|i| matches!(i, SoakRegression::ErrorsObserved { .. })));
}
#[test]
fn test_read_thread_count_returns_nonzero_on_linux() {
let count = read_thread_count();
#[cfg(target_os = "linux")]
{
assert!(count >= 1, "Linux thread_count should be >= 1");
}
#[cfg(not(target_os = "linux"))]
{
assert_eq!(count, 0, "Non-Linux thread_count should be 0");
let _ = count;
}
}
#[test]
fn test_snapshot_includes_thread_count_field() {
let snap = SoakSnapshot {
elapsed_secs: 1,
ops_completed: 1,
ops_per_sec: 1.0,
pool_idle: 1,
pool_active: 1,
pool_max: 1,
rss_bytes: 0,
fd_count: 0,
thread_count: 42,
p99_latency_us: 100,
error_count: 0,
};
assert_eq!(snap.thread_count, 42);
let line = snap.to_csv_line();
assert!(line.contains(",42,"));
}
#[test]
fn test_csv_header_includes_thread_count() {
let header = SoakSnapshot::csv_header();
assert!(header.contains("thread_count"));
assert_eq!(header.matches(',').count(), 11);
}
}