1use std::sync::LazyLock;
11use std::time::Duration;
12
13use crate::utils::server::parse_migrate_startup_timeout;
14use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime};
15use tokio_postgres::config::Host;
16use tokio_postgres::{Config, NoTls};
17
18fn build_pg_config() -> Result<tokio_postgres::Config, String> {
26 let db_url = std::env::var("DATABASE_URL")
27 .map_err(|_| "DATABASE_URL environment variable not set".to_string())?;
28 let mut pg_cfg = db_url
29 .parse::<tokio_postgres::Config>()
30 .map_err(|e| format!("Invalid DATABASE_URL format: {e}"))?;
31
32 let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS")
35 .ok()
36 .and_then(|s| s.parse::<u32>().ok())
37 .unwrap_or(30);
38 pg_cfg.options(format!(
40 "-c statement_timeout={}",
41 statement_timeout_secs * 1000
42 ));
43
44 Ok(pg_cfg)
45}
46
47pub fn validate_database_url() -> Result<(), String> {
55 build_pg_config()?;
56
57 if let Ok(s) = std::env::var("DB_POOL_SIZE") {
59 match s.parse::<usize>() {
60 Ok(n) if n > 0 => {}
61 Ok(_) => return Err("DB_POOL_SIZE is not a positive integer".to_string()),
62 Err(e) => return Err(format!("Invalid DB_POOL_SIZE value: {e}")),
63 }
64 }
65 Ok(())
66}
67
68pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
76 let pg_cfg = build_pg_config()
78 .expect("DATABASE_URL should have been validated at startup; validate_database_url() was not called");
79
80 let mgr_cfg = ManagerConfig {
84 recycling_method: RecyclingMethod::Fast,
85 };
86 let mgr = Manager::from_config(pg_cfg, NoTls, mgr_cfg);
87
88 Pool::builder(mgr)
89 .max_size(
90 std::env::var("DB_POOL_SIZE")
91 .ok()
92 .and_then(|s| s.parse().ok())
93 .unwrap_or(20),
94 )
95 .wait_timeout(Some(Duration::from_secs(10)))
96 .create_timeout(Some(Duration::from_secs(10)))
97 .recycle_timeout(Some(Duration::from_secs(5)))
98 .runtime(Runtime::Tokio1)
99 .build()
100 .expect("Failed to create database connection pool")
101});
102
103pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
113 use rand::Rng;
114
115 let mut last_err = None;
116 for attempt in 0..=crate::db::retry::MAX_RETRIES {
117 match DB_POOL.get().await {
118 Ok(conn) => return Ok(conn),
119 Err(e) => {
120 let is_timeout = matches!(e, deadpool_postgres::PoolError::Timeout(_));
123 last_err = Some(e);
124 if !is_timeout && attempt < crate::db::retry::MAX_RETRIES {
125 let jitter = rand::thread_rng().gen::<f64>();
126 let delay = crate::db::retry::backoff_for(attempt, jitter);
127 tracing::warn!(
128 "DB connection attempt {} failed (backend error), retrying in {:?}: {:?}",
129 attempt + 1,
130 delay,
131 last_err.as_ref().unwrap(),
132 );
133 tokio::time::sleep(delay).await;
134 } else if is_timeout {
135 break;
137 }
138 }
139 }
140 }
141 Err(last_err.unwrap())
142}
143
144pub async fn get_conn_for_startup(
157) -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
158 let timeout_secs = parse_migrate_startup_timeout();
159
160 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
161 let retry_interval = Duration::from_millis(500);
162
163 let mut attempt = 0u32;
164 loop {
165 attempt += 1;
166 match DB_POOL.get().await {
167 Ok(conn) => {
168 if attempt > 1 {
169 tracing::info!("connected to database after {} attempt(s)", attempt);
170 }
171 return Ok(conn);
172 }
173 Err(e) => {
174 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
175 if remaining.is_zero() {
176 return Err(e);
177 }
178 tracing::warn!(
179 "startup DB connection attempt {} failed, ~{}s remaining until giving up: {:?}",
180 attempt,
181 remaining.as_secs(),
182 e,
183 );
184 let sleep = std::cmp::min(retry_interval, remaining);
186 tokio::time::sleep(sleep).await;
187 }
188 }
189 }
190}
191
192pub async fn ensure_database() -> Result<(), String> {
212 let pg_cfg = build_pg_config()?;
214 let db_name = pg_cfg
215 .get_dbname()
216 .or_else(|| pg_cfg.get_user())
217 .map(|s| s.to_string());
218
219 let db_name = match db_name {
220 Some(name) => name,
221 None => {
222 tracing::warn!(
223 "could not determine target database name from DATABASE_URL; \
224 skipping auto-create (letting normal connect path surface any error)"
225 );
226 return Ok(());
227 }
228 };
229
230 if !is_simple_ident(&db_name) {
233 tracing::warn!(
234 "target database name {:?} is not a simple identifier; \
235 skipping auto-create (letting normal connect path surface any error)",
236 db_name
237 );
238 return Ok(());
239 }
240
241 let timeout_secs = parse_migrate_startup_timeout();
243 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
244 let retry_interval = Duration::from_millis(500);
245
246 let (client, connection) = loop {
247 let admin_cfg = build_admin_config()?;
248 match admin_cfg.connect(NoTls).await {
249 Ok(joined) => break joined,
250 Err(e) => {
251 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
252 if remaining.is_zero() {
253 return Err(format!(
254 "could not connect to 'postgres' maintenance database within {timeout_secs}s: {}",
255 crate::db::format_with_sources(&e)
256 ));
257 }
258 tracing::warn!(
259 "ensure_database: connect to 'postgres' failed, ~{}s remaining: {}",
260 remaining.as_secs(),
261 crate::db::format_with_sources(&e)
262 );
263 tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await;
264 }
265 }
266 };
267 tokio::spawn(async move {
269 if let Err(e) = connection.await {
270 tracing::warn!(
271 "postgres maintenance connection ended: {}",
272 crate::db::format_with_sources(&e)
273 );
274 }
275 });
276
277 let exists: bool = client
279 .query_one(
280 "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)",
281 &[&db_name],
282 )
283 .await
284 .map_err(|e| {
285 format!(
286 "failed to query pg_database: {}",
287 crate::db::format_with_sources(&e)
288 )
289 })?
290 .get(0);
291
292 if exists {
293 tracing::info!("target database {:?} already exists", db_name);
294 return Ok(());
295 }
296
297 tracing::info!("target database {:?} does not exist, creating", db_name);
299 let stmt = format!("CREATE DATABASE {db_name}");
300 client.batch_execute(&stmt).await.map_err(|e| {
301 format!(
302 "failed to create database {db_name:?}: {}",
303 crate::db::format_with_sources(&e)
304 )
305 })?;
306 tracing::info!("created database {:?}", db_name);
307 Ok(())
308}
309
310fn build_admin_config() -> Result<Config, String> {
316 let src = build_pg_config()?;
317 let mut dst = Config::new();
318
319 if let Some(user) = src.get_user() {
320 dst.user(user);
321 }
322 if let Some(password) = src.get_password() {
323 dst.password(password);
324 }
325 let hosts = src.get_hosts();
326 let ports = src.get_ports();
327 for (i, host) in hosts.iter().enumerate() {
328 let port = ports.get(i).copied().unwrap_or(5432);
330 match host {
331 Host::Tcp(h) => {
332 dst.host(h);
333 dst.port(port);
334 }
335 Host::Unix(p) => {
336 dst.host_path(p);
337 }
338 };
339 }
340 dst.dbname("postgres");
342 Ok(dst)
343}
344
345fn is_simple_ident(s: &str) -> bool {
350 let mut chars = s.chars();
351 match chars.next() {
352 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
353 _ => return false,
354 }
355 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
356}
357
358#[cfg(test)]
359mod tests {
360 use super::is_simple_ident;
361
362 #[test]
363 fn simple_ident_accepts_valid_names() {
364 assert!(is_simple_ident("yggdrasil"));
365 assert!(is_simple_ident("_ygg"));
366 assert!(is_simple_ident("db_1"));
367 assert!(is_simple_ident("YggDrasil09"));
368 }
369
370 #[test]
371 fn simple_ident_rejects_invalid_names() {
372 assert!(!is_simple_ident(""));
373 assert!(!is_simple_ident("my-db")); assert!(!is_simple_ident("9db")); assert!(!is_simple_ident("db name")); assert!(!is_simple_ident("db\"; --")); assert!(!is_simple_ident("db.name")); }
379}