use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PoolerHint {
PgBouncer,
RdsProxy,
Supabase,
Neon,
PoolerPort(u16),
}
impl std::fmt::Display for PoolerHint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PgBouncer => f.write_str("PgBouncer"),
Self::RdsProxy => f.write_str("Amazon RDS Proxy"),
Self::Supabase => f.write_str("Supabase pooler"),
Self::Neon => f.write_str("Neon pooler"),
Self::PoolerPort(port) => write!(f, "a pooler port ({port})"),
}
}
}
struct Endpoint {
host: String,
port: Option<u16>,
pgbouncer_flag: bool,
}
fn endpoint(target: &str) -> Option<Endpoint> {
if let Ok(url) = url::Url::parse(target) {
if !matches!(url.scheme(), "postgres" | "postgresql") {
return None;
}
let mut endpoint = Endpoint {
host: url.host_str().unwrap_or_default().to_owned(),
port: url.port(),
pgbouncer_flag: false,
};
for (key, value) in url.query_pairs() {
match &*key {
"host" => endpoint.host = value.into_owned(),
"port" => endpoint.port = value.parse().ok(),
"pgbouncer" => endpoint.pgbouncer_flag = value == "true",
_ => {}
}
}
return Some(endpoint);
}
let pairs = crate::pg_conn_str::keyword_value_pairs(target)?;
let mut endpoint = Endpoint {
host: String::new(),
port: None,
pgbouncer_flag: false,
};
for (key, value) in pairs {
match key.as_str() {
"host" => endpoint.host = value,
"port" => endpoint.port = value.parse().ok(),
_ => {}
}
}
Some(endpoint)
}
pub fn pooler_hint(target: &str) -> Option<PoolerHint> {
let Endpoint {
host,
port,
pgbouncer_flag,
} = endpoint(target)?;
let host = host.to_ascii_lowercase();
if host.contains(".proxy-") && host.ends_with(".rds.amazonaws.com") {
return Some(PoolerHint::RdsProxy);
}
if host.ends_with(".pooler.supabase.com") {
return (port == Some(6543)).then_some(PoolerHint::Supabase);
}
if host.ends_with(".neon.tech") && host.split('.').next()?.ends_with("-pooler") {
return Some(PoolerHint::Neon);
}
if port == Some(6432) || host.contains("pgbouncer") || pgbouncer_flag {
return Some(PoolerHint::PgBouncer);
}
if port == Some(6543) {
return Some(PoolerHint::PoolerPort(6543));
}
None
}
pub fn warn_if_pooled(target: &str) -> bool {
static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let Some(pooler) = pooler_hint(target) else {
return false;
};
let redacted = crate::db_url::redact_target(target);
let first = WARNED
.get_or_init(Mutex::default)
.lock()
.map_or(true, |mut seen| seen.insert(redacted.clone()));
if first {
tracing::warn!(
target = %redacted,
pooler = %pooler,
"the database target points to a connection pooler ({pooler}). In transaction \
mode, session advisory locks are not safe: `Lock` and the migration lock can \
leak or lose their lock. Use `LeaseLock` for \
mutual exclusion, and run migrations on a direct connection. See \
docs/guide/distributed-locks.md, \"Connection poolers\"."
);
}
first
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_postgres_is_not_a_pooler() {
assert_eq!(pooler_hint("postgres://u:p@localhost:5432/app"), None);
assert_eq!(pooler_hint("postgres://u:p@db.internal/app"), None);
}
#[test]
fn pgbouncer_port_and_host_are_poolers() {
assert_eq!(
pooler_hint("postgres://u:p@db:6432/app"),
Some(PoolerHint::PgBouncer)
);
assert_eq!(
pooler_hint("postgres://u:p@pgbouncer.internal:5432/app"),
Some(PoolerHint::PgBouncer)
);
assert_eq!(
pooler_hint("postgres://u:p@db:5432/app?pgbouncer=true"),
Some(PoolerHint::PgBouncer)
);
}
#[test]
fn libpq_forms_are_read() {
assert_eq!(
pooler_hint("host=db port=6432 user=app password=secret dbname=app"),
Some(PoolerHint::PgBouncer)
);
assert_eq!(
pooler_hint("postgres:///app?host=db&port=6432"),
Some(PoolerHint::PgBouncer)
);
assert_eq!(pooler_hint("host=db port=5432 dbname=app"), None);
}
#[test]
fn managed_poolers_are_detected() {
assert_eq!(
pooler_hint("postgres://u:p@app.proxy-abc123.us-east-1.rds.amazonaws.com:5432/app"),
Some(PoolerHint::RdsProxy)
);
assert_eq!(
pooler_hint("postgres://u:p@aws-0-eu-west-1.pooler.supabase.com:6543/postgres"),
Some(PoolerHint::Supabase)
);
assert_eq!(
pooler_hint("postgres://u:p@ep-cool-name-123456-pooler.us-east-2.aws.neon.tech/app"),
Some(PoolerHint::Neon)
);
}
#[test]
fn supabase_session_mode_is_not_flagged() {
assert_eq!(
pooler_hint("postgres://u:p@aws-0-eu-west-1.pooler.supabase.com:5432/postgres"),
None
);
}
#[test]
fn port_6543_elsewhere_is_a_generic_pooler_port() {
assert_eq!(
pooler_hint("postgres://u:p@db.internal:6543/app"),
Some(PoolerHint::PoolerPort(6543))
);
}
#[test]
fn non_postgres_and_garbage_are_not_poolers() {
assert_eq!(pooler_hint("sqlite://app.db"), None);
assert_eq!(pooler_hint("not a url"), None);
assert_eq!(pooler_hint(""), None);
}
#[test]
fn hint_names_the_pooler() {
assert!(PoolerHint::PgBouncer.to_string().contains("PgBouncer"));
assert!(PoolerHint::RdsProxy.to_string().contains("RDS Proxy"));
assert!(PoolerHint::PoolerPort(6543).to_string().contains("6543"));
}
#[test]
fn each_target_warns_once() {
let target = "postgres://u:p@warn-once-test:6432/app";
assert!(warn_if_pooled(target), "the first build warns");
assert!(
!warn_if_pooled(target),
"a second pool on one target does not"
);
assert!(!warn_if_pooled("postgres://u:p@direct-test:5432/app"));
}
}