Skip to main content

tibba_sql/
lib.rs

1// Copyright 2026 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
30/// 该 crate 所有日志事件的 tracing target。
31/// 可通过 `RUST_LOG=tibba:sql=info`(或 `debug`)进行过滤。
32const LOG_TARGET: &str = "tibba:sql";
33
34#[derive(Debug, Snafu)]
35pub enum Error {
36    /// SQLx 数据库操作错误,属于基础设施异常。
37    #[snafu(display("sqlx error: {source}"))]
38    Sqlx {
39        #[snafu(source(from(sqlx::Error, Box::new)))]
40        source: Box<sqlx::Error>,
41    },
42    /// 配置字段校验失败(如连接数范围越界等)。
43    #[snafu(display("validate error: {source}"))]
44    Validate {
45        #[snafu(source(from(validator::ValidationErrors, Box::new)))]
46        source: Box<validator::ValidationErrors>,
47    },
48    /// 读取应用配置失败。
49    #[snafu(display("config error: {source}"))]
50    Config {
51        #[snafu(source(from(tibba_config::Error, Box::new)))]
52        source: Box<tibba_config::Error>,
53    },
54    /// 数据库 URI 解析失败。
55    #[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/// 数据库连接池配置,字段均通过 URI 查询参数解析后填充。
79///
80/// `url` 含明文密码,因此不派生 `Debug`:手写实现用 `redacted_url` 顶替,
81/// 避免凭据随 `{:?}` 进入日志或错误信息。
82#[derive(Clone, Default, Validate)]
83#[validate(schema(function = "validate_pool_bounds"))]
84pub struct DatabaseConfig {
85    /// 实际用于建连的 URL(含密码,已去除连接池查询参数)
86    #[validate(length(min = 10))]
87    pub url: String,
88    /// 密码已替换为 [`PASSWORD_MASK`] 的 URL,仅用于日志输出
89    pub redacted_url: String,
90    /// 连接池最大连接数(2–1000)
91    #[validate(range(min = 2, max = 1000))]
92    pub max_connections: u32,
93    /// 连接池最小保活连接数(0–10)
94    #[validate(range(min = 0, max = 10))]
95    pub min_connections: u32,
96    /// 取得连接的超时时间(含排队等空闲连接与新建连接),映射到 sqlx 的
97    /// `acquire_timeout`。此前解析了却从未应用,实际生效的是 sqlx 默认的 30s。
98    pub connect_timeout: Duration,
99    /// 连接空闲超时时间,超出后连接将被回收
100    pub idle_timeout: Duration,
101    /// 连接最大存活时间,超出后强制重建
102    pub max_lifetime: Duration,
103    /// 取出连接前是否先执行健康检测
104    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            // 用脱敏 URL 顶替含密码的 `url` 字段
111            .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/// 从 URI 查询字符串反序列化的连接池参数,未设置时使用各自的默认值。
142#[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/// 连接池运行时统计,所有计数器在每次调用 `stat()` 时原子性地读取并重置为 0。
162#[derive(Debug, Default)]
163pub struct PoolStat {
164    /// 自上次读取以来新建的连接数
165    connected: AtomicU32,
166    /// 自上次读取以来连接被取出(acquire)的次数
167    executions: AtomicUsize,
168    /// 自上次读取以来所有连接取出前累计的空闲时间(秒)
169    idle_for: AtomicU64,
170}
171
172impl PoolStat {
173    /// 原子性地读取并重置所有计数器,返回 `(新建连接数, 取出次数, 累计空闲秒数)`。
174    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
182/// 本 crate 自己消费的连接池参数名;建连前只剥离这些,其余原样交给 sqlx。
183const POOL_PARAMS: &[&str] = &[
184    "max_connections",
185    "min_connections",
186    "connect_timeout",
187    "idle_timeout",
188    "max_lifetime",
189    "test_before_acquire",
190];
191
192/// 从 URL 查询串里**只**剥离连接池参数,保留 Postgres 自身的连接参数。
193///
194/// 此前是 `url.set_query(None)`——整个查询串一起丢掉,`sslmode=require`、
195/// `sslrootcert`、`application_name`、`options` 全部被静默删除。配置了
196/// `sslmode=require` 的部署实际以 sqlx 默认的 `prefer` 建连:不校验证书,服务端
197/// 不支持 TLS 时直接退回明文,而且没有任何报错或告警。与此前 Redis 的
198/// `rediss://` 被硬编码成 `redis://` 是同一类静默降级。
199fn 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
212/// 最小连接数不得超过最大连接数:否则连接池的保活目标永远达不到,
213/// sqlx 会在后台不停尝试补齐连接。
214fn 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
225/// 脱敏 URL 中替换真实密码的占位符。
226const PASSWORD_MASK: &str = "***";
227
228/// 生成日志用的脱敏 URL:有密码则替换为 [`PASSWORD_MASK`],无密码原样返回。
229///
230/// 按 URL 结构替换而非 `str::replace`:后者是子串匹配,密码若恰好是 `5432`
231/// 或主机名的一部分,会把端口 / 主机一起改掉。
232fn redact_url(mut url: Url) -> String {
233    if url.password().is_some() && url.set_password(Some(PASSWORD_MASK)).is_err() {
234        // 带密码的 URL 必然有 host,理论上不会失败;兜底也绝不回退到明文
235        return String::from("<unprintable database url>");
236    }
237    url.to_string()
238}
239
240/// 从应用配置中解析并校验 `DatabaseConfig`。
241/// URL 去除连接池查询参数后用于建连,同时生成一份脱敏副本供日志使用。
242fn 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    // 只剥离连接池参数;sslmode 等 Postgres 连接参数必须保留,见 strip_pool_params
248    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
265/// 根据配置创建并连接 PostgreSQL 连接池。
266/// 若提供了 `pool_stat`,则通过 `after_connect` 和 `before_acquire` 钩子
267/// 原子性地记录新建连接数、取出次数和连接空闲时间。
268pub 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                    // 新建连接后累加计数并打印日志
292                    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                    // 取出连接前记录空闲时间和连接年龄,便于监控连接复用情况。
305                    // 每次取连接(≈ 每条查询)都会触发,用 debug 级别避免生产环境刷屏
306                    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        // 密码与端口同为 5432:子串替换会连端口一起改掉,按结构替换不会
333        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    /// **回归守卫**:Postgres 自身的连接参数(尤其 sslmode)必须保留到建连 URL。
352    ///
353    /// 此前 `set_query(None)` 把它们和连接池参数一起丢掉,`sslmode=require`
354    /// 被静默降级成 sqlx 默认的 `prefer`。
355    #[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        // 连接池参数不得混进建连 URL
375        assert!(
376            pairs
377                .iter()
378                .all(|(k, _)| !POOL_PARAMS.contains(&k.as_str())),
379            "{pairs:?}"
380        );
381        // 但仍被正确解析
382        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}