use serde::Deserialize;
use snafu::{ResultExt, Snafu};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use tibba_config::Config;
use tibba_error::Error as BaseError;
use tibba_util::parse_uri;
use tracing::{debug, info};
use url::Url;
use validator::Validate;
const LOG_TARGET: &str = "tibba:sql";
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("sqlx error: {source}"))]
Sqlx {
#[snafu(source(from(sqlx::Error, Box::new)))]
source: Box<sqlx::Error>,
},
#[snafu(display("validate error: {source}"))]
Validate {
#[snafu(source(from(validator::ValidationErrors, Box::new)))]
source: Box<validator::ValidationErrors>,
},
#[snafu(display("config error: {source}"))]
Config {
#[snafu(source(from(tibba_config::Error, Box::new)))]
source: Box<tibba_config::Error>,
},
#[snafu(display("parse uri error: {source}"))]
ParseUri {
#[snafu(source(from(tibba_util::Error, Box::new)))]
source: Box<tibba_util::Error>,
},
}
impl From<Error> for BaseError {
fn from(val: Error) -> Self {
let err = match val {
Error::Sqlx { source } => BaseError::new(source)
.with_sub_category("sqlx")
.with_exception(true),
Error::Validate { source } => BaseError::new(source).with_sub_category("validate"),
Error::Config { source } => BaseError::new(source).with_sub_category("config"),
Error::ParseUri { source } => BaseError::new(source).with_sub_category("parse_uri"),
};
err.with_category("sql")
}
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Default, Validate)]
#[validate(schema(function = "validate_pool_bounds"))]
pub struct DatabaseConfig {
#[validate(length(min = 10))]
pub url: String,
pub redacted_url: String,
#[validate(range(min = 2, max = 1000))]
pub max_connections: u32,
#[validate(range(min = 0, max = 10))]
pub min_connections: u32,
pub connect_timeout: Duration,
pub idle_timeout: Duration,
pub max_lifetime: Duration,
pub test_before_acquire: bool,
}
impl fmt::Debug for DatabaseConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatabaseConfig")
.field("url", &self.redacted_url)
.field("max_connections", &self.max_connections)
.field("min_connections", &self.min_connections)
.field("connect_timeout", &self.connect_timeout)
.field("idle_timeout", &self.idle_timeout)
.field("max_lifetime", &self.max_lifetime)
.field("test_before_acquire", &self.test_before_acquire)
.finish()
}
}
fn default_max_connections() -> u32 {
10
}
fn default_min_connections() -> u32 {
2
}
fn default_connect_timeout() -> Duration {
Duration::from_secs(3)
}
fn default_idle_timeout() -> Duration {
Duration::from_secs(60)
}
fn default_max_lifetime() -> Duration {
Duration::from_secs(6 * 60 * 60)
}
fn default_test_before_acquire() -> bool {
true
}
#[derive(Deserialize, Debug, Clone)]
struct DatabaseQuery {
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_connect_timeout")]
#[serde(with = "humantime_serde")]
pub connect_timeout: Duration,
#[serde(default = "default_idle_timeout")]
#[serde(with = "humantime_serde")]
pub idle_timeout: Duration,
#[serde(default = "default_max_lifetime")]
#[serde(with = "humantime_serde")]
pub max_lifetime: Duration,
#[serde(default = "default_test_before_acquire")]
pub test_before_acquire: bool,
}
#[derive(Debug, Default)]
pub struct PoolStat {
connected: AtomicU32,
executions: AtomicUsize,
idle_for: AtomicU64,
}
impl PoolStat {
pub fn stat(&self) -> (u32, usize, u64) {
let connected = self.connected.swap(0, Ordering::Relaxed);
let executions = self.executions.swap(0, Ordering::Relaxed);
let idle_for = self.idle_for.swap(0, Ordering::Relaxed);
(connected, executions, idle_for)
}
}
const POOL_PARAMS: &[&str] = &[
"max_connections",
"min_connections",
"connect_timeout",
"idle_timeout",
"max_lifetime",
"test_before_acquire",
];
fn strip_pool_params(url: &mut Url) {
let kept: Vec<(String, String)> = url
.query_pairs()
.filter(|(k, _)| !POOL_PARAMS.contains(&k.as_ref()))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if kept.is_empty() {
url.set_query(None);
} else {
url.query_pairs_mut().clear().extend_pairs(kept);
}
}
fn validate_pool_bounds(
config: &DatabaseConfig,
) -> std::result::Result<(), validator::ValidationError> {
if config.min_connections > config.max_connections {
return Err(validator::ValidationError::new(
"min_connections must not exceed max_connections",
));
}
Ok(())
}
const PASSWORD_MASK: &str = "***";
fn redact_url(mut url: Url) -> String {
if url.password().is_some() && url.set_password(Some(PASSWORD_MASK)).is_err() {
return String::from("<unprintable database url>");
}
url.to_string()
}
fn new_database_config(config: &Config) -> Result<DatabaseConfig> {
let origin_url = config.get_string("uri").context(ConfigSnafu)?;
let parsed = parse_uri::<DatabaseQuery>(&origin_url).context(ParseUriSnafu)?;
let mut url = parsed.url().context(ParseUriSnafu)?;
strip_pool_params(&mut url);
let query = &parsed.query;
let database_config = DatabaseConfig {
url: url.to_string(),
redacted_url: redact_url(url),
max_connections: query.max_connections,
min_connections: query.min_connections,
connect_timeout: query.connect_timeout,
idle_timeout: query.idle_timeout,
max_lifetime: query.max_lifetime,
test_before_acquire: query.test_before_acquire,
};
database_config.validate().context(ValidateSnafu)?;
Ok(database_config)
}
pub async fn new_pg_pool(config: &Config, pool_stat: Option<Arc<PoolStat>>) -> Result<PgPool> {
let database_config = new_database_config(config)?;
info!(
target: LOG_TARGET,
url = database_config.redacted_url,
"connect to database"
);
let mut options = PgPoolOptions::new()
.max_connections(database_config.max_connections)
.min_connections(database_config.min_connections)
.acquire_timeout(database_config.connect_timeout)
.idle_timeout(database_config.idle_timeout)
.max_lifetime(database_config.max_lifetime)
.test_before_acquire(database_config.test_before_acquire);
if let Some(pool_stat) = pool_stat {
let after_connect_pool_stat = pool_stat.clone();
let before_acquire_pool_stat = pool_stat.clone();
options = options
.after_connect(move |_conn, _meta| {
let stat = after_connect_pool_stat.clone();
Box::pin(async move {
let connected = stat.connected.fetch_add(1, Ordering::Relaxed) + 1;
info!(
target: LOG_TARGET,
connected,
"after connect"
);
Ok(())
})
})
.before_acquire(move |_conn, meta| {
let stat = before_acquire_pool_stat.clone();
Box::pin(async move {
let idle = meta.idle_for.as_secs();
debug!(
target: LOG_TARGET,
age = meta.age.as_secs(),
idle,
"before acquire"
);
stat.executions.fetch_add(1, Ordering::Relaxed);
stat.idle_for.fetch_add(idle, Ordering::Relaxed);
Ok(true)
})
});
}
options
.connect(database_config.url.as_str())
.await
.context(SqlxSnafu)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_url_replaces_only_the_password_component() {
let url = Url::parse("postgres://app:5432@db.internal:5432/tibba").expect("valid url");
assert_eq!(redact_url(url), "postgres://app:***@db.internal:5432/tibba");
}
#[test]
fn redact_url_keeps_password_free_url_unchanged() {
let raw = "postgres://app@db.internal:5432/tibba";
let url = Url::parse(raw).expect("valid url");
assert_eq!(redact_url(url), raw);
}
fn config(uri: &str) -> Config {
Config::builder()
.add_toml(format!("uri = \"{uri}\""))
.build()
.expect("构造测试配置")
}
#[test]
fn postgres_connection_params_survive_pool_param_stripping() {
let cfg = new_database_config(&config(
"postgres://app:pw@db:5432/tibba?sslmode=require&max_connections=20&application_name=tibba&idle_timeout=30s",
))
.expect("解析配置");
let url = Url::parse(&cfg.url).expect("合法 URL");
let pairs: Vec<(String, String)> = url
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
assert!(
pairs.contains(&("sslmode".into(), "require".into())),
"{pairs:?}"
);
assert!(
pairs.contains(&("application_name".into(), "tibba".into())),
"{pairs:?}"
);
assert!(
pairs
.iter()
.all(|(k, _)| !POOL_PARAMS.contains(&k.as_str())),
"{pairs:?}"
);
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.idle_timeout, Duration::from_secs(30));
}
#[test]
fn url_without_extra_params_has_no_query() {
let cfg = new_database_config(&config("postgres://app:pw@db:5432/tibba?max_connections=5"))
.expect("解析配置");
assert_eq!(cfg.url, "postgres://app:pw@db:5432/tibba");
}
#[test]
fn min_connections_above_max_is_rejected() {
let err = new_database_config(&config(
"postgres://app:pw@db:5432/tibba?max_connections=2&min_connections=5",
))
.expect_err("min > max 应当被拒绝");
assert!(matches!(err, Error::Validate { .. }), "{err}");
}
#[test]
fn debug_output_never_contains_the_password() {
let config = DatabaseConfig {
url: "postgres://app:s3cret@db.internal:5432/tibba".into(),
redacted_url: "postgres://app:***@db.internal:5432/tibba".into(),
..Default::default()
};
let debug = format!("{config:?}");
assert!(
!debug.contains("s3cret"),
"密码不得出现在 Debug 输出中: {debug}"
);
assert!(debug.contains("***"), "应输出脱敏 URL: {debug}");
}
}