1use crate::config::SslMode;
9use crate::dialect::{DatabaseDialect, DialectKind};
10use crate::error::{Result, WaypointError};
11use std::path::PathBuf;
12
13#[cfg(feature = "postgres")]
14use fastrand;
15
16#[cfg(feature = "postgres")]
17use tokio_postgres::Client;
18
19#[derive(Debug, Clone)]
26pub struct TransportConfig {
27 pub ssl_mode: SslMode,
29 pub ssl_root_cert: Option<PathBuf>,
31 pub retries: u32,
33 pub connect_timeout_secs: u32,
35 pub statement_timeout_secs: u32,
37 pub keepalive_secs: u32,
39}
40
41impl Default for TransportConfig {
42 fn default() -> Self {
43 Self {
45 ssl_mode: SslMode::Prefer,
46 ssl_root_cert: None,
47 retries: 0,
48 connect_timeout_secs: 30,
49 statement_timeout_secs: 0,
50 keepalive_secs: 120,
51 }
52 }
53}
54
55impl TransportConfig {
56 pub fn from_database_config(db: &crate::config::DatabaseConfig) -> Self {
58 Self {
59 ssl_mode: db.ssl_mode,
60 ssl_root_cert: db.ssl_root_cert.clone(),
61 retries: db.connect_retries,
62 connect_timeout_secs: db.connect_timeout_secs,
63 statement_timeout_secs: db.statement_timeout_secs,
64 keepalive_secs: db.keepalive_secs,
65 }
66 }
67}
68
69pub fn sandbox_name(prefix: &str) -> String {
85 let millis = std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_millis();
89 format!(
90 "{}_{}_{:x}_{:08x}",
91 prefix,
92 millis,
93 std::process::id(),
94 fastrand::u32(..)
95 )
96}
97
98pub fn quote_literal(value: &str) -> String {
110 format!("'{}'", value.replace('\'', "''"))
111}
112
113pub fn quote_ident(name: &str) -> String {
119 format!("\"{}\"", name.replace('"', "\"\""))
120}
121
122pub fn quote_ident_mysql(name: &str) -> String {
131 format!("`{}`", name.replace('`', "``"))
132}
133
134pub fn validate_identifier(name: &str) -> Result<()> {
139 if name.is_empty() {
140 return Err(WaypointError::ConfigError(
141 "Identifier cannot be empty".to_string(),
142 ));
143 }
144 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
145 return Err(WaypointError::ConfigError(format!(
146 "Identifier '{}' contains invalid characters. Only [a-zA-Z0-9_] are allowed.",
147 name
148 )));
149 }
150 Ok(())
151}
152
153pub enum DbClient {
163 #[cfg(feature = "postgres")]
165 Postgres(Client),
166 #[cfg(feature = "mysql")]
171 Mysql(mysql_async::Pool),
172}
173
174impl DbClient {
175 #[cfg(feature = "postgres")]
177 pub fn with_postgres(client: Client) -> Self {
178 DbClient::Postgres(client)
179 }
180
181 #[cfg(feature = "mysql")]
183 pub fn with_mysql(pool: mysql_async::Pool) -> Self {
184 DbClient::Mysql(pool)
185 }
186
187 pub fn dialect_kind(&self) -> DialectKind {
189 match self {
190 #[cfg(feature = "postgres")]
191 DbClient::Postgres(_) => DialectKind::Postgres,
192 #[cfg(feature = "mysql")]
193 DbClient::Mysql(_) => DialectKind::Mysql,
194 }
195 }
196
197 pub fn dialect(&self) -> &'static dyn DatabaseDialect {
202 #[cfg(feature = "postgres")]
203 static PG: crate::dialect::postgres::PostgresDialect =
204 crate::dialect::postgres::PostgresDialect;
205 #[cfg(feature = "mysql")]
206 static MY: crate::dialect::mysql::MysqlDialect = crate::dialect::mysql::MysqlDialect;
207 match self.dialect_kind() {
208 #[cfg(feature = "postgres")]
209 DialectKind::Postgres => &PG,
210 #[cfg(not(feature = "postgres"))]
211 DialectKind::Postgres => {
212 panic!("PostgreSQL connection without `postgres` feature compiled in")
213 }
214 #[cfg(feature = "mysql")]
215 DialectKind::Mysql => &MY,
216 #[cfg(not(feature = "mysql"))]
217 DialectKind::Mysql => {
218 panic!("MySQL connection without `mysql` feature compiled in")
219 }
220 }
221 }
222
223 #[cfg(feature = "postgres")]
227 pub fn as_postgres(&self) -> Result<&Client> {
228 match self {
229 DbClient::Postgres(c) => Ok(c),
230 #[cfg(feature = "mysql")]
231 DbClient::Mysql(_) => Err(WaypointError::ConfigError(
232 "This operation is not yet implemented for MySQL".into(),
233 )),
234 }
235 }
236
237 #[cfg(feature = "mysql")]
240 pub fn as_mysql(&self) -> Result<&mysql_async::Pool> {
241 match self {
242 DbClient::Mysql(p) => Ok(p),
243 #[cfg(feature = "postgres")]
244 DbClient::Postgres(_) => Err(WaypointError::ConfigError(
245 "This operation requires a MySQL connection".into(),
246 )),
247 }
248 }
249
250 pub async fn check_connection(&self) -> Result<()> {
252 match self {
253 #[cfg(feature = "postgres")]
254 DbClient::Postgres(c) => check_connection(c).await,
255 #[cfg(feature = "mysql")]
256 DbClient::Mysql(pool) => {
257 use mysql_async::prelude::*;
258 let mut conn =
259 pool.get_conn()
260 .await
261 .map_err(|e| WaypointError::ConnectionLost {
262 operation: "health check".into(),
263 detail: e.to_string(),
264 })?;
265 conn.query_drop("DO 0")
266 .await
267 .map_err(|e| WaypointError::ConnectionLost {
268 operation: "health check".into(),
269 detail: e.to_string(),
270 })?;
271 Ok(())
272 }
273 }
274 }
275
276 pub async fn acquire_lock(&self, schema: &str, table_name: &str) -> Result<()> {
281 match self {
282 #[cfg(feature = "postgres")]
283 DbClient::Postgres(c) => acquire_advisory_lock(c, schema, table_name).await,
284 #[cfg(feature = "mysql")]
285 DbClient::Mysql(pool) => {
286 use mysql_async::prelude::*;
287 let key = mysql_lock_key(schema, table_name);
288 let mut conn = pool.get_conn().await?;
289 let acquired: Option<i64> = conn
290 .exec_first("SELECT GET_LOCK(?, -1)", (key.clone(),))
291 .await?;
292 match acquired {
293 Some(1) => {
294 park_lock_conn(pool, &key, conn);
295 Ok(())
296 }
297 _ => Err(WaypointError::LockError(format!(
298 "Failed to acquire MySQL named lock {}",
299 key
300 ))),
301 }
302 }
303 }
304 }
305
306 pub async fn acquire_lock_with_timeout(
308 &self,
309 schema: &str,
310 table_name: &str,
311 timeout_secs: u32,
312 ) -> Result<()> {
313 match self {
314 #[cfg(feature = "postgres")]
315 DbClient::Postgres(c) => {
316 acquire_advisory_lock_with_timeout(c, schema, table_name, timeout_secs).await
317 }
318 #[cfg(feature = "mysql")]
319 DbClient::Mysql(pool) => {
320 use mysql_async::prelude::*;
321 let key = mysql_lock_key(schema, table_name);
322 let mut conn = pool.get_conn().await?;
323 let acquired: Option<i64> = conn
324 .exec_first("SELECT GET_LOCK(?, ?)", (key.clone(), timeout_secs as i64))
325 .await?;
326 match acquired {
327 Some(1) => {
328 park_lock_conn(pool, &key, conn);
329 Ok(())
330 }
331 Some(0) => Err(WaypointError::LockError(format!(
332 "Timed out waiting for MySQL named lock {} after {}s",
333 key, timeout_secs
334 ))),
335 _ => Err(WaypointError::LockError(format!(
336 "Failed to acquire MySQL named lock {} (NULL result)",
337 key
338 ))),
339 }
340 }
341 }
342 }
343
344 pub async fn release_lock(&self, schema: &str, table_name: &str) -> Result<()> {
346 match self {
347 #[cfg(feature = "postgres")]
348 DbClient::Postgres(c) => release_advisory_lock(c, schema, table_name).await,
349 #[cfg(feature = "mysql")]
350 DbClient::Mysql(pool) => {
351 use mysql_async::prelude::*;
352 let key = mysql_lock_key(schema, table_name);
353 let mut conn = match unpark_lock_conn(pool, &key) {
357 Some(conn) => conn,
358 None => {
359 return Err(WaypointError::LockError(format!(
360 "No pinned connection holds MySQL named lock {} — \
361 release_lock called without a matching acquire_lock",
362 key
363 )));
364 }
365 };
366 let released = conn
367 .exec_first::<Option<i64>, _, _>("SELECT RELEASE_LOCK(?)", (key.clone(),))
368 .await;
369 drop(conn);
373 match released {
374 Ok(Some(Some(1))) => Ok(()),
375 Ok(_) => {
376 log::warn!(
377 "RELEASE_LOCK({}) did not report success; the lock is released \
378 regardless because the holding session was returned to the pool",
379 key
380 );
381 Ok(())
382 }
383 Err(e) => Err(WaypointError::MysqlError(e)),
384 }
385 }
386 }
387 }
388
389 pub async fn current_user(&self) -> Result<String> {
391 match self {
392 #[cfg(feature = "postgres")]
393 DbClient::Postgres(c) => get_current_user(c).await,
394 #[cfg(feature = "mysql")]
395 DbClient::Mysql(pool) => {
396 use mysql_async::prelude::*;
397 let mut conn = pool.get_conn().await?;
398 let user: Option<String> = conn.query_first("SELECT CURRENT_USER()").await?;
399 user.ok_or_else(|| {
400 WaypointError::ConfigError("CURRENT_USER() returned no rows".into())
401 })
402 }
403 }
404 }
405
406 pub async fn current_database(&self) -> Result<String> {
408 match self {
409 #[cfg(feature = "postgres")]
410 DbClient::Postgres(c) => get_current_database(c).await,
411 #[cfg(feature = "mysql")]
412 DbClient::Mysql(pool) => {
413 use mysql_async::prelude::*;
414 let mut conn = pool.get_conn().await?;
415 let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await?;
417 match db.flatten() {
418 Some(name) => Ok(name),
419 None => Err(WaypointError::ConfigError(
420 "MySQL connection has no current database (none selected in URL)".into(),
421 )),
422 }
423 }
424 }
425 }
426
427 pub async fn resolve_schema(&self, configured: &str) -> Result<String> {
434 match self.dialect_kind() {
435 DialectKind::Postgres => Ok(configured.to_string()),
436 DialectKind::Mysql => {
437 if configured == "public" {
438 self.current_database().await
439 } else {
440 Ok(configured.to_string())
441 }
442 }
443 }
444 }
445
446 pub async fn execute_raw(&self, sql: &str) -> Result<i32> {
455 match self {
456 #[cfg(feature = "postgres")]
457 DbClient::Postgres(c) => execute_raw(c, sql).await,
458 #[cfg(feature = "mysql")]
459 DbClient::Mysql(pool) => {
460 use mysql_async::prelude::*;
461 let start = std::time::Instant::now();
462 let mut conn = pool.get_conn().await?;
463 for stmt in crate::sql_parser::split_mysql_statements(sql) {
464 conn.query_drop(&stmt).await?;
465 }
466 Ok(start.elapsed().as_millis() as i32)
467 }
468 }
469 }
470
471 pub async fn execute_in_transaction(&self, sql: &str) -> Result<i32> {
480 match self {
481 #[cfg(feature = "postgres")]
482 DbClient::Postgres(c) => execute_in_transaction(c, sql).await,
483 #[cfg(feature = "mysql")]
484 DbClient::Mysql(_) => self.execute_raw(sql).await,
485 }
486 }
487}
488
489pub async fn connect_for_url(
499 conn_string: &str,
500 #[cfg_attr(
501 not(any(feature = "postgres", feature = "mysql")),
502 allow(unused_variables)
503 )]
504 config: &crate::config::WaypointConfig,
505) -> Result<DbClient> {
506 let kind = DialectKind::from_url(conn_string).unwrap_or(config.database.engine);
507 match kind {
508 #[cfg(feature = "postgres")]
509 DialectKind::Postgres => {
510 let transport = TransportConfig::from_database_config(&config.database);
511 let client = connect_with_transport(conn_string, &transport).await?;
512 Ok(DbClient::with_postgres(client))
513 }
514 #[cfg(not(feature = "postgres"))]
515 DialectKind::Postgres => Err(WaypointError::ConfigError(
516 "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
517 )),
518 #[cfg(feature = "mysql")]
519 DialectKind::Mysql => {
520 let pool = connect_mysql_pool(
521 conn_string,
522 config.database.ssl_mode,
523 config.database.ssl_root_cert.as_deref(),
524 config.database.statement_timeout_secs,
525 config.database.keepalive_secs,
526 )
527 .await?;
528 Ok(DbClient::with_mysql(pool))
529 }
530 #[cfg(not(feature = "mysql"))]
531 DialectKind::Mysql => Err(WaypointError::ConfigError(
532 "MySQL support is not compiled in (enable the `mysql` feature)".into(),
533 )),
534 }
535}
536
537#[cfg(feature = "mysql")]
568async fn connect_mysql_pool(
569 conn_string: &str,
570 ssl_mode: SslMode,
571 ssl_root_cert: Option<&std::path::Path>,
572 statement_timeout_secs: u32,
573 keepalive_secs: u32,
574) -> Result<mysql_async::Pool> {
575 let base = mysql_async::Opts::from_url(conn_string)
576 .map_err(|e| WaypointError::ConfigError(format!("Invalid MySQL connection URL: {}", e)))?;
577
578 let mut builder = mysql_async::OptsBuilder::from_opts(base);
584
585 if statement_timeout_secs > 0 {
586 let millis = u64::from(statement_timeout_secs).saturating_mul(1000);
587 log::debug!(
588 "Setting MySQL MAX_EXECUTION_TIME={}ms (bounds SELECTs only; DDL is not interruptible \
589 by it)",
590 millis
591 );
592 builder = builder.setup(vec![format!("SET SESSION MAX_EXECUTION_TIME = {}", millis)]);
593 }
594
595 if keepalive_secs > 0 {
596 builder = builder.tcp_keepalive(Some(std::time::Duration::from_secs(u64::from(
597 keepalive_secs,
598 ))));
599 }
600
601 let base = mysql_async::Opts::from(builder);
602
603 if ssl_mode.requires_tls() && base.socket().is_some() {
608 return Err(WaypointError::ConfigError(format!(
609 "ssl_mode = '{}' requires TLS, but this MySQL connection uses a Unix \
610 socket, which the driver cannot secure. Use a TCP host:port, or set \
611 ssl_mode = 'disable'.",
612 ssl_mode
613 )));
614 }
615
616 if ssl_mode == SslMode::Prefer && base.ssl_opts().is_some() {
620 log::debug!(
621 "Using the TLS options from the MySQL connection URL (ssl_mode is at its default)."
622 );
623 return Ok(mysql_async::Pool::new(base));
624 }
625
626 let Some(ssl_opts) = crate::tls::make_mysql_ssl_opts(ssl_mode, ssl_root_cert) else {
627 return Ok(mysql_async::Pool::new(base));
629 };
630
631 let secure = mysql_async::Pool::new(
632 mysql_async::OptsBuilder::from_opts(base.clone()).ssl_opts(Some(ssl_opts)),
633 );
634
635 if ssl_mode != SslMode::Prefer {
636 return Ok(secure);
637 }
638
639 match secure.get_conn().await {
640 Ok(conn) => {
641 drop(conn);
642 Ok(secure)
643 }
644 Err(e) if mysql_tls_unavailable(&e) => {
649 log::warn!(
650 "MySQL server does not support TLS ({}); continuing with an UNENCRYPTED \
651 connection because ssl_mode is 'prefer'. Set ssl_mode to 'require' or \
652 higher to refuse this.",
653 e
654 );
655 let _ = secure.disconnect().await;
656 Ok(mysql_async::Pool::new(base))
657 }
658 Err(e) => Err(WaypointError::MysqlError(e)),
659 }
660}
661
662#[cfg(feature = "mysql")]
669fn mysql_tls_unavailable(e: &mysql_async::Error) -> bool {
670 matches!(
671 e,
672 mysql_async::Error::Driver(mysql_async::DriverError::NoClientSslFlagFromServer)
673 ) || matches!(e, mysql_async::Error::Io(mysql_async::IoError::Tls(_)))
674}
675
676#[cfg(feature = "mysql")]
696fn mysql_lock_key(schema: &str, table_name: &str) -> String {
697 let full = format!("waypoint_{}_{}", schema, table_name);
698 if full.len() <= 64 {
699 full
700 } else {
701 format!("waypoint_{:08x}", crc32fast::hash(full.as_bytes()))
702 }
703}
704
705#[cfg(feature = "mysql")]
724type MysqlLockRegistry = std::collections::HashMap<(usize, String), mysql_async::Conn>;
725
726#[cfg(feature = "mysql")]
727static MYSQL_LOCK_CONNS: std::sync::LazyLock<std::sync::Mutex<MysqlLockRegistry>> =
728 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
729
730#[cfg(feature = "mysql")]
744fn mysql_pool_ident(pool: &mysql_async::Pool) -> usize {
745 pool as *const mysql_async::Pool as usize
746}
747
748#[cfg(feature = "mysql")]
750fn park_lock_conn(pool: &mysql_async::Pool, key: &str, conn: mysql_async::Conn) {
751 let registry_key = (mysql_pool_ident(pool), key.to_string());
752 match MYSQL_LOCK_CONNS.lock() {
753 Ok(mut guard) => {
754 guard.insert(registry_key, conn);
755 }
756 Err(poisoned) => {
757 poisoned.into_inner().insert(registry_key, conn);
761 }
762 }
763}
764
765#[cfg(feature = "mysql")]
767fn unpark_lock_conn(pool: &mysql_async::Pool, key: &str) -> Option<mysql_async::Conn> {
768 let registry_key = (mysql_pool_ident(pool), key.to_string());
769 match MYSQL_LOCK_CONNS.lock() {
770 Ok(mut guard) => guard.remove(®istry_key),
771 Err(poisoned) => poisoned.into_inner().remove(®istry_key),
772 }
773}
774
775#[cfg(feature = "postgres")]
789fn to_pg_ssl_mode(mode: SslMode) -> tokio_postgres::config::SslMode {
790 match mode {
791 SslMode::Disable => tokio_postgres::config::SslMode::Disable,
792 SslMode::Prefer => tokio_postgres::config::SslMode::Prefer,
793 SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => {
794 tokio_postgres::config::SslMode::Require
795 }
796 }
797}
798
799#[cfg(feature = "postgres")]
801fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
802 if let Some(db_err) = e.as_db_error() {
803 let code = db_err.code().code();
804 return code == "28P01" || code == "28000";
806 }
807 false
808}
809
810pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
816 if keepalive_secs == 0 {
817 return conn_string.to_string();
818 }
819 let lower = conn_string.to_lowercase();
820 if lower.contains("keepalives") {
821 return conn_string.to_string();
822 }
823 let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
824 if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
825 if conn_string.contains('?') {
826 format!("{}&{}", conn_string, params)
827 } else {
828 format!("{}?{}", conn_string, params)
829 }
830 } else {
831 format!(
833 "{} keepalives=1 keepalives_idle={}",
834 conn_string, keepalive_secs
835 )
836 }
837}
838
839#[cfg(feature = "postgres")]
845fn spawn_connection_task<F>(connection: F)
846where
847 F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
848 + Send
849 + 'static,
850{
851 tokio::spawn(async move {
852 if let Err(e) = connection.await {
853 log::error!("Database connection error: {}", e);
854 }
855 });
856}
857
858#[cfg(feature = "postgres")]
874async fn connect_once(
875 pg_config: &tokio_postgres::Config,
876 tls_config: Option<&rustls::ClientConfig>,
877 connect_timeout_secs: u32,
878) -> std::result::Result<Client, tokio_postgres::Error> {
879 let connect_fut = async {
880 match tls_config {
881 None => {
882 let (client, connection) = pg_config.connect(tokio_postgres::NoTls).await?;
883 spawn_connection_task(connection);
884 Ok(client)
885 }
886 Some(tls_config) => {
887 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config.clone());
888 let (client, connection) = pg_config.connect(tls).await?;
889 spawn_connection_task(connection);
890 Ok(client)
891 }
892 }
893 };
894
895 if connect_timeout_secs > 0 {
896 match tokio::time::timeout(
897 std::time::Duration::from_secs(connect_timeout_secs as u64),
898 connect_fut,
899 )
900 .await
901 {
902 Ok(result) => result,
903 Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
904 }
905 } else {
906 connect_fut.await
907 }
908}
909
910#[cfg(feature = "postgres")]
914#[deprecated(
915 since = "0.7.0",
916 note = "Use connect_with_transport, which supports the full sslmode ladder and a custom CA. Will be removed in 1.0."
917)]
918pub async fn connect(conn_string: &str) -> Result<Client> {
919 connect_with_transport(conn_string, &TransportConfig::default()).await
920}
921
922#[cfg(feature = "postgres")]
927#[deprecated(
928 since = "0.7.0",
929 note = "Use connect_with_transport, which supports the full sslmode ladder and a custom CA. Will be removed in 1.0."
930)]
931pub async fn connect_with_config(
932 conn_string: &str,
933 ssl_mode: &SslMode,
934 retries: u32,
935 connect_timeout_secs: u32,
936 statement_timeout_secs: u32,
937) -> Result<Client> {
938 connect_with_transport(
939 conn_string,
940 &TransportConfig {
941 ssl_mode: *ssl_mode,
942 retries,
943 connect_timeout_secs,
944 statement_timeout_secs,
945 ..TransportConfig::default()
946 },
947 )
948 .await
949}
950
951#[cfg(feature = "postgres")]
953#[deprecated(
954 since = "0.7.0",
955 note = "Use connect_with_transport — this signature cannot express ssl_root_cert. Will be removed in 1.0."
956)]
957pub async fn connect_with_full_config(
958 conn_string: &str,
959 ssl_mode: &SslMode,
960 retries: u32,
961 connect_timeout_secs: u32,
962 statement_timeout_secs: u32,
963 keepalive_secs: u32,
964) -> Result<Client> {
965 connect_with_transport(
966 conn_string,
967 &TransportConfig {
968 ssl_mode: *ssl_mode,
969 ssl_root_cert: None,
970 retries,
971 connect_timeout_secs,
972 statement_timeout_secs,
973 keepalive_secs,
974 },
975 )
976 .await
977}
978
979#[cfg(feature = "postgres")]
986pub async fn connect_with_transport(
987 conn_string: &str,
988 transport: &TransportConfig,
989) -> Result<Client> {
990 let conn_string = inject_keepalive(conn_string, transport.keepalive_secs);
991
992 let (conn_string, embedded) = crate::tls::parse_url_sslmode(&conn_string);
996 let ssl_mode = crate::tls::reconcile_ssl_mode(transport.ssl_mode, embedded.mode);
997 let ssl_root_cert =
998 crate::tls::reconcile_root_cert(transport.ssl_root_cert.as_deref(), embedded.root_cert);
999
1000 let mut pg_config: tokio_postgres::Config = conn_string.parse().map_err(|e| {
1001 WaypointError::ConfigError(format!("Invalid PostgreSQL connection string: {}", e))
1002 })?;
1003 pg_config.ssl_mode(to_pg_ssl_mode(ssl_mode));
1004
1005 let tls_config = match ssl_mode {
1008 SslMode::Disable => None,
1009 _ => Some(crate::tls::make_rustls_config(
1010 ssl_mode,
1011 ssl_root_cert.as_deref(),
1012 )?),
1013 };
1014
1015 let retries = transport.retries;
1016 let mut last_err = None;
1017
1018 for attempt in 0..=retries {
1019 if attempt > 0 {
1020 let base_delay = std::cmp::min(1u64 << attempt, 30);
1021 let jitter_ms = fastrand::u64(0..1000);
1022 let delay = std::time::Duration::from_secs(base_delay)
1023 + std::time::Duration::from_millis(jitter_ms);
1024 log::info!(
1025 "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
1026 attempt + 1,
1027 retries + 1,
1028 delay.as_millis() as u64
1029 );
1030 tokio::time::sleep(delay).await;
1031 }
1032
1033 match connect_once(
1034 &pg_config,
1035 tls_config.as_ref(),
1036 transport.connect_timeout_secs,
1037 )
1038 .await
1039 {
1040 Ok(client) => {
1041 if attempt > 0 {
1042 log::info!(
1043 "Connected successfully after retry; attempt={}, max_attempts={}",
1044 attempt + 1,
1045 retries + 1
1046 );
1047 }
1048
1049 if transport.statement_timeout_secs > 0 {
1051 let timeout_sql = format!(
1052 "SET statement_timeout = '{}s'",
1053 transport.statement_timeout_secs
1054 );
1055 client.batch_execute(&timeout_sql).await?;
1056 }
1057
1058 return Ok(client);
1059 }
1060 Err(e) => {
1061 if is_permanent_error(&e) {
1063 log::error!("Permanent connection error, not retrying: {}", e);
1064 return Err(WaypointError::DatabaseError(e));
1065 }
1066 last_err = Some(e);
1067 }
1068 }
1069 }
1070
1071 Err(WaypointError::DatabaseError(last_err.unwrap()))
1072}
1073
1074#[cfg(feature = "postgres")]
1078pub async fn acquire_advisory_lock(client: &Client, schema: &str, table_name: &str) -> Result<()> {
1079 let lock_id = advisory_lock_id(schema, table_name);
1080 log::info!(
1081 "Acquiring advisory lock; lock_id={}, table={}",
1082 lock_id,
1083 table_name
1084 );
1085
1086 client
1087 .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
1088 .await
1089 .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
1090
1091 Ok(())
1092}
1093
1094#[cfg(feature = "postgres")]
1099pub async fn acquire_advisory_lock_with_timeout(
1100 client: &Client,
1101 schema: &str,
1102 table_name: &str,
1103 timeout_secs: u32,
1104) -> Result<()> {
1105 let lock_id = advisory_lock_id(schema, table_name);
1106 log::info!(
1107 "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
1108 lock_id,
1109 table_name,
1110 timeout_secs
1111 );
1112
1113 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
1114
1115 loop {
1116 let row = client
1117 .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
1118 .await
1119 .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
1120
1121 let acquired: bool = row.get(0);
1122 if acquired {
1123 return Ok(());
1124 }
1125
1126 if std::time::Instant::now() >= deadline {
1127 return Err(WaypointError::LockError(format!(
1128 "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
1129 timeout_secs, table_name
1130 )));
1131 }
1132
1133 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1135 }
1136}
1137
1138#[cfg(feature = "postgres")]
1140pub async fn release_advisory_lock(client: &Client, schema: &str, table_name: &str) -> Result<()> {
1141 let lock_id = advisory_lock_id(schema, table_name);
1142 log::info!(
1143 "Releasing advisory lock; lock_id={}, table={}",
1144 lock_id,
1145 table_name
1146 );
1147
1148 client
1149 .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
1150 .await
1151 .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
1152
1153 Ok(())
1154}
1155
1156pub fn advisory_lock_id(schema: &str, table_name: &str) -> i64 {
1183 let key = format!("{}\0{}", schema, table_name);
1186 crc32fast::hash(key.as_bytes()) as i64
1187}
1188
1189#[cfg(feature = "postgres")]
1191pub async fn get_current_user(client: &Client) -> Result<String> {
1192 let row = client.query_one("SELECT current_user", &[]).await?;
1193 Ok(row.get::<_, String>(0))
1194}
1195
1196#[cfg(feature = "postgres")]
1198pub async fn get_current_database(client: &Client) -> Result<String> {
1199 let row = client.query_one("SELECT current_database()", &[]).await?;
1200 Ok(row.get::<_, String>(0))
1201}
1202
1203#[cfg(feature = "postgres")]
1206pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
1207 let start = std::time::Instant::now();
1208
1209 client.batch_execute("BEGIN").await?;
1210
1211 match client.batch_execute(sql).await {
1212 Ok(()) => {
1213 client.batch_execute("COMMIT").await?;
1214 }
1215 Err(e) => {
1216 if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
1217 log::warn!("Failed to rollback transaction: {}", rollback_err);
1218 }
1219 return Err(WaypointError::DatabaseError(e));
1220 }
1221 }
1222
1223 let elapsed = start.elapsed().as_millis() as i32;
1224 Ok(elapsed)
1225}
1226
1227#[cfg(feature = "postgres")]
1229pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
1230 let start = std::time::Instant::now();
1231 client.batch_execute(sql).await?;
1232 let elapsed = start.elapsed().as_millis() as i32;
1233 Ok(elapsed)
1234}
1235
1236pub fn is_transient_error(e: &WaypointError) -> bool {
1241 match e {
1242 #[cfg(feature = "postgres")]
1243 WaypointError::DatabaseError(pg_err) => {
1244 if pg_err.is_closed() {
1246 return true;
1247 }
1248 if let Some(db_err) = pg_err.as_db_error() {
1250 let code = db_err.code().code();
1251 return matches!(
1255 code,
1256 "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
1257 );
1258 }
1259 let msg = pg_err.to_string().to_lowercase();
1261 msg.contains("connection reset")
1262 || msg.contains("broken pipe")
1263 || msg.contains("connection closed")
1264 || msg.contains("unexpected eof")
1265 }
1266 #[cfg(feature = "mysql")]
1267 WaypointError::MysqlError(my_err) => {
1268 let msg = my_err.to_string().to_lowercase();
1272 msg.contains("connection reset")
1273 || msg.contains("broken pipe")
1274 || msg.contains("connection closed")
1275 || msg.contains("server has gone away")
1276 || msg.contains("lost connection")
1277 || msg.contains("io error")
1278 }
1279 WaypointError::ConnectionLost { .. } => true,
1280 _ => false,
1281 }
1282}
1283
1284#[cfg(feature = "postgres")]
1286pub async fn check_connection(client: &Client) -> Result<()> {
1287 client
1288 .simple_query("")
1289 .await
1290 .map_err(|e| WaypointError::ConnectionLost {
1291 operation: "health check".to_string(),
1292 detail: e.to_string(),
1293 })?;
1294 Ok(())
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300
1301 #[test]
1304 fn test_inject_keepalive_url_style() {
1305 let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
1306 assert_eq!(
1307 result,
1308 "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_inject_keepalive_url_with_existing_params() {
1314 let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
1315 assert_eq!(
1316 result,
1317 "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
1318 );
1319 }
1320
1321 #[test]
1322 fn test_inject_keepalive_kv_style() {
1323 let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
1324 assert_eq!(
1325 result,
1326 "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_inject_keepalive_zero_disables() {
1332 let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
1333 assert_eq!(result, "postgres://user:pass@localhost/db");
1334 }
1335
1336 #[test]
1337 fn test_inject_keepalive_already_present() {
1338 let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
1339 assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
1340 }
1341
1342 #[test]
1345 fn test_transient_error_connection_lost() {
1346 let err = WaypointError::ConnectionLost {
1347 operation: "test".to_string(),
1348 detail: "gone".to_string(),
1349 };
1350 assert!(is_transient_error(&err));
1351 }
1352
1353 #[test]
1354 fn test_transient_error_config_is_not_transient() {
1355 let err = WaypointError::ConfigError("bad config".to_string());
1356 assert!(!is_transient_error(&err));
1357 }
1358
1359 #[test]
1360 fn test_transient_error_migration_failed_is_not_transient() {
1361 let err = WaypointError::MigrationFailed {
1362 script: "V1__test.sql".to_string(),
1363 reason: "syntax error".to_string(),
1364 };
1365 assert!(!is_transient_error(&err));
1366 }
1367
1368 #[test]
1369 fn test_advisory_lock_id_stability() {
1370 let id1 = advisory_lock_id("public", "waypoint_schema_history");
1373 let id2 = advisory_lock_id("public", "waypoint_schema_history");
1374 assert_eq!(id1, id2);
1375 let id3 = advisory_lock_id("public", "other_table");
1377 assert_ne!(id1, id3);
1378 }
1379
1380 #[test]
1381 fn test_advisory_lock_id_is_scoped_per_schema() {
1382 let a = advisory_lock_id("tenant_a", "waypoint_schema_history");
1386 let b = advisory_lock_id("tenant_b", "waypoint_schema_history");
1387 assert_ne!(a, b, "schemas in one database must not share a lock");
1388 }
1389
1390 #[test]
1391 fn test_advisory_lock_id_separator_cannot_be_forged() {
1392 assert_ne!(
1395 advisory_lock_id("a", "b_c"),
1396 advisory_lock_id("a_b", "c"),
1397 "schema/table boundary must be unambiguous"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_transient_error_lock_error_is_not_transient() {
1403 let err = WaypointError::LockError("lock failed".to_string());
1404 assert!(!is_transient_error(&err));
1405 }
1406
1407 #[test]
1408 fn test_transient_error_io_error_is_not_transient() {
1409 let err = WaypointError::IoError(std::io::Error::new(
1410 std::io::ErrorKind::NotFound,
1411 "file not found",
1412 ));
1413 assert!(!is_transient_error(&err));
1414 }
1415
1416 #[test]
1417 fn test_validate_identifier_valid() {
1418 assert!(validate_identifier("users").is_ok());
1419 assert!(validate_identifier("my_table").is_ok());
1420 assert!(validate_identifier("Table123").is_ok());
1421 assert!(validate_identifier("a").is_ok());
1422 }
1423
1424 #[test]
1425 fn test_validate_identifier_invalid() {
1426 assert!(validate_identifier("").is_err());
1427 assert!(validate_identifier("my-table").is_err());
1428 assert!(validate_identifier("my table").is_err());
1429 assert!(validate_identifier("table.name").is_err());
1430 assert!(validate_identifier("table;drop").is_err());
1431 }
1432
1433 #[test]
1434 fn test_quote_ident_simple() {
1435 assert_eq!(quote_ident("users"), "\"users\"");
1436 }
1437
1438 #[test]
1439 fn test_quote_ident_embedded_quotes() {
1440 assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
1441 }
1442
1443 #[test]
1444 fn test_quote_ident_empty() {
1445 assert_eq!(quote_ident(""), "\"\"");
1446 }
1447
1448 #[test]
1449 fn test_inject_keepalive_postgresql_prefix() {
1450 let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
1451 assert_eq!(
1452 result,
1453 "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1454 );
1455 }
1456
1457 #[cfg(feature = "mysql")]
1458 #[test]
1459 fn mysql_lock_key_is_scoped_per_database() {
1460 let a = mysql_lock_key("app_prod", "waypoint_schema_history");
1464 let b = mysql_lock_key("app_staging", "waypoint_schema_history");
1465 assert_ne!(a, b);
1466 assert_eq!(a, "waypoint_app_prod_waypoint_schema_history");
1467 }
1468
1469 #[cfg(feature = "mysql")]
1470 #[test]
1471 fn mysql_lock_key_respects_the_64_char_limit() {
1472 let long_db = "d".repeat(60);
1473 let long_tbl = "t".repeat(60);
1474 let k = mysql_lock_key(&long_db, &long_tbl);
1475 assert!(
1476 k.len() <= 64,
1477 "GET_LOCK names are capped at 64: {}",
1478 k.len()
1479 );
1480 }
1481
1482 #[cfg(feature = "mysql")]
1483 #[test]
1484 fn mysql_lock_key_does_not_collide_after_shortening() {
1485 let prefix = "x".repeat(60);
1488 let a = mysql_lock_key(&prefix, "alpha");
1489 let b = mysql_lock_key(&prefix, "beta");
1490 assert!(a.len() <= 64 && b.len() <= 64);
1491 assert_ne!(a, b, "distinct tables collapsed onto one lock key");
1492 }
1493
1494 #[cfg(feature = "mysql")]
1495 #[test]
1496 fn mysql_lock_key_is_stable() {
1497 assert_eq!(mysql_lock_key("db", "tbl"), mysql_lock_key("db", "tbl"));
1500 }
1501
1502 #[test]
1503 fn test_sandbox_name_is_unique_across_rapid_calls() {
1504 let names: std::collections::HashSet<String> =
1509 (0..2000).map(|_| sandbox_name("waypoint_sim")).collect();
1510 assert_eq!(
1511 names.len(),
1512 2000,
1513 "sandbox names collided within a single tight loop"
1514 );
1515 }
1516
1517 #[test]
1518 fn test_sandbox_name_fits_identifier_limits() {
1519 for prefix in ["waypoint_sim", "waypoint_drift_check"] {
1523 let name = sandbox_name(prefix);
1524 assert!(
1525 name.len() <= 63,
1526 "{} is {} bytes, over PostgreSQL's 63-byte limit",
1527 name,
1528 name.len()
1529 );
1530 assert!(name.starts_with(prefix));
1531 }
1532 }
1533
1534 #[test]
1535 fn test_quote_literal_escapes_embedded_single_quotes() {
1536 assert_eq!(quote_literal("fine"), "'fine'");
1539 assert_eq!(quote_literal("it's bad"), "'it''s bad'");
1540 assert_eq!(quote_literal("''"), r"''''''");
1542 assert_eq!(quote_literal(""), r"''");
1543 }
1544
1545 #[test]
1546 fn test_quote_literal_leaves_other_characters_alone() {
1547 assert_eq!(quote_literal(r"back\slash"), r"'back\slash'");
1550 assert_eq!(quote_literal("multi\nline"), "'multi\nline'");
1551 }
1552}