1use serde::Deserialize;
16use snafu::{ResultExt, Snafu};
17use sqlx::PgPool;
18use sqlx::postgres::PgPoolOptions;
19use std::fmt;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
22use std::time::Duration;
23use tibba_config::Config;
24use tibba_error::Error as BaseError;
25use tibba_util::parse_uri;
26use tracing::{debug, info};
27use url::Url;
28use validator::Validate;
29
30const LOG_TARGET: &str = "tibba:sql";
33
34#[derive(Debug, Snafu)]
35pub enum Error {
36 #[snafu(display("sqlx error: {source}"))]
38 Sqlx {
39 #[snafu(source(from(sqlx::Error, Box::new)))]
40 source: Box<sqlx::Error>,
41 },
42 #[snafu(display("validate error: {source}"))]
44 Validate {
45 #[snafu(source(from(validator::ValidationErrors, Box::new)))]
46 source: Box<validator::ValidationErrors>,
47 },
48 #[snafu(display("config error: {source}"))]
50 Config {
51 #[snafu(source(from(tibba_config::Error, Box::new)))]
52 source: Box<tibba_config::Error>,
53 },
54 #[snafu(display("parse uri error: {source}"))]
56 ParseUri {
57 #[snafu(source(from(tibba_util::Error, Box::new)))]
58 source: Box<tibba_util::Error>,
59 },
60}
61
62impl From<Error> for BaseError {
63 fn from(val: Error) -> Self {
64 let err = match val {
65 Error::Sqlx { source } => BaseError::new(source)
66 .with_sub_category("sqlx")
67 .with_exception(true),
68 Error::Validate { source } => BaseError::new(source).with_sub_category("validate"),
69 Error::Config { source } => BaseError::new(source).with_sub_category("config"),
70 Error::ParseUri { source } => BaseError::new(source).with_sub_category("parse_uri"),
71 };
72 err.with_category("sql")
73 }
74}
75
76type Result<T> = std::result::Result<T, Error>;
77
78#[derive(Clone, Default, Validate)]
83#[validate(schema(function = "validate_pool_bounds"))]
84pub struct DatabaseConfig {
85 #[validate(length(min = 10))]
87 pub url: String,
88 pub redacted_url: String,
90 #[validate(range(min = 2, max = 1000))]
92 pub max_connections: u32,
93 #[validate(range(min = 0, max = 10))]
95 pub min_connections: u32,
96 pub connect_timeout: Duration,
99 pub idle_timeout: Duration,
101 pub max_lifetime: Duration,
103 pub test_before_acquire: bool,
105}
106
107impl fmt::Debug for DatabaseConfig {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 f.debug_struct("DatabaseConfig")
110 .field("url", &self.redacted_url)
112 .field("max_connections", &self.max_connections)
113 .field("min_connections", &self.min_connections)
114 .field("connect_timeout", &self.connect_timeout)
115 .field("idle_timeout", &self.idle_timeout)
116 .field("max_lifetime", &self.max_lifetime)
117 .field("test_before_acquire", &self.test_before_acquire)
118 .finish()
119 }
120}
121
122fn default_max_connections() -> u32 {
123 10
124}
125fn default_min_connections() -> u32 {
126 2
127}
128fn default_connect_timeout() -> Duration {
129 Duration::from_secs(3)
130}
131fn default_idle_timeout() -> Duration {
132 Duration::from_secs(60)
133}
134fn default_max_lifetime() -> Duration {
135 Duration::from_secs(6 * 60 * 60)
136}
137fn default_test_before_acquire() -> bool {
138 true
139}
140
141#[derive(Deserialize, Debug, Clone)]
143struct DatabaseQuery {
144 #[serde(default = "default_max_connections")]
145 pub max_connections: u32,
146 #[serde(default = "default_min_connections")]
147 pub min_connections: u32,
148 #[serde(default = "default_connect_timeout")]
149 #[serde(with = "humantime_serde")]
150 pub connect_timeout: Duration,
151 #[serde(default = "default_idle_timeout")]
152 #[serde(with = "humantime_serde")]
153 pub idle_timeout: Duration,
154 #[serde(default = "default_max_lifetime")]
155 #[serde(with = "humantime_serde")]
156 pub max_lifetime: Duration,
157 #[serde(default = "default_test_before_acquire")]
158 pub test_before_acquire: bool,
159}
160
161#[derive(Debug, Default)]
163pub struct PoolStat {
164 connected: AtomicU32,
166 executions: AtomicUsize,
168 idle_for: AtomicU64,
170}
171
172impl PoolStat {
173 pub fn stat(&self) -> (u32, usize, u64) {
175 let connected = self.connected.swap(0, Ordering::Relaxed);
176 let executions = self.executions.swap(0, Ordering::Relaxed);
177 let idle_for = self.idle_for.swap(0, Ordering::Relaxed);
178 (connected, executions, idle_for)
179 }
180}
181
182const POOL_PARAMS: &[&str] = &[
184 "max_connections",
185 "min_connections",
186 "connect_timeout",
187 "idle_timeout",
188 "max_lifetime",
189 "test_before_acquire",
190];
191
192fn strip_pool_params(url: &mut Url) {
200 let kept: Vec<(String, String)> = url
201 .query_pairs()
202 .filter(|(k, _)| !POOL_PARAMS.contains(&k.as_ref()))
203 .map(|(k, v)| (k.into_owned(), v.into_owned()))
204 .collect();
205 if kept.is_empty() {
206 url.set_query(None);
207 } else {
208 url.query_pairs_mut().clear().extend_pairs(kept);
209 }
210}
211
212fn validate_pool_bounds(
215 config: &DatabaseConfig,
216) -> std::result::Result<(), validator::ValidationError> {
217 if config.min_connections > config.max_connections {
218 return Err(validator::ValidationError::new(
219 "min_connections must not exceed max_connections",
220 ));
221 }
222 Ok(())
223}
224
225const PASSWORD_MASK: &str = "***";
227
228fn redact_url(mut url: Url) -> String {
233 if url.password().is_some() && url.set_password(Some(PASSWORD_MASK)).is_err() {
234 return String::from("<unprintable database url>");
236 }
237 url.to_string()
238}
239
240fn new_database_config(config: &Config) -> Result<DatabaseConfig> {
243 let origin_url = config.get_string("uri").context(ConfigSnafu)?;
244 let parsed = parse_uri::<DatabaseQuery>(&origin_url).context(ParseUriSnafu)?;
245
246 let mut url = parsed.url().context(ParseUriSnafu)?;
247 strip_pool_params(&mut url);
249
250 let query = &parsed.query;
251 let database_config = DatabaseConfig {
252 url: url.to_string(),
253 redacted_url: redact_url(url),
254 max_connections: query.max_connections,
255 min_connections: query.min_connections,
256 connect_timeout: query.connect_timeout,
257 idle_timeout: query.idle_timeout,
258 max_lifetime: query.max_lifetime,
259 test_before_acquire: query.test_before_acquire,
260 };
261 database_config.validate().context(ValidateSnafu)?;
262 Ok(database_config)
263}
264
265pub async fn new_pg_pool(config: &Config, pool_stat: Option<Arc<PoolStat>>) -> Result<PgPool> {
269 let database_config = new_database_config(config)?;
270 info!(
271 target: LOG_TARGET,
272 url = database_config.redacted_url,
273 "connect to database"
274 );
275
276 let mut options = PgPoolOptions::new()
277 .max_connections(database_config.max_connections)
278 .min_connections(database_config.min_connections)
279 .acquire_timeout(database_config.connect_timeout)
280 .idle_timeout(database_config.idle_timeout)
281 .max_lifetime(database_config.max_lifetime)
282 .test_before_acquire(database_config.test_before_acquire);
283
284 if let Some(pool_stat) = pool_stat {
285 let after_connect_pool_stat = pool_stat.clone();
286 let before_acquire_pool_stat = pool_stat.clone();
287 options = options
288 .after_connect(move |_conn, _meta| {
289 let stat = after_connect_pool_stat.clone();
290 Box::pin(async move {
291 let connected = stat.connected.fetch_add(1, Ordering::Relaxed) + 1;
293 info!(
294 target: LOG_TARGET,
295 connected,
296 "after connect"
297 );
298 Ok(())
299 })
300 })
301 .before_acquire(move |_conn, meta| {
302 let stat = before_acquire_pool_stat.clone();
303 Box::pin(async move {
304 let idle = meta.idle_for.as_secs();
307 debug!(
308 target: LOG_TARGET,
309 age = meta.age.as_secs(),
310 idle,
311 "before acquire"
312 );
313 stat.executions.fetch_add(1, Ordering::Relaxed);
314 stat.idle_for.fetch_add(idle, Ordering::Relaxed);
315 Ok(true)
316 })
317 });
318 }
319
320 options
321 .connect(database_config.url.as_str())
322 .await
323 .context(SqlxSnafu)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn redact_url_replaces_only_the_password_component() {
332 let url = Url::parse("postgres://app:5432@db.internal:5432/tibba").expect("valid url");
334 assert_eq!(redact_url(url), "postgres://app:***@db.internal:5432/tibba");
335 }
336
337 #[test]
338 fn redact_url_keeps_password_free_url_unchanged() {
339 let raw = "postgres://app@db.internal:5432/tibba";
340 let url = Url::parse(raw).expect("valid url");
341 assert_eq!(redact_url(url), raw);
342 }
343
344 fn config(uri: &str) -> Config {
345 Config::builder()
346 .add_toml(format!("uri = \"{uri}\""))
347 .build()
348 .expect("构造测试配置")
349 }
350
351 #[test]
356 fn postgres_connection_params_survive_pool_param_stripping() {
357 let cfg = new_database_config(&config(
358 "postgres://app:pw@db:5432/tibba?sslmode=require&max_connections=20&application_name=tibba&idle_timeout=30s",
359 ))
360 .expect("解析配置");
361 let url = Url::parse(&cfg.url).expect("合法 URL");
362 let pairs: Vec<(String, String)> = url
363 .query_pairs()
364 .map(|(k, v)| (k.into_owned(), v.into_owned()))
365 .collect();
366 assert!(
367 pairs.contains(&("sslmode".into(), "require".into())),
368 "{pairs:?}"
369 );
370 assert!(
371 pairs.contains(&("application_name".into(), "tibba".into())),
372 "{pairs:?}"
373 );
374 assert!(
376 pairs
377 .iter()
378 .all(|(k, _)| !POOL_PARAMS.contains(&k.as_str())),
379 "{pairs:?}"
380 );
381 assert_eq!(cfg.max_connections, 20);
383 assert_eq!(cfg.idle_timeout, Duration::from_secs(30));
384 }
385
386 #[test]
387 fn url_without_extra_params_has_no_query() {
388 let cfg = new_database_config(&config("postgres://app:pw@db:5432/tibba?max_connections=5"))
389 .expect("解析配置");
390 assert_eq!(cfg.url, "postgres://app:pw@db:5432/tibba");
391 }
392
393 #[test]
394 fn min_connections_above_max_is_rejected() {
395 let err = new_database_config(&config(
396 "postgres://app:pw@db:5432/tibba?max_connections=2&min_connections=5",
397 ))
398 .expect_err("min > max 应当被拒绝");
399 assert!(matches!(err, Error::Validate { .. }), "{err}");
400 }
401
402 #[test]
403 fn debug_output_never_contains_the_password() {
404 let config = DatabaseConfig {
405 url: "postgres://app:s3cret@db.internal:5432/tibba".into(),
406 redacted_url: "postgres://app:***@db.internal:5432/tibba".into(),
407 ..Default::default()
408 };
409 let debug = format!("{config:?}");
410 assert!(
411 !debug.contains("s3cret"),
412 "密码不得出现在 Debug 输出中: {debug}"
413 );
414 assert!(debug.contains("***"), "应输出脱敏 URL: {debug}");
415 }
416}