systemprompt_database/services/postgres/
connection.rs1use std::future::Future;
14use std::str::FromStr;
15use std::sync::OnceLock;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::{Duration, Instant};
18
19use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions};
20
21use crate::error::DatabaseResult;
22use crate::resilience::classify::Outcome;
23use crate::resilience::config::RetryConfig;
24use crate::resilience::retry::retry_async;
25
26const RETRY_DELAYS_MS: &[u64] = &[100, 200, 400, 800, 1600];
27const MAX_ATTEMPTS: u32 = 5;
28pub const DEFAULT_STATEMENT_CACHE_CAPACITY: usize = 100;
29
30static POOL_CLOCK: OnceLock<Instant> = OnceLock::new();
31static SCHEMA_CHANGED_AT_NANOS: AtomicU64 = AtomicU64::new(0);
32
33fn pool_clock() -> Instant {
34 *POOL_CLOCK.get_or_init(Instant::now)
35}
36
37fn nanos_since_clock(at: Duration) -> u64 {
38 u64::try_from(at.as_nanos()).unwrap_or(u64::MAX).max(1)
39}
40
41pub fn mark_schema_changed() {
42 let now = nanos_since_clock(pool_clock().elapsed());
43 SCHEMA_CHANGED_AT_NANOS.store(now, Ordering::Release);
44}
45
46fn opened_after_schema_change(age: Duration) -> bool {
47 let changed = SCHEMA_CHANGED_AT_NANOS.load(Ordering::Acquire);
48 if changed == 0 {
49 return true;
50 }
51 let opened = pool_clock().elapsed().saturating_sub(age);
52 nanos_since_clock(opened) >= changed
53}
54
55#[derive(Debug, Clone, Copy)]
61pub struct PoolConfig {
62 pub max_connections: u32,
63 pub min_connections: u32,
64 pub acquire_timeout: Duration,
65 pub idle_timeout: Duration,
66 pub max_lifetime: Duration,
67 pub statement_cache_capacity: usize,
68}
69
70impl Default for PoolConfig {
71 fn default() -> Self {
72 Self {
73 max_connections: 50,
74 min_connections: 0,
75 acquire_timeout: Duration::from_secs(30),
76 idle_timeout: Duration::from_mins(5),
77 max_lifetime: Duration::from_mins(30),
78 statement_cache_capacity: DEFAULT_STATEMENT_CACHE_CAPACITY,
79 }
80 }
81}
82
83#[must_use]
84pub fn build_pool_options(cfg: &PoolConfig) -> PgPoolOptions {
85 pool_clock();
86 PgPoolOptions::new()
87 .max_connections(cfg.max_connections)
88 .min_connections(cfg.min_connections)
89 .max_lifetime(cfg.max_lifetime)
90 .acquire_timeout(cfg.acquire_timeout)
91 .idle_timeout(cfg.idle_timeout)
92 .before_acquire(|_conn, meta| {
98 Box::pin(async move { Ok(opened_after_schema_change(meta.age)) })
99 })
100}
101
102pub fn connect_options(database_url: &str) -> DatabaseResult<PgConnectOptions> {
103 let options = PgConnectOptions::from_str(database_url)?
104 .application_name("systemprompt")
105 .statement_cache_capacity(DEFAULT_STATEMENT_CACHE_CAPACITY)
112 .options([
118 ("client_min_messages", "warning"),
119 ("idle_in_transaction_session_timeout", "600000"),
120 ]);
121 Ok(options)
122}
123
124pub async fn connect_with_retry(
125 options: PgPoolOptions,
126 connect_options: PgConnectOptions,
127) -> DatabaseResult<PgPool> {
128 let connector = |opts: PgConnectOptions| {
129 let options = options.clone();
130 async move { options.connect_with(opts).await }
131 };
132 connect_with_retry_using(connect_options, MAX_ATTEMPTS, RETRY_DELAYS_MS, connector).await
133}
134
135pub async fn connect_with_retry_using<T, F, Fut>(
136 connect_options: PgConnectOptions,
137 max_attempts: u32,
138 delays_ms: &[u64],
139 connector: F,
140) -> DatabaseResult<T>
141where
142 T: Send,
143 F: Fn(PgConnectOptions) -> Fut + Send + Sync,
144 Fut: Future<Output = Result<T, sqlx::Error>> + Send,
145{
146 let cfg = RetryConfig {
147 max_attempts,
148 base_delay: Duration::from_millis(delays_ms.first().copied().unwrap_or(100)),
149 max_delay: Duration::from_millis(delays_ms.iter().copied().max().unwrap_or(1600)),
150 jitter: false,
151 };
152 let classify = |err: &sqlx::Error| {
153 if is_retryable(err) {
154 Outcome::Transient { retry_after: None }
155 } else {
156 Outcome::Permanent
157 }
158 };
159 retry_async(&cfg, "postgres-connect", classify, || {
160 connector(connect_options.clone())
161 })
162 .await
163 .map_err(Into::into)
164}
165
166fn is_retryable(err: &sqlx::Error) -> bool {
167 if let sqlx::Error::Io(io_err) = err
168 && io_err.kind() == std::io::ErrorKind::ConnectionRefused
169 {
170 return true;
171 }
172 let msg = err.to_string();
173 msg.contains("unexpected response from SSLRequest") || msg.contains("starting up")
174}