Skip to main content

sbol_db_postgres/
pool.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4use sbol_db_core::DomainError;
5use sbol_db_storage::{MigrationEntry, Migrator};
6
7pub use sqlx::PgPool;
8
9static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
10
11/// The migration capability for a Postgres backend, over its connection pool.
12#[derive(Clone)]
13pub struct PgMigrator {
14    pool: PgPool,
15}
16
17impl PgMigrator {
18    pub fn new(pool: PgPool) -> Self {
19        Self { pool }
20    }
21}
22
23#[async_trait]
24impl Migrator for PgMigrator {
25    async fn run_migrations(&self) -> Result<(), DomainError> {
26        run_migrations(&self.pool).await
27    }
28
29    async fn migration_status(&self) -> Result<Vec<MigrationEntry>, DomainError> {
30        migration_status(&self.pool).await
31    }
32}
33
34fn db_err<E: std::fmt::Display>(e: E) -> DomainError {
35    DomainError::Database(e.to_string())
36}
37
38/// Connection pool configuration. Sensible defaults match the prior
39/// hardcoded behavior (max_connections=8, sqlx defaults elsewhere); each
40/// field can be overridden by an environment variable so the Helm chart
41/// can tune pool sizing without code changes.
42#[derive(Debug, Clone)]
43pub struct PoolConfig {
44    pub max_connections: u32,
45    pub min_connections: u32,
46    pub acquire_timeout: Duration,
47    pub idle_timeout: Option<Duration>,
48    pub max_lifetime: Option<Duration>,
49    pub connect_timeout: Duration,
50}
51
52impl Default for PoolConfig {
53    fn default() -> Self {
54        Self {
55            max_connections: 8,
56            min_connections: 0,
57            acquire_timeout: Duration::from_secs(5),
58            idle_timeout: Some(Duration::from_secs(300)),
59            max_lifetime: Some(Duration::from_secs(1800)),
60            connect_timeout: Duration::from_secs(5),
61        }
62    }
63}
64
65impl PoolConfig {
66    /// Read overrides from environment variables. Variables that fail to
67    /// parse fall back to the default rather than aborting startup; the
68    /// goal is graceful degradation, not strict validation. A value of 0
69    /// disables the `idle_timeout` / `max_lifetime` knobs.
70    pub fn from_env() -> Self {
71        let defaults = Self::default();
72        Self {
73            max_connections: env_u32("DATABASE_MAX_CONNECTIONS", defaults.max_connections),
74            min_connections: env_u32("DATABASE_MIN_CONNECTIONS", defaults.min_connections),
75            acquire_timeout: env_duration_secs(
76                "DATABASE_ACQUIRE_TIMEOUT_SECS",
77                defaults.acquire_timeout,
78            ),
79            idle_timeout: env_opt_duration_secs(
80                "DATABASE_IDLE_TIMEOUT_SECS",
81                defaults.idle_timeout,
82            ),
83            max_lifetime: env_opt_duration_secs(
84                "DATABASE_MAX_LIFETIME_SECS",
85                defaults.max_lifetime,
86            ),
87            connect_timeout: env_duration_secs(
88                "DATABASE_CONNECT_TIMEOUT_SECS",
89                defaults.connect_timeout,
90            ),
91        }
92    }
93}
94
95/// One-shot connect. Suitable for short-lived CLI commands where a fast
96/// failure beats a long retry loop. Reads pool sizing from env.
97pub async fn connect(database_url: &str) -> Result<PgPool, DomainError> {
98    connect_with_config(database_url, &PoolConfig::from_env()).await
99}
100
101pub async fn connect_with_config(
102    database_url: &str,
103    cfg: &PoolConfig,
104) -> Result<PgPool, DomainError> {
105    let mut opts = sqlx::postgres::PgPoolOptions::new()
106        .max_connections(cfg.max_connections)
107        .min_connections(cfg.min_connections)
108        .acquire_timeout(cfg.acquire_timeout);
109    if let Some(t) = cfg.idle_timeout {
110        opts = opts.idle_timeout(t);
111    }
112    if let Some(t) = cfg.max_lifetime {
113        opts = opts.max_lifetime(t);
114    }
115    tokio::time::timeout(cfg.connect_timeout, opts.connect(database_url))
116        .await
117        .map_err(|_| DomainError::Database("connect timed out".into()))?
118        .map_err(db_err)
119}
120
121/// Retrying connect for boot-time use. Retries with capped exponential
122/// backoff (1s → 2s → 4s → … → 10s) until `total_deadline` elapses,
123/// then returns the last error. Use this from the `serve` and
124/// `migrate up` paths where Postgres may not be ready when sbol-db
125/// starts (the bitnami subchart's postgres pod boots in parallel with
126/// the chart's migration Job).
127pub async fn connect_with_retry(
128    database_url: &str,
129    total_deadline: Duration,
130) -> Result<PgPool, DomainError> {
131    let cfg = PoolConfig::from_env();
132    let started = std::time::Instant::now();
133    let mut backoff = Duration::from_secs(1);
134    let max_backoff = Duration::from_secs(10);
135    loop {
136        match connect_with_config(database_url, &cfg).await {
137            Ok(pool) => return Ok(pool),
138            Err(err) => {
139                let remaining = total_deadline.saturating_sub(started.elapsed());
140                if remaining.is_zero() {
141                    return Err(err);
142                }
143                let sleep_for = backoff.min(remaining);
144                tracing::warn!(
145                    error = %err,
146                    retry_in_secs = sleep_for.as_secs_f64(),
147                    "database connect failed; retrying",
148                );
149                tokio::time::sleep(sleep_for).await;
150                backoff = (backoff * 2).min(max_backoff);
151            }
152        }
153    }
154}
155
156pub async fn run_migrations(pool: &PgPool) -> Result<(), DomainError> {
157    MIGRATOR.run(pool).await.map_err(db_err)
158}
159
160pub async fn migration_status(pool: &PgPool) -> Result<Vec<MigrationEntry>, DomainError> {
161    let mut conn = pool.acquire().await.map_err(db_err)?;
162    let applied = MIGRATOR
163        .iter()
164        .map(|m| (m.version, m.description.to_string()))
165        .collect::<Vec<_>>();
166    let applied_versions: Vec<i64> =
167        sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY version")
168            .fetch_all(&mut *conn)
169            .await
170            .unwrap_or_default();
171    Ok(applied
172        .into_iter()
173        .map(|(v, d)| MigrationEntry {
174            version: v,
175            description: d,
176            applied: applied_versions.contains(&v),
177        })
178        .collect())
179}
180
181fn env_u32(name: &str, default: u32) -> u32 {
182    std::env::var(name)
183        .ok()
184        .and_then(|s| s.parse().ok())
185        .unwrap_or(default)
186}
187
188fn env_duration_secs(name: &str, default: Duration) -> Duration {
189    std::env::var(name)
190        .ok()
191        .and_then(|s| s.parse::<u64>().ok())
192        .map(Duration::from_secs)
193        .unwrap_or(default)
194}
195
196fn env_opt_duration_secs(name: &str, default: Option<Duration>) -> Option<Duration> {
197    match std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok()) {
198        Some(0) => None, // explicit disable
199        Some(n) => Some(Duration::from_secs(n)),
200        None => default,
201    }
202}