fix(db): expand source chain for all DB error logging
The previous fix only covered migration errors. The same truncation
affected two more places, both because tokio_postgres::Error's Display
prints only "db error" while the real message lives in source():
- api/error.rs: AppError::{db_conn,query,tx} logged with {e}, so every
runtime DB failure showed as "Query failed: db error". The
constructors' bound is tightened from impl Display+Debug to
impl std::error::Error so the source chain can be walked; the
user-facing message stays sanitized (existing tests still pass).
- db/pool.rs: ensure_database formatted tokio_postgres::Error directly
into the String returned to main.rs' exit(1) path, collapsing to
"failed to query pg_database: db error" etc.
Adds a shared crate::db::format_with_sources helper (walks source()
with de-dup of placeholder layers) and reuses it from MigrateError's
Display, the AppError constructors, and pool.rs.
This commit is contained in:
parent
e9f0b032b7
commit
383e6f6b43
@ -30,21 +30,34 @@ pub enum AppError {
|
|||||||
impl AppError {
|
impl AppError {
|
||||||
/// 记录并包装数据库连接错误。
|
/// 记录并包装数据库连接错误。
|
||||||
///
|
///
|
||||||
/// 日志仅记录 Display 摘要,避免 Debug 输出中的 SQL/参数泄露。
|
/// 对外只暴露通用提示(脱敏),但服务端日志会展开 source 链,记录完整的
|
||||||
pub fn db_conn(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
|
/// 失败原因(如 `db error: connection refused`、IO 错误等)。传入的错误类型
|
||||||
tracing::error!("DB connection failed: {e}");
|
/// 必须实现 `std::error::Error` —— DB 相关错误(`tokio_postgres::Error`、
|
||||||
|
/// `deadpool_postgres::PoolError`)均满足,这一点是上次"日志只显示 `db error`"
|
||||||
|
/// 问题的根治前提。
|
||||||
|
pub fn db_conn(e: impl std::error::Error) -> Self {
|
||||||
|
tracing::error!(
|
||||||
|
"DB connection failed: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
|
);
|
||||||
AppError::DbConn("connection error".to_string())
|
AppError::DbConn("connection error".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录并包装 SQL 查询错误。
|
/// 记录并包装 SQL 查询错误。
|
||||||
pub fn query(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
|
pub fn query(e: impl std::error::Error) -> Self {
|
||||||
tracing::error!("Query failed: {e}");
|
tracing::error!(
|
||||||
|
"Query failed: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
|
);
|
||||||
AppError::Query("query error".to_string())
|
AppError::Query("query error".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录并包装数据库事务错误。
|
/// 记录并包装数据库事务错误。
|
||||||
pub fn tx(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
|
pub fn tx(e: impl std::error::Error) -> Self {
|
||||||
tracing::error!("Transaction failed: {e}");
|
tracing::error!(
|
||||||
|
"Transaction failed: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
|
);
|
||||||
AppError::Transaction("transaction error".to_string())
|
AppError::Transaction("transaction error".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +93,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn db_conn_hides_internal_details() {
|
fn db_conn_hides_internal_details() {
|
||||||
let err: ServerFnError = AppError::db_conn("connection refused on port 5432").into();
|
// 入参是实现 Error 的类型;构造函数内部会把 source 链写进日志,
|
||||||
|
// 但对外(ServerFnError)必须只暴露通用提示。
|
||||||
|
let src = std::io::Error::other("connection refused on port 5432");
|
||||||
|
let err: ServerFnError = AppError::db_conn(src).into();
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(
|
assert!(
|
||||||
!msg.contains("5432"),
|
!msg.contains("5432"),
|
||||||
@ -94,7 +110,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn query_hides_sql_details() {
|
fn query_hides_sql_details() {
|
||||||
let err: ServerFnError = AppError::query("syntax error at SELECT * FROM").into();
|
let src = std::io::Error::other("syntax error at SELECT * FROM");
|
||||||
|
let err: ServerFnError = AppError::query(src).into();
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(!msg.contains("SELECT"), "should not leak SQL: {msg}");
|
assert!(!msg.contains("SELECT"), "should not leak SQL: {msg}");
|
||||||
}
|
}
|
||||||
@ -124,7 +141,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn transaction_hides_sql_details() {
|
fn transaction_hides_sql_details() {
|
||||||
// 事务错误同样返回通用提示,不泄露 SQL 细节。
|
// 事务错误同样返回通用提示,不泄露 SQL 细节。
|
||||||
let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into();
|
let src = std::io::Error::other("deadlock detected on UPDATE posts");
|
||||||
|
let err: ServerFnError = AppError::tx(src).into();
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
|
assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@ -91,18 +91,22 @@ impl From<tokio_postgres::Error> for MigrateError {
|
|||||||
|
|
||||||
impl std::fmt::Display for MigrateError {
|
impl std::fmt::Display for MigrateError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
// 主动展开 source 链:tokio_postgres::Error 的 Display 对 DB 侧错误只打印
|
||||||
|
// 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。
|
||||||
match self {
|
match self {
|
||||||
MigrateError::Pool(e) => {
|
MigrateError::Pool(e) => {
|
||||||
write!(f, "database pool error: {}", e)?;
|
write!(f, "database pool error: {}", crate::db::format_with_sources(e))
|
||||||
fmt_source_chain(f, e)
|
|
||||||
}
|
}
|
||||||
MigrateError::Query(e) => {
|
MigrateError::Query(e) => {
|
||||||
write!(f, "database query error: {}", e)?;
|
write!(f, "database query error: {}", crate::db::format_with_sources(e))
|
||||||
fmt_source_chain(f, e)
|
|
||||||
}
|
}
|
||||||
MigrateError::Apply { version, source } => {
|
MigrateError::Apply { version, source } => {
|
||||||
write!(f, "migration {} failed: {}", version, source)?;
|
write!(
|
||||||
fmt_source_chain(f, source)
|
f,
|
||||||
|
"migration {} failed: {}",
|
||||||
|
version,
|
||||||
|
crate::db::format_with_sources(source)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,26 +122,6 @@ impl std::error::Error for MigrateError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把错误的 `source()` 链逐层拼到 Display 输出里,用 `: ` 分隔。
|
|
||||||
///
|
|
||||||
/// 存在的原因:`tokio_postgres::Error` 的 `Display` 对 DB 侧错误只会打印
|
|
||||||
/// 无信息量的 `db error`,真正的消息文本(如
|
|
||||||
/// `column "x" of relation "y" already exists`、SQLSTATE、约束名)藏在
|
|
||||||
/// `source()` 链里的 `postgres::error::DbError`。不主动遍历链就会丢失全部上下文,
|
|
||||||
/// 日志只显示 `migration 001 failed: db error`,难以定位。
|
|
||||||
fn fmt_source_chain(
|
|
||||||
f: &mut std::fmt::Formatter<'_>,
|
|
||||||
mut source: &(dyn std::error::Error + 'static),
|
|
||||||
) -> std::fmt::Result {
|
|
||||||
while let Some(next) = source.source() {
|
|
||||||
// `DbError` 的 Display 已经包含 message(必要时还有 detail/hint),
|
|
||||||
// 直接拼上即可;避免逐层重复打印 `db error` 这类占位串。
|
|
||||||
write!(f, ": {next}")?;
|
|
||||||
source = next;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。
|
/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。
|
||||||
///
|
///
|
||||||
/// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用
|
/// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用
|
||||||
|
|||||||
@ -8,6 +8,33 @@
|
|||||||
//! 这种 stub 模式是 Dioxus fullstack 项目的常见做法:服务端函数体在 WASM 构建时会被剥离,
|
//! 这种 stub 模式是 Dioxus fullstack 项目的常见做法:服务端函数体在 WASM 构建时会被剥离,
|
||||||
//! 但模块结构必须保持一致,因此需要一个占位实现来满足编译器的符号解析。
|
//! 但模块结构必须保持一致,因此需要一个占位实现来满足编译器的符号解析。
|
||||||
|
|
||||||
|
/// 错误格式化工具:把 `std::error::Error` 的 source 链完整展开为字符串。
|
||||||
|
///
|
||||||
|
/// 存在的原因:`tokio_postgres::Error` 的 `Display` 对 DB 侧错误只会打印
|
||||||
|
/// 无信息量的占位串 `db error`,真正的消息文本(如
|
||||||
|
/// `column "x" of relation "y" already exists`、SQLSTATE、约束名)藏在
|
||||||
|
/// `source()` 链里的 `postgres::error::DbError`。不主动遍历链,日志和错误
|
||||||
|
/// 字符串就会全部折叠成 `db error`,无法定位失败原因。
|
||||||
|
///
|
||||||
|
/// 用法:`format!("...: {}", format_with_sources(&e))` 或直接
|
||||||
|
/// `format_with_sources(&e)` 得到完整的 `e: cause: deeper cause`。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub fn format_with_sources(e: &dyn std::error::Error) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let mut s = e.to_string();
|
||||||
|
let mut cur: &dyn std::error::Error = e;
|
||||||
|
while let Some(next) = cur.source() {
|
||||||
|
// 跳过与外层 Display 完全相同的占位层(如 tokio_postgres 的 `db error`),
|
||||||
|
// 避免输出 `db error: db error` 这种重复。只在能带来新信息时追加。
|
||||||
|
let next_disp = next.to_string();
|
||||||
|
if !next_disp.is_empty() && next_disp != s {
|
||||||
|
let _ = write!(s, ": {next_disp}");
|
||||||
|
}
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
/// 真实的 PostgreSQL 连接池实现,仅在启用 server feature 时编译。
|
/// 真实的 PostgreSQL 连接池实现,仅在启用 server feature 时编译。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
|
|||||||
@ -256,12 +256,14 @@ pub async fn ensure_database() -> Result<(), String> {
|
|||||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
if remaining.is_zero() {
|
if remaining.is_zero() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"could not connect to 'postgres' maintenance database within {timeout_secs}s: {e}"
|
"could not connect to 'postgres' maintenance database within {timeout_secs}s: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"ensure_database: connect to 'postgres' failed, ~{}s remaining: {e}",
|
"ensure_database: connect to 'postgres' failed, ~{}s remaining: {}",
|
||||||
remaining.as_secs()
|
remaining.as_secs(),
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
);
|
);
|
||||||
tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
|
tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
|
||||||
}
|
}
|
||||||
@ -270,7 +272,10 @@ pub async fn ensure_database() -> Result<(), String> {
|
|||||||
// 连接的后台驱动任务:出错时仅记录,连接随即作废。
|
// 连接的后台驱动任务:出错时仅记录,连接随即作废。
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = connection.await {
|
if let Err(e) = connection.await {
|
||||||
tracing::warn!("postgres maintenance connection ended: {e}");
|
tracing::warn!(
|
||||||
|
"postgres maintenance connection ended: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -281,7 +286,12 @@ pub async fn ensure_database() -> Result<(), String> {
|
|||||||
&[&db_name],
|
&[&db_name],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to query pg_database: {e}"))?
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"failed to query pg_database: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
|
)
|
||||||
|
})?
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
@ -295,7 +305,12 @@ pub async fn ensure_database() -> Result<(), String> {
|
|||||||
client
|
client
|
||||||
.batch_execute(&stmt)
|
.batch_execute(&stmt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to create database {db_name:?}: {e}"))?;
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"failed to create database {db_name:?}: {}",
|
||||||
|
crate::db::format_with_sources(&e)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
tracing::info!("created database {:?}", db_name);
|
tracing::info!("created database {:?}", db_name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user