systemprompt_database/services/postgres/
connection.rs1use std::future::Future;
14use std::str::FromStr;
15use std::sync::Arc;
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
30#[derive(Debug, Clone)]
35pub struct SchemaEpoch {
36 inner: Arc<EpochState>,
37}
38
39#[derive(Debug)]
40struct EpochState {
41 clock: Instant,
42 changed_at_nanos: AtomicU64,
43}
44
45impl Default for SchemaEpoch {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl SchemaEpoch {
52 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 inner: Arc::new(EpochState {
56 clock: Instant::now(),
57 changed_at_nanos: AtomicU64::new(0),
58 }),
59 }
60 }
61
62 pub fn mark_changed(&self) {
63 let now = nanos_since_clock(self.inner.clock.elapsed());
64 self.inner.changed_at_nanos.store(now, Ordering::Release);
65 }
66
67 fn opened_after_change(&self, age: Duration) -> bool {
68 let changed = self.inner.changed_at_nanos.load(Ordering::Acquire);
69 if changed == 0 {
70 return true;
71 }
72 let opened = self.inner.clock.elapsed().saturating_sub(age);
73 nanos_since_clock(opened) >= changed
74 }
75}
76
77fn nanos_since_clock(at: Duration) -> u64 {
78 u64::try_from(at.as_nanos()).unwrap_or(u64::MAX).max(1)
79}
80
81#[derive(Debug, Clone, Copy)]
87pub struct PoolConfig {
88 pub max_connections: u32,
89 pub min_connections: u32,
90 pub acquire_timeout: Duration,
91 pub idle_timeout: Duration,
92 pub max_lifetime: Duration,
93 pub statement_cache_capacity: usize,
94}
95
96impl Default for PoolConfig {
97 fn default() -> Self {
98 Self {
99 max_connections: 50,
100 min_connections: 0,
101 acquire_timeout: Duration::from_secs(30),
102 idle_timeout: Duration::from_mins(5),
103 max_lifetime: Duration::from_mins(30),
104 statement_cache_capacity: DEFAULT_STATEMENT_CACHE_CAPACITY,
105 }
106 }
107}
108
109pub const DEFAULT_SESSION_MAX_CONNECTIONS: u32 = 10;
110
111impl PoolConfig {
112 #[must_use]
113 pub const fn for_session(self, max_connections: Option<u32>) -> Self {
114 Self {
115 max_connections: match max_connections {
116 Some(max) => max,
117 None => DEFAULT_SESSION_MAX_CONNECTIONS,
118 },
119 ..self
120 }
121 }
122}
123
124#[must_use]
125pub fn build_pool_options(cfg: &PoolConfig, epoch: &SchemaEpoch) -> PgPoolOptions {
126 let epoch = epoch.clone();
127 PgPoolOptions::new()
128 .max_connections(cfg.max_connections)
129 .min_connections(cfg.min_connections)
130 .max_lifetime(cfg.max_lifetime)
131 .acquire_timeout(cfg.acquire_timeout)
132 .idle_timeout(cfg.idle_timeout)
133 .before_acquire(move |_conn, meta| {
139 let fresh = epoch.opened_after_change(meta.age);
140 Box::pin(async move { Ok(fresh) })
141 })
142}
143
144pub fn connect_options(database_url: &str) -> DatabaseResult<PgConnectOptions> {
145 let options = PgConnectOptions::from_str(database_url)?
146 .application_name("systemprompt")
147 .statement_cache_capacity(DEFAULT_STATEMENT_CACHE_CAPACITY)
154 .options([
160 ("client_min_messages", "warning"),
161 ("idle_in_transaction_session_timeout", "600000"),
162 ]);
163 Ok(options)
164}
165
166pub async fn connect_with_retry(
167 options: PgPoolOptions,
168 connect_options: PgConnectOptions,
169) -> DatabaseResult<PgPool> {
170 let connector = |opts: PgConnectOptions| {
171 let options = options.clone();
172 async move { options.connect_with(opts).await }
173 };
174 connect_with_retry_using(connect_options, MAX_ATTEMPTS, RETRY_DELAYS_MS, connector).await
175}
176
177pub async fn connect_with_retry_using<T, F, Fut>(
178 connect_options: PgConnectOptions,
179 max_attempts: u32,
180 delays_ms: &[u64],
181 connector: F,
182) -> DatabaseResult<T>
183where
184 T: Send,
185 F: Fn(PgConnectOptions) -> Fut + Send + Sync,
186 Fut: Future<Output = Result<T, sqlx::Error>> + Send,
187{
188 let cfg = RetryConfig {
189 max_attempts,
190 base_delay: Duration::from_millis(delays_ms.first().copied().unwrap_or(100)),
191 max_delay: Duration::from_millis(delays_ms.iter().copied().max().unwrap_or(1600)),
192 jitter: true,
193 };
194 let classify = |err: &sqlx::Error| {
195 if is_retryable(err) {
196 Outcome::Transient { retry_after: None }
197 } else {
198 Outcome::Permanent
199 }
200 };
201 retry_async(&cfg, "postgres-connect", classify, || {
202 connector(connect_options.clone())
203 })
204 .await
205 .map_err(Into::into)
206}
207
208fn is_retryable(err: &sqlx::Error) -> bool {
209 if let sqlx::Error::Io(io_err) = err
210 && io_err.kind() == std::io::ErrorKind::ConnectionRefused
211 {
212 return true;
213 }
214 let msg = err.to_string();
215 msg.contains("unexpected response from SSLRequest") || msg.contains("starting up")
216}