use super::*;
use ecat_circuit_breaker::BreakerState;
use ecat_data::{BackendKind, timeout_counter};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
async fn spawn_slow(delay: Duration, in_flight: Arc<AtomicUsize>) -> String {
let app = axum::Router::new().fallback(move |_req: axum::http::Request<axum::body::Body>| {
let in_flight = Arc::clone(&in_flight);
async move {
in_flight.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(delay).await;
in_flight.fetch_sub(1, Ordering::SeqCst);
axum::response::Response::new(axum::body::Body::from(""))
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
async fn spawn_slow_once(delay: Duration) -> String {
let seen = Arc::new(AtomicUsize::new(0));
let app = axum::Router::new().fallback(move |_req: axum::http::Request<axum::body::Body>| {
let seen = Arc::clone(&seen);
async move {
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
tokio::time::sleep(delay).await;
}
axum::response::Response::new(axum::body::Body::from("{}"))
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
async fn spawn_barrier(n: usize) -> String {
let barrier = Arc::new(tokio::sync::Barrier::new(n));
let app = axum::Router::new().fallback(move |_req: axum::http::Request<axum::body::Body>| {
let barrier = Arc::clone(&barrier);
async move {
barrier.wait().await;
axum::response::Response::new(axum::body::Body::from("{}"))
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
async fn assert_times_out<T, F>(label: &str, fut: F) -> Error
where
T: std::fmt::Debug,
F: std::future::Future<Output = Result<T, Error>>,
{
let before = timeout_counter(BackendKind::Tsdb).load(Ordering::SeqCst);
let err = tokio::time::timeout(Duration::from_secs(5), fut)
.await
.unwrap_or_else(|_| panic!("{label}: 内层超时没开火(漏包 guarded?)"))
.unwrap_err();
assert_eq!(err.code, ErrorCode::DeadlineExceeded, "{label}: {err}");
assert_eq!(err.reason, "tsdb", "{label}: 超时 reason 应是 kind.slug()");
assert!(
timeout_counter(BackendKind::Tsdb).load(Ordering::SeqCst) > before,
"{label}: 超时必须计入 Tsdb 维度"
);
err
}
fn one_point() -> [DataPoint; 1] {
[DataPoint::new("cpu").with_field("v", FieldValue::Int(1))]
}
fn client_at(url: &str, timeout_secs: u64, max_concurrency: Option<usize>) -> IotdbClient {
let mc = match max_concurrency {
Some(n) => format!(r#", "max_concurrency": {n}"#),
None => String::new(),
};
let cfg: IotdbConfig = serde_json::from_str(&format!(
r#"{{"base_url": "{url}", "username": "root", "password": "root",
"query_timeout_secs": {timeout_secs}{mc}}}"#
))
.unwrap();
IotdbClient::from_config(cfg).unwrap()
}
#[tokio::test]
async fn config_wires_timeout_concurrency_and_breaker() {
let cfg: IotdbConfig = serde_json::from_str(
r#"{"base_url":"http://127.0.0.1:1","username":"root","password":"root",
"query_timeout_secs":1,"max_concurrency":3,"breaker":{"failure_ratio":1.1}}"#,
)
.unwrap();
let c = IotdbClient::from_config(cfg).unwrap();
assert_eq!(c.query_timeout, Some(Duration::from_secs(1)));
assert_eq!(
c.semaphore.as_ref().unwrap().available_permits(),
3,
"显式给非 0 的值必须真的建出对应许可数的信号量"
);
assert_eq!(c.breaker().state(), BreakerState::Closed);
let fail = || async { Err::<(), &str>("backend down") };
for _ in 0..5 {
let _ = c.breaker().call(fail).await;
}
assert_eq!(
c.breaker().state(),
BreakerState::Closed,
"配置里的 failure_ratio: 1.1 没生效(退回了默认 0.5?5 次失败本该打不开)"
);
assert_eq!(c.breaker().opened_total(), 0);
}
#[test]
fn zero_timeout_means_disabled() {
assert_eq!(query_timeout(Some(0)), None);
assert_eq!(query_timeout(None), Some(Duration::from_secs(30)));
let cfg: IotdbConfig = serde_json::from_str(
r#"{"base_url":"http://127.0.0.1:1","username":"root","password":"root",
"query_timeout_secs":0}"#,
)
.unwrap();
assert_eq!(IotdbClient::from_config(cfg).unwrap().query_timeout, None);
}
#[tokio::test]
async fn write_times_out_and_counts_tsdb_dimension() {
let url = spawn_slow(Duration::from_secs(5), Arc::new(AtomicUsize::new(0))).await;
let c = client_at(&url, 1, None);
let witness = timeout_counter(BackendKind::Storage).load(Ordering::SeqCst);
assert_times_out("write", ecat_data::TsdbClient::write(&c, &one_point())).await;
assert_eq!(
timeout_counter(BackendKind::Storage).load(Ordering::SeqCst),
witness,
"Tsdb 的超时不得落到别的槽"
);
}
#[tokio::test]
async fn every_io_method_times_out_when_the_backend_stalls() {
let url = spawn_slow(Duration::from_secs(5), Arc::new(AtomicUsize::new(0))).await;
let c = client_at(&url, 1, None);
assert_times_out("write", ecat_data::TsdbClient::write(&c, &one_point())).await;
assert_times_out(
"query",
ecat_data::TsdbClient::query(&c, "select x from root.s"),
)
.await;
assert_eq!(
c.breaker().state(),
BreakerState::Closed,
"2 次调用打不满 5 条窗口"
);
}
#[tokio::test]
async fn whole_call_budget_covers_every_request_in_write() {
let url = spawn_slow(Duration::from_millis(600), Arc::new(AtomicUsize::new(0))).await;
let c = client_at(&url, 1, None);
let points: Vec<DataPoint> = (0..3)
.map(|i| DataPoint::new("cpu").with_field("v", FieldValue::Int(i)))
.collect();
assert_times_out("write(3 点)", ecat_data::TsdbClient::write(&c, &points)).await;
}
#[tokio::test]
async fn repeated_timeouts_open_the_breaker_and_fail_fast() {
let url = spawn_slow(Duration::from_secs(5), Arc::new(AtomicUsize::new(0))).await;
let c = client_at(&url, 1, None);
for _ in 0..5 {
let _ = ecat_data::TsdbClient::write(&c, &one_point()).await;
}
assert_eq!(c.breaker().state(), BreakerState::Open);
assert_eq!(
c.breaker().opened_total(),
1,
"超时失败必须真的打开过熔断器"
);
let start = std::time::Instant::now();
let err = ecat_data::TsdbClient::write(&c, &one_point())
.await
.expect_err("熔断已打开");
assert_eq!(err.code, ErrorCode::Unavailable, "got: {err}");
assert_eq!(err.message, "circuit breaker is open", "got: {err}");
assert_eq!(
err.reason, "iotdb",
"熔断错误的 reason 是产品名(超时路径才是 kind.slug())"
);
assert!(
start.elapsed() < Duration::from_millis(500),
"熔断拒绝必须立即返回,实际 {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn concurrency_cap_limits_in_flight_requests() {
let in_flight = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let url = spawn_slow(Duration::from_millis(50), Arc::clone(&in_flight)).await;
let c = Arc::new(client_at(&url, 30, Some(2)));
let mut handles = Vec::new();
for _ in 0..3 {
let c = Arc::clone(&c);
let peak = Arc::clone(&peak);
let in_flight = Arc::clone(&in_flight);
handles.push(tokio::spawn(async move {
let mut last = 0;
let sampler = tokio::spawn(async move {
for _ in 0..10 {
tokio::time::sleep(Duration::from_millis(5)).await;
last = last.max(in_flight.load(Ordering::SeqCst));
}
last
});
let _ = ecat_data::TsdbClient::write(c.as_ref(), &one_point()).await;
let seen = sampler.await.unwrap();
peak.fetch_max(seen, Ordering::SeqCst);
}));
}
for (i, h) in handles.into_iter().enumerate() {
tokio::time::timeout(Duration::from_secs(10), h)
.await
.unwrap_or_else(|_| panic!("第 {i} 个并发任务挂死(许可泄漏?)"))
.unwrap();
}
assert!(
peak.load(Ordering::SeqCst) <= 2,
"并发上限是 2,实测峰值 {}",
peak.load(Ordering::SeqCst)
);
}
#[tokio::test]
async fn zero_max_concurrency_means_unlimited() {
let url = spawn_barrier(5).await;
let c = Arc::new(client_at(&url, 30, Some(0)));
assert!(
c.semaphore.is_none(),
"0 应表示不限并发(不建信号量),而不是一个有限上限"
);
let mut handles = Vec::new();
for _ in 0..5 {
let c = Arc::clone(&c);
handles.push(tokio::spawn(async move {
ecat_data::TsdbClient::write(c.as_ref(), &one_point()).await
}));
}
for h in handles {
tokio::time::timeout(Duration::from_secs(2), h)
.await
.expect("5 个调用没能同时在飞(挂死或排队)—— `max_concurrency: 0` 应表示不限并发")
.unwrap()
.expect("mock 回 200 `{}`,write 能解析,应成功");
}
}
#[tokio::test]
async fn timed_out_request_returns_its_permit() {
let url = spawn_slow_once(Duration::from_secs(5)).await;
let c = client_at(&url, 1, Some(1));
let first = ecat_data::TsdbClient::write(&c, &one_point())
.await
.expect_err("第一发必须超时");
assert_eq!(first.code, ErrorCode::DeadlineExceeded, "got: {first}");
tokio::time::timeout(
Duration::from_secs(2),
ecat_data::TsdbClient::write(&c, &one_point()),
)
.await
.expect("第二发被排在许可上(超时路径没归还许可?)")
.expect("许可归还后第二次必须成功");
}
#[tokio::test]
async fn delete_default_does_not_trip_the_breaker() {
let url = spawn_slow(Duration::from_millis(10), Arc::new(AtomicUsize::new(0))).await;
let c = client_at(&url, 30, None);
for _ in 0..8 {
assert!(
ecat_data::TsdbClient::delete(&c, "DELETE FROM cpu")
.await
.is_err()
);
}
assert_eq!(
c.breaker().state(),
BreakerState::Closed,
"「不支持」不是后端故障,不得计入熔断窗口"
);
assert_eq!(c.breaker().opened_total(), 0);
}