use crate::pg::connection::PgConnection;
use crate::{DbError, DjogiError};
use deadpool_postgres::{Config, ManagerConfig, PoolConfig, RecyclingMethod, Runtime};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio_postgres::NoTls;
static NEXT_POOL_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn next_pool_id() -> u64 {
NEXT_POOL_ID.fetch_add(1, Ordering::Relaxed)
}
pub const DEFAULT_MAX_SIZE: usize = 5;
pub const ENV_DATABASE_MAX_CONNECTIONS: &str = "DJOGI_DATABASE_MAX_CONNECTIONS";
pub type ClientFuture<'a, R> = Pin<Box<dyn Future<Output = Result<R, DjogiError>> + Send + 'a>>;
pub type PostConnectFuture<'a> = ClientFuture<'a, ()>;
type PostConnectFn =
Arc<dyn for<'a> Fn(&'a mut tokio_postgres::Client) -> PostConnectFuture<'a> + Send + Sync>;
#[derive(Clone)]
pub struct DjogiPool {
pub(crate) inner: deadpool_postgres::Pool,
#[allow(dead_code)] pub(crate) url: Option<String>,
#[allow(dead_code)] pub(crate) pool_id: u64,
}
impl std::fmt::Debug for DjogiPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DjogiPool")
.field("status", &self.status())
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy)]
pub struct DjogiPoolStatus {
pub max_size: usize,
pub size: usize,
pub available: usize,
pub waiting: usize,
}
impl DjogiPoolStatus {
pub(crate) fn from_deadpool(s: deadpool_postgres::Status) -> Self {
Self {
max_size: s.max_size,
size: s.size,
available: s.available,
waiting: s.waiting,
}
}
}
impl DjogiPool {
pub async fn connect(url: &str) -> Result<Self, DjogiError> {
Self::builder(url).build().await
}
pub fn builder(url: impl Into<String>) -> DjogiPoolBuilder {
DjogiPoolBuilder::new(url.into())
}
pub async fn from_database_config(
cfg: &crate::config::DatabaseConfig,
) -> Result<Self, DjogiError> {
let mut builder = Self::builder(&cfg.url);
if let Some(n) = resolve_max_connections(cfg) {
builder = builder.max_size(n);
}
builder.build().await
}
pub fn status(&self) -> DjogiPoolStatus {
DjogiPoolStatus::from_deadpool(self.inner.status())
}
#[allow(clippy::disallowed_methods)]
pub(crate) async fn get(&self) -> Result<PgConnection, DjogiError> {
let obj = self.inner.get().await.map_err(map_pool_err)?;
Ok(PgConnection::new(obj))
}
#[allow(clippy::disallowed_methods)]
pub(crate) async fn with_client<F, R>(&self, f: F) -> Result<R, DjogiError>
where
F: for<'a> FnOnce(&'a mut tokio_postgres::Client) -> ClientFuture<'a, R>,
{
let obj = self.inner.get().await.map_err(map_pool_err)?;
let mut guard = WithClientGuard {
obj: Some(obj),
committed: false,
};
let result = {
let obj_ref = guard
.obj
.as_mut()
.expect("guard.obj is Some until commit/drop");
let client: &mut tokio_postgres::Client = obj_ref;
f(client).await
};
if result.is_ok() {
guard.committed = true;
}
drop(guard);
result
}
}
struct WithClientGuard {
obj: Option<deadpool_postgres::Object>,
committed: bool,
}
impl Drop for WithClientGuard {
fn drop(&mut self) {
if let Some(obj) = self.obj.take() {
if self.committed {
drop(obj);
} else {
let _client_wrapper = deadpool_postgres::Object::take(obj);
}
}
}
}
pub struct DjogiPoolBuilder {
url: String,
max_size: usize,
wait_timeout: Option<Duration>,
post_connect: Option<PostConnectFn>,
}
impl DjogiPoolBuilder {
fn new(url: String) -> Self {
Self {
url,
max_size: DEFAULT_MAX_SIZE,
wait_timeout: None,
post_connect: None,
}
}
pub fn max_size(mut self, value: usize) -> Self {
self.max_size = value;
self
}
pub fn timeout(mut self, value: Duration) -> Self {
self.wait_timeout = Some(value);
self
}
pub fn post_connect<F>(mut self, hook: F) -> Self
where
F: for<'a> Fn(&'a mut tokio_postgres::Client) -> PostConnectFuture<'a>
+ Send
+ Sync
+ 'static,
{
self.post_connect = Some(Arc::new(hook));
self
}
pub async fn build(self) -> Result<DjogiPool, DjogiError> {
crate::presentation::validate_startup_inventory()
.map_err(DjogiError::PresentationStartup)?;
if self.max_size == 0 {
return Err(DjogiError::Validation(
"DjogiPoolBuilder::max_size must be >= 1; \
a zero-permit pool would block every checkout indefinitely"
.to_owned(),
));
}
let url = self.url;
let mut cfg = Config::new();
cfg.url = Some(url.clone());
cfg.manager = Some(ManagerConfig {
recycling_method: RecyclingMethod::Fast,
});
cfg.pool = Some(PoolConfig::new(self.max_size));
let mut pool_builder = cfg.builder(NoTls).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"DjogiPool builder: invalid config — {e}"
)))
})?;
pool_builder = pool_builder.runtime(Runtime::Tokio1);
if let Some(d) = self.wait_timeout {
pool_builder = pool_builder.wait_timeout(Some(d));
}
if let Some(hook) = self.post_connect {
pool_builder = pool_builder.post_create(deadpool_postgres::Hook::async_fn(
move |wrapper, _metrics| {
let hook = hook.clone();
Box::pin(async move {
let client: &mut tokio_postgres::Client = &mut *wrapper;
match hook(client).await {
Ok(()) => Ok(()),
Err(e) => Err(deadpool_postgres::HookError::message(format!(
"post_connect: {e}"
))),
}
})
},
));
}
let pool = pool_builder.build().map_err(|e| {
DjogiError::Db(DbError::other(format!(
"DjogiPool::build: pool creation failed: {e}"
)))
})?;
Ok(DjogiPool {
inner: pool,
url: Some(url),
pool_id: next_pool_id(),
})
}
}
impl std::fmt::Debug for DjogiPoolBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DjogiPoolBuilder")
.field("max_size", &self.max_size)
.field("wait_timeout", &self.wait_timeout)
.field("post_connect", &self.post_connect.is_some())
.finish_non_exhaustive()
}
}
pub fn resolve_max_connections(cfg: &crate::config::DatabaseConfig) -> Option<usize> {
read_env_max_connections().or_else(|| cfg.max_connections.and_then(non_zero_size))
}
fn non_zero_size(n: u32) -> Option<usize> {
if n > 0 { Some(n as usize) } else { None }
}
fn read_env_max_connections() -> Option<usize> {
let n = std::env::var(ENV_DATABASE_MAX_CONNECTIONS)
.ok()?
.trim()
.parse::<usize>()
.ok()?;
if n == 0 { None } else { Some(n) }
}
pub(crate) fn map_pool_err(e: deadpool_postgres::PoolError) -> DjogiError {
use deadpool_postgres::PoolError as P;
match e {
P::Timeout(deadpool_postgres::TimeoutType::Wait) => {
DjogiError::PoolTimeout { phase: "wait" }
}
P::Timeout(deadpool_postgres::TimeoutType::Create) => {
DjogiError::PoolTimeout { phase: "create" }
}
P::Timeout(deadpool_postgres::TimeoutType::Recycle) => {
DjogiError::PoolTimeout { phase: "recycle" }
}
other => {
tracing::error!("DjogiPool: deadpool error: {other}");
DjogiError::Db(DbError::other(format!("pool error: {other}")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn builder_constructs_pool_with_defaults() {
let pool = DjogiPool::builder("postgres://localhost/_djogi_unreachable")
.build()
.await
.expect("builder should construct pool eagerly without connecting");
let _ = format!("{pool:?}");
}
#[tokio::test]
async fn connect_delegates_to_builder() {
let _pool = DjogiPool::connect("postgres://localhost/_djogi_unreachable")
.await
.expect("connect should construct pool with builder defaults");
}
#[tokio::test]
async fn builder_accepts_max_size_and_timeout() {
let pool = DjogiPool::builder("postgres://localhost/_djogi_unreachable")
.max_size(7)
.timeout(Duration::from_millis(50))
.build()
.await
.expect("builder should accept max_size and timeout");
let dbg = format!("{pool:?}");
assert!(
dbg.contains("max_size: 7"),
"Debug output should reflect max_size, got: {dbg}"
);
}
#[tokio::test]
async fn builder_rejects_zero_max_size() {
let err = DjogiPool::builder("postgres://localhost/_djogi_unreachable")
.max_size(0)
.build()
.await
.expect_err("max_size(0) must be rejected");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("max_size") && msg.contains(">= 1"),
"Validation message should mention max_size and >= 1; got: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got: {other:?}"),
}
}
#[test]
fn map_pool_err_lowers_timeouts() {
use deadpool_postgres::{PoolError, TimeoutType};
match map_pool_err(PoolError::Timeout(TimeoutType::Wait)) {
DjogiError::PoolTimeout { phase } => assert_eq!(phase, "wait"),
other => panic!("expected PoolTimeout(wait), got: {other:?}"),
}
match map_pool_err(PoolError::Timeout(TimeoutType::Create)) {
DjogiError::PoolTimeout { phase } => assert_eq!(phase, "create"),
other => panic!("expected PoolTimeout(create), got: {other:?}"),
}
match map_pool_err(PoolError::Timeout(TimeoutType::Recycle)) {
DjogiError::PoolTimeout { phase } => assert_eq!(phase, "recycle"),
other => panic!("expected PoolTimeout(recycle), got: {other:?}"),
}
}
#[test]
fn pool_timeout_is_transient() {
let err = DjogiError::PoolTimeout { phase: "wait" };
assert!(err.is_transient(), "PoolTimeout must be transient");
assert!(!err.is_terminal(), "PoolTimeout must NOT be terminal");
}
#[test]
fn resolve_max_connections_walks_env_then_config() {
use crate::config::DatabaseConfig;
let clear_env = || {
unsafe { std::env::remove_var(ENV_DATABASE_MAX_CONNECTIONS) };
};
clear_env();
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: None,
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), None);
clear_env();
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: Some(25),
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), Some(25));
unsafe { std::env::set_var(ENV_DATABASE_MAX_CONNECTIONS, "42") };
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: Some(25),
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), Some(42));
unsafe { std::env::set_var(ENV_DATABASE_MAX_CONNECTIONS, " ") };
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: Some(25),
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), Some(25));
unsafe { std::env::set_var(ENV_DATABASE_MAX_CONNECTIONS, "not-a-number") };
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: Some(25),
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), Some(25));
unsafe { std::env::set_var(ENV_DATABASE_MAX_CONNECTIONS, "0") };
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: Some(25),
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), Some(25));
unsafe { std::env::set_var(ENV_DATABASE_MAX_CONNECTIONS, "0") };
let cfg = DatabaseConfig {
url: String::new(),
crud_log_url: None,
event_log_url: None,
max_connections: None,
dev_mode: false,
};
assert_eq!(resolve_max_connections(&cfg), None);
clear_env();
}
#[tokio::test]
async fn from_database_config_applies_max_size() {
use crate::config::DatabaseConfig;
unsafe { std::env::remove_var(ENV_DATABASE_MAX_CONNECTIONS) };
let cfg = DatabaseConfig {
url: "postgres://localhost/_djogi_unreachable".to_string(),
crud_log_url: None,
event_log_url: None,
max_connections: Some(11),
dev_mode: false,
};
let pool = DjogiPool::from_database_config(&cfg)
.await
.expect("from_database_config should construct pool");
let dbg = format!("{pool:?}");
assert!(
dbg.contains("max_size: 11"),
"Debug output should reflect config max_size, got: {dbg}"
);
}
#[allow(dead_code)] fn with_client_accepts_documented_closure_shape() {
fn _check(
pool: &DjogiPool,
) -> impl std::future::Future<Output = Result<i32, DjogiError>> + '_ {
pool.with_client(|client| {
Box::pin(async move {
let _ = client;
Ok(42)
})
})
}
}
#[tokio::test]
async fn builder_accepts_post_connect_closure() {
let pool = DjogiPool::builder("postgres://localhost/_djogi_unreachable")
.post_connect(|client| {
Box::pin(async move {
let _ = client;
Ok(())
})
})
.build()
.await
.expect("builder should accept post_connect closure");
let _ = pool;
}
}