#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DatabaseFailureKind {
PoolExhausted,
ConnectionLost,
Other,
}
impl DatabaseFailureKind {
pub fn classify(rendered: &str) -> Self {
let text = rendered.to_ascii_lowercase();
if text.contains("pool timed out while waiting for an open connection")
|| text.contains("pool timed out")
{
return Self::PoolExhausted;
}
if text.contains("connection reset by peer")
|| text.contains("connection closed")
|| text.contains("error communicating with database")
|| text.contains("server closed the connection unexpectedly")
{
return Self::ConnectionLost;
}
Self::Other
}
pub fn as_str(&self) -> &'static str {
match self {
Self::PoolExhausted => "database.pool_exhausted",
Self::ConnectionLost => "database.connection_lost",
Self::Other => "database.other",
}
}
pub fn is_shared_incident(&self) -> bool {
matches!(self, Self::PoolExhausted | Self::ConnectionLost)
}
pub fn shared_message(&self) -> Option<&'static str> {
match self {
Self::PoolExhausted => Some("database connection pool exhausted"),
Self::ConnectionLost => Some("database connection lost"),
Self::Other => None,
}
}
}
pub fn log_database_failure(
subsystem: &'static str,
fallback: &'static str,
rendered: &str,
) -> DatabaseFailureKind {
let kind = DatabaseFailureKind::classify(rendered);
match kind.shared_message() {
Some(shared) => tracing::error!(
subsystem,
db_failure = kind.as_str(),
error = %rendered,
"{shared}"
),
None => tracing::error!(
subsystem,
db_failure = kind.as_str(),
error = %rendered,
"{fallback}"
),
}
kind
}
#[cfg(test)]
mod tests {
use super::*;
const PROD_POOL_TIMEOUT: &str =
"Failed to claim due schedules: pool timed out while waiting for an open connection";
const PROD_CONNECTION_RESET: &str = "Failed to claim due schedules: error communicating with database: Connection reset by peer (os error 104)";
const PROD_CLAIM_TASKS_RESET: &str = "Failed to claim tasks: database error: error communicating with database: Connection reset by peer (os error 104)";
#[test]
fn the_prod_pool_timeout_classifies_as_exhaustion() {
assert_eq!(
DatabaseFailureKind::classify(PROD_POOL_TIMEOUT),
DatabaseFailureKind::PoolExhausted
);
}
#[test]
fn the_prod_connection_resets_classify_as_a_lost_connection() {
for rendered in [PROD_CONNECTION_RESET, PROD_CLAIM_TASKS_RESET] {
assert_eq!(
DatabaseFailureKind::classify(rendered),
DatabaseFailureKind::ConnectionLost,
"{rendered}"
);
}
}
#[test]
fn every_starved_subsystem_agrees_on_one_message() {
let renderings = [
PROD_POOL_TIMEOUT,
"Failed to claim tasks: pool timed out while waiting for an open connection",
"failed to process due schedules: pool timed out while waiting for an open connection",
"Observer scoring batch failed: pool timed out while waiting for an open connection",
];
let messages: std::collections::BTreeSet<_> = renderings
.iter()
.map(|rendered| DatabaseFailureKind::classify(rendered).shared_message())
.collect();
assert_eq!(
messages,
std::collections::BTreeSet::from([Some("database connection pool exhausted")])
);
}
#[test]
fn classification_ignores_case_and_surrounding_context() {
assert_eq!(
DatabaseFailureKind::classify("Store(Database(\"POOL TIMED OUT\"))"),
DatabaseFailureKind::PoolExhausted
);
}
#[test]
fn an_ordinary_query_error_keeps_the_call_sites_own_message() {
let kind = DatabaseFailureKind::classify(
"duplicate key value violates unique constraint \"sessions_pkey\"",
);
assert_eq!(kind, DatabaseFailureKind::Other);
assert!(!kind.is_shared_incident());
assert_eq!(kind.shared_message(), None);
}
#[test]
fn shared_incidents_are_the_ones_with_shared_wording() {
for kind in [
DatabaseFailureKind::PoolExhausted,
DatabaseFailureKind::ConnectionLost,
DatabaseFailureKind::Other,
] {
assert_eq!(
kind.is_shared_incident(),
kind.shared_message().is_some(),
"{kind:?}"
);
}
}
#[test]
fn kinds_have_distinct_stable_identifiers() {
let ids: std::collections::BTreeSet<_> = [
DatabaseFailureKind::PoolExhausted,
DatabaseFailureKind::ConnectionLost,
DatabaseFailureKind::Other,
]
.iter()
.map(|kind| kind.as_str())
.collect();
assert_eq!(ids.len(), 3);
assert_eq!(
DatabaseFailureKind::PoolExhausted.as_str(),
"database.pool_exhausted"
);
}
#[test]
fn logging_reports_the_classification_it_used() {
assert_eq!(
log_database_failure(
"durable.tasks.claim",
"Failed to claim tasks",
PROD_POOL_TIMEOUT
),
DatabaseFailureKind::PoolExhausted
);
assert_eq!(
log_database_failure(
"durable.tasks.claim",
"Failed to claim tasks",
"no such row"
),
DatabaseFailureKind::Other
);
}
}