Skip to main content

reinhardt_db/pool/
pool.rs

1//! Connection pool implementation
2
3use super::config::PoolConfig;
4use super::errors::{PoolError, PoolResult};
5use super::events::{PoolEvent, PoolEventListener};
6use sqlx::{Database, MySql, Pool, Postgres, Sqlite};
7use std::mem::ManuallyDrop;
8use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9use std::sync::{Arc, OnceLock};
10use tokio::sync::RwLock;
11
12/// Mask the password in a database URL for safe display.
13///
14/// Handles standard URL formats like `scheme://user:password@host/db`
15/// and replaces the password portion with `***`.
16/// Correctly handles passwords containing `@` by using the last `@` as
17/// the user-info delimiter.
18pub(crate) fn mask_url_password(url: &str) -> String {
19	// Try to parse as a standard URL with scheme://user:pass@host format
20	if let Some(scheme_end) = url.find("://") {
21		let after_scheme = &url[scheme_end + 3..];
22
23		// Use the last @ as the user-info delimiter, since passwords may contain @
24		if let Some(at_pos) = after_scheme.rfind('@') {
25			let user_info = &after_scheme[..at_pos];
26
27			// Find the first colon separating user from password
28			if let Some(colon_pos) = user_info.find(':') {
29				let scheme_and_user = &url[..scheme_end + 3 + colon_pos + 1];
30				let rest = &url[scheme_end + 3 + at_pos..];
31				return format!("{}***{}", scheme_and_user, rest);
32			}
33		}
34	}
35
36	// No password found, return as-is
37	url.to_string()
38}
39
40fn generate_connection_id() -> String {
41	uuid::Uuid::now_v7().to_string()
42}
43
44struct PoolEventHub {
45	listeners: RwLock<Vec<Arc<dyn PoolEventListener>>>,
46	listener_count: AtomicUsize,
47}
48
49impl PoolEventHub {
50	fn new() -> Self {
51		Self {
52			listeners: RwLock::new(Vec::new()),
53			listener_count: AtomicUsize::new(0),
54		}
55	}
56
57	fn has_listeners(&self) -> bool {
58		self.listener_count.load(Ordering::Acquire) > 0
59	}
60
61	async fn add_listener(&self, listener: Arc<dyn PoolEventListener>) {
62		let mut listeners = self.listeners.write().await;
63		listeners.push(listener);
64		self.listener_count
65			.store(listeners.len(), Ordering::Release);
66	}
67
68	async fn emit_event(&self, event: PoolEvent) {
69		if !self.has_listeners() {
70			return;
71		}
72
73		let listeners = self.listeners.read().await;
74		for listener in listeners.iter() {
75			listener.on_event(event.clone()).await;
76		}
77	}
78}
79
80/// A database connection pool
81pub struct ConnectionPool<DB: Database> {
82	pool: Pool<DB>,
83	config: PoolConfig,
84	url: String,
85	events: Arc<PoolEventHub>,
86	first_connect_fired: Arc<AtomicBool>,
87}
88
89impl ConnectionPool<Postgres> {
90	/// Create a new PostgreSQL connection pool
91	///
92	/// # Examples
93	///
94	/// ```
95	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
96	///
97	/// # async fn example() {
98	/// let config = PoolConfig::default();
99	/// // For doctest purposes, using SQLite in-memory instead of PostgreSQL
100	/// let pool = ConnectionPool::new_sqlite("sqlite::memory:", config).await.unwrap();
101	/// assert!(pool.url().contains("memory"));
102	/// assert_eq!(pool.config().max_connections, 10);
103	/// # }
104	/// # tokio::runtime::Runtime::new().unwrap().block_on(example());
105	/// ```
106	pub async fn new_postgres(url: &str, config: PoolConfig) -> PoolResult<Self> {
107		config.validate().map_err(PoolError::Config)?;
108
109		let pool = sqlx::postgres::PgPoolOptions::new()
110			.min_connections(config.min_connections)
111			.max_connections(config.max_connections)
112			.acquire_timeout(config.acquire_timeout)
113			.idle_timeout(config.idle_timeout)
114			.max_lifetime(config.max_lifetime)
115			.test_before_acquire(config.test_before_acquire)
116			.connect(url)
117			.await?;
118
119		Ok(Self {
120			pool,
121			config,
122			url: url.to_string(),
123			events: Arc::new(PoolEventHub::new()),
124			first_connect_fired: Arc::new(AtomicBool::new(false)),
125		})
126	}
127}
128
129impl ConnectionPool<MySql> {
130	/// Create a new MySQL connection pool
131	///
132	/// # Examples
133	///
134	/// ```
135	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
136	///
137	/// # async fn example() {
138	/// let config = PoolConfig::default();
139	/// // For doctest purposes, using SQLite in-memory instead of MySQL
140	/// let pool = ConnectionPool::new_sqlite("sqlite::memory:", config).await.unwrap();
141	/// assert!(pool.url().contains("memory"));
142	/// assert_eq!(pool.config().max_connections, 10);
143	/// # }
144	/// # tokio::runtime::Runtime::new().unwrap().block_on(example());
145	/// ```
146	pub async fn new_mysql(url: &str, config: PoolConfig) -> PoolResult<Self> {
147		config.validate().map_err(PoolError::Config)?;
148
149		let pool = sqlx::mysql::MySqlPoolOptions::new()
150			.min_connections(config.min_connections)
151			.max_connections(config.max_connections)
152			.acquire_timeout(config.acquire_timeout)
153			.idle_timeout(config.idle_timeout)
154			.max_lifetime(config.max_lifetime)
155			.test_before_acquire(config.test_before_acquire)
156			.connect(url)
157			.await?;
158
159		Ok(Self {
160			pool,
161			config,
162			url: url.to_string(),
163			events: Arc::new(PoolEventHub::new()),
164			first_connect_fired: Arc::new(AtomicBool::new(false)),
165		})
166	}
167}
168
169impl ConnectionPool<Sqlite> {
170	/// Create a new SQLite connection pool
171	///
172	/// # Examples
173	///
174	/// ```
175	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
176	///
177	/// # async fn example() {
178	/// let config = PoolConfig::default();
179	/// // Using in-memory SQLite for doctest
180	/// let pool = ConnectionPool::new_sqlite("sqlite::memory:", config).await.unwrap();
181	/// assert!(pool.url().contains("memory"));
182	/// assert!(pool.config().max_connections > 0);
183	/// # }
184	/// # tokio::runtime::Runtime::new().unwrap().block_on(example());
185	/// ```
186	pub async fn new_sqlite(url: &str, config: PoolConfig) -> PoolResult<Self> {
187		config.validate().map_err(PoolError::Config)?;
188
189		let pool = sqlx::sqlite::SqlitePoolOptions::new()
190			.min_connections(config.min_connections)
191			.max_connections(config.max_connections)
192			.acquire_timeout(config.acquire_timeout)
193			.idle_timeout(config.idle_timeout)
194			.max_lifetime(config.max_lifetime)
195			.test_before_acquire(config.test_before_acquire)
196			.connect(url)
197			.await?;
198
199		Ok(Self {
200			pool,
201			config,
202			url: url.to_string(),
203			events: Arc::new(PoolEventHub::new()),
204			first_connect_fired: Arc::new(AtomicBool::new(false)),
205		})
206	}
207}
208
209impl<DB> ConnectionPool<DB>
210where
211	DB: sqlx::Database,
212{
213	/// Add an event listener
214	///
215	pub async fn add_listener(&self, listener: Arc<dyn PoolEventListener>) {
216		self.events.add_listener(listener).await;
217	}
218
219	/// Emit an event to all listeners
220	pub(crate) async fn emit_event(&self, event: PoolEvent) {
221		self.events.emit_event(event).await;
222	}
223	/// Acquire a connection from the pool with event emission
224	///
225	/// # Examples
226	///
227	/// ```no_run
228	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
229	///
230	/// # async fn example() {
231	/// let config = PoolConfig::default();
232	/// let pool = ConnectionPool::new_postgres("postgresql://user:pass@localhost/test", config)
233	///     .await
234	///     .unwrap();
235	///
236	/// // Acquire a connection
237	/// let conn = pool.acquire().await;
238	/// assert!(conn.is_ok());
239	/// # }
240	/// ```
241	pub async fn acquire(&self) -> PoolResult<PooledConnection<DB>> {
242		// Check if this is the first connection
243		let is_first = !self.first_connect_fired.swap(true, Ordering::SeqCst);
244
245		let conn = self.pool.acquire().await?;
246		let connection_id = OnceLock::new();
247
248		if self.events.has_listeners() {
249			let id = connection_id.get_or_init(generate_connection_id).clone();
250
251			if is_first {
252				// Emit first_connect event (using ConnectionCreated as proxy)
253				self.emit_event(PoolEvent::connection_created(id.clone()))
254					.await;
255			}
256
257			// Emit checkout event
258			self.emit_event(PoolEvent::connection_acquired(id)).await;
259		}
260
261		Ok(PooledConnection {
262			conn: ManuallyDrop::new(conn),
263			events: self.events.clone(),
264			connection_id,
265		})
266	}
267	/// Get the underlying pool
268	///
269	pub fn inner(&self) -> &Pool<DB> {
270		&self.pool
271	}
272	/// Get pool configuration
273	///
274	pub fn config(&self) -> &PoolConfig {
275		&self.config
276	}
277	/// Close the pool
278	///
279	/// Attempts to gracefully close the pool with a 5-second timeout.
280	/// If active connections are not returned within this time, the pool
281	/// will be forcefully closed.
282	pub async fn close(&self) {
283		use tokio::time::{Duration, timeout};
284
285		// Try to close gracefully with a timeout
286		let close_future = self.pool.close();
287		if timeout(Duration::from_secs(5), close_future).await.is_err() {
288			// Timeout occurred - pool had active connections
289			// The pool will be forcefully closed when dropped
290		}
291	}
292	/// Get the database URL with password masked for safe display
293	///
294	/// Returns the database URL with any password replaced by `***`
295	/// to prevent credential exposure in logs and debug output.
296	/// Use `url_raw()` when the actual password is needed for reconnection.
297	pub fn url(&self) -> String {
298		mask_url_password(&self.url)
299	}
300
301	/// Get the raw database URL including credentials
302	///
303	/// This method returns the unmasked URL containing the actual password.
304	/// Use with caution - prefer `url()` for logging and display purposes.
305	// Allow dead_code: preserved for internal use by reconnection logic (e.g., `recreate()`)
306	#[allow(dead_code)]
307	pub(crate) fn url_raw(&self) -> &str {
308		&self.url
309	}
310}
311
312// Database-specific recreate implementations
313impl ConnectionPool<Postgres> {
314	/// Recreate the pool with the same configuration
315	///
316	/// # Examples
317	///
318	/// ```
319	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
320	///
321	/// # async fn example() {
322	/// let config = PoolConfig::default();
323	/// // For doctest purposes, using SQLite in-memory instead of PostgreSQL
324	/// let mut pool = ConnectionPool::new_sqlite("sqlite::memory:", config)
325	///     .await
326	///     .unwrap();
327	///
328	/// // Recreate the pool
329	/// pool.recreate().await.unwrap();
330	/// assert_eq!(pool.config().max_connections, 10);
331	/// # }
332	/// # tokio::runtime::Runtime::new().unwrap().block_on(example());
333	/// ```
334	pub async fn recreate(&mut self) -> PoolResult<()> {
335		// Close existing pool
336		self.pool.close().await;
337
338		// Create new pool with same configuration
339		let new_pool = sqlx::postgres::PgPoolOptions::new()
340			.min_connections(self.config.min_connections)
341			.max_connections(self.config.max_connections)
342			.acquire_timeout(self.config.acquire_timeout)
343			.idle_timeout(self.config.idle_timeout)
344			.max_lifetime(self.config.max_lifetime)
345			.test_before_acquire(self.config.test_before_acquire)
346			.connect(&self.url)
347			.await?;
348
349		self.pool = new_pool;
350		self.first_connect_fired.store(false, Ordering::SeqCst);
351
352		Ok(())
353	}
354}
355
356impl ConnectionPool<MySql> {
357	/// Recreate the pool with the same configuration
358	///
359	/// # Examples
360	///
361	/// ```
362	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
363	///
364	/// # async fn example() {
365	/// let config = PoolConfig::default();
366	/// // For doctest purposes, using SQLite in-memory instead of MySQL
367	/// let mut pool = ConnectionPool::new_sqlite("sqlite::memory:", config)
368	///     .await
369	///     .unwrap();
370	///
371	/// // Recreate the pool
372	/// pool.recreate().await.unwrap();
373	/// assert_eq!(pool.config().max_connections, 10);
374	/// # }
375	/// # tokio::runtime::Runtime::new().unwrap().block_on(example());
376	/// ```
377	pub async fn recreate(&mut self) -> PoolResult<()> {
378		// Close existing pool
379		self.pool.close().await;
380
381		// Create new pool with same configuration
382		let new_pool = sqlx::mysql::MySqlPoolOptions::new()
383			.min_connections(self.config.min_connections)
384			.max_connections(self.config.max_connections)
385			.acquire_timeout(self.config.acquire_timeout)
386			.idle_timeout(self.config.idle_timeout)
387			.max_lifetime(self.config.max_lifetime)
388			.test_before_acquire(self.config.test_before_acquire)
389			.connect(&self.url)
390			.await?;
391
392		self.pool = new_pool;
393		self.first_connect_fired.store(false, Ordering::SeqCst);
394
395		Ok(())
396	}
397}
398
399impl ConnectionPool<Sqlite> {
400	/// Recreate the pool with the same configuration
401	///
402	/// # Examples
403	///
404	/// ```
405	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
406	///
407	/// # async fn example() {
408	/// let config = PoolConfig::default();
409	/// let mut pool = ConnectionPool::new_sqlite("sqlite::memory:", config)
410	///     .await
411	///     .unwrap();
412	///
413	/// // Recreate the pool
414	/// pool.recreate().await.unwrap();
415	/// assert!(pool.url().contains("memory"));
416	/// # }
417	/// # tokio::runtime::Runtime::new().unwrap().block_on(example());
418	/// ```
419	pub async fn recreate(&mut self) -> PoolResult<()> {
420		// Close existing pool
421		self.pool.close().await;
422
423		// Create new pool with same configuration
424		let new_pool = sqlx::sqlite::SqlitePoolOptions::new()
425			.min_connections(self.config.min_connections)
426			.max_connections(self.config.max_connections)
427			.acquire_timeout(self.config.acquire_timeout)
428			.idle_timeout(self.config.idle_timeout)
429			.max_lifetime(self.config.max_lifetime)
430			.test_before_acquire(self.config.test_before_acquire)
431			.connect(&self.url)
432			.await?;
433
434		self.pool = new_pool;
435		self.first_connect_fired.store(false, Ordering::SeqCst);
436
437		Ok(())
438	}
439}
440
441/// A pooled connection wrapper with event emission
442pub struct PooledConnection<DB: sqlx::Database> {
443	// Wrapped in ManuallyDrop so we can take ownership in Drop.
444	// When no tokio runtime is available, we detach the connection
445	// to avoid sqlx's PoolConnection::Drop calling rt::spawn().
446	conn: ManuallyDrop<sqlx::pool::PoolConnection<DB>>,
447	events: Arc<PoolEventHub>,
448	connection_id: OnceLock<String>,
449}
450
451impl<DB: sqlx::Database> PooledConnection<DB> {
452	/// Documentation for `inner`
453	///
454	pub fn inner(&mut self) -> &mut sqlx::pool::PoolConnection<DB> {
455		&mut self.conn
456	}
457	/// Get the unique identifier for this connection
458	///
459	/// # Examples
460	///
461	/// ```no_run
462	/// use reinhardt_db::pool::{ConnectionPool, PoolConfig};
463	///
464	/// # async fn example() {
465	/// let config = PoolConfig::default();
466	/// let pool = ConnectionPool::new_postgres("postgresql://user:pass@localhost/test", config)
467	///     .await
468	///     .unwrap();
469	///
470	/// let mut conn = pool.acquire().await.unwrap();
471	/// let id = conn.connection_id();
472	/// assert!(!id.is_empty());
473	/// # }
474	/// ```
475	pub fn connection_id(&self) -> &str {
476		self.connection_id
477			.get_or_init(generate_connection_id)
478			.as_str()
479	}
480	/// Invalidate this connection (hard invalidation - connection is unusable)
481	///
482	pub async fn invalidate(self, reason: String) {
483		if self.events.has_listeners() {
484			let connection_id = self
485				.connection_id
486				.get_or_init(generate_connection_id)
487				.clone();
488			self.events
489				.emit_event(PoolEvent::connection_invalidated(connection_id, reason))
490				.await;
491		}
492		// Connection will be dropped and not returned to pool
493	}
494	/// Soft invalidate this connection (can complete current operation)
495	///
496	pub async fn soft_invalidate(&mut self) {
497		if self.events.has_listeners() {
498			let connection_id = self
499				.connection_id
500				.get_or_init(generate_connection_id)
501				.clone();
502			self.events
503				.emit_event(PoolEvent::connection_soft_invalidated(connection_id))
504				.await;
505		}
506	}
507	/// Reset this connection
508	///
509	pub async fn reset(&mut self) {
510		if self.events.has_listeners() {
511			let connection_id = self
512				.connection_id
513				.get_or_init(generate_connection_id)
514				.clone();
515			self.events
516				.emit_event(PoolEvent::connection_reset(connection_id))
517				.await;
518		}
519	}
520}
521
522impl<DB: sqlx::Database> Drop for PooledConnection<DB> {
523	fn drop(&mut self) {
524		// SAFETY: ManuallyDrop::take is called exactly once (in drop).
525		let conn = unsafe { ManuallyDrop::take(&mut self.conn) };
526
527		match tokio::runtime::Handle::try_current() {
528			Ok(handle) => {
529				// Runtime available: drop the connection normally (returns to pool)
530				// and emit the connection-returned event.
531				drop(conn);
532
533				if self.events.has_listeners() {
534					let events = self.events.clone();
535					let connection_id = self
536						.connection_id
537						.get_or_init(generate_connection_id)
538						.clone();
539
540					handle.spawn(async move {
541						events
542							.emit_event(PoolEvent::connection_returned(connection_id))
543							.await;
544					});
545				}
546			}
547			Err(_) => {
548				// No runtime available: prevent sqlx's PoolConnection::Drop
549				// from running, as it calls crate::rt::spawn() which panics
550				// without a tokio runtime. The connection is intentionally
551				// leaked to avoid the panic.
552				std::mem::forget(conn);
553			}
554		}
555	}
556}
557
558#[cfg(test)]
559mod tests {
560	use super::*;
561	use rstest::rstest;
562	use std::sync::Mutex;
563
564	struct RecordingListener {
565		events: Arc<Mutex<Vec<&'static str>>>,
566	}
567
568	#[async_trait::async_trait]
569	impl PoolEventListener for RecordingListener {
570		async fn on_event(&self, event: PoolEvent) {
571			let name = match event {
572				PoolEvent::ConnectionAcquired { .. } => "acquired",
573				PoolEvent::ConnectionReturned { .. } => "returned",
574				PoolEvent::ConnectionCreated { .. } => "created",
575				PoolEvent::ConnectionClosed { .. } => "closed",
576				PoolEvent::ConnectionTestFailed { .. } => "test_failed",
577				PoolEvent::ConnectionInvalidated { .. } => "invalidated",
578				PoolEvent::ConnectionSoftInvalidated { .. } => "soft_invalidated",
579				PoolEvent::ConnectionReset { .. } => "reset",
580			};
581			self.events
582				.lock()
583				.expect("events mutex should not be poisoned")
584				.push(name);
585		}
586	}
587
588	#[rstest]
589	#[case(
590		"postgresql://user:secret@localhost:5432/mydb",
591		"postgresql://user:***@localhost:5432/mydb"
592	)]
593	#[case(
594		"mysql://admin:p@ssw0rd@db.example.com/app",
595		"mysql://admin:***@db.example.com/app"
596	)]
597	#[case(
598		"postgres://user:pass@host:5432/db?sslmode=require",
599		"postgres://user:***@host:5432/db?sslmode=require"
600	)]
601	fn test_mask_url_password_with_credentials(#[case] input: &str, #[case] expected: &str) {
602		// Arrange
603		// (input provided by case parameters)
604
605		// Act
606		let masked = mask_url_password(input);
607
608		// Assert
609		assert_eq!(masked, expected);
610	}
611
612	#[rstest]
613	#[case("sqlite::memory:")]
614	#[case("sqlite:///path/to/db.sqlite")]
615	#[case("postgresql://user@localhost:5432/mydb")]
616	fn test_mask_url_password_without_password(#[case] input: &str) {
617		// Arrange
618		// (input provided by case parameter)
619
620		// Act
621		let masked = mask_url_password(input);
622
623		// Assert
624		assert_eq!(masked, input, "URL without password should be unchanged");
625	}
626
627	#[rstest]
628	fn test_mask_url_password_empty_password() {
629		// Arrange
630		let url = "postgresql://user:@localhost:5432/mydb";
631
632		// Act
633		let masked = mask_url_password(url);
634
635		// Assert
636		assert_eq!(masked, "postgresql://user:***@localhost:5432/mydb");
637	}
638
639	#[rstest]
640	fn test_mask_url_password_special_chars_in_password() {
641		// Arrange
642		let url = "postgresql://user:p%40ss%3Aw0rd@localhost:5432/mydb";
643
644		// Act
645		let masked = mask_url_password(url);
646
647		// Assert
648		assert_eq!(masked, "postgresql://user:***@localhost:5432/mydb");
649		assert!(
650			!masked.contains("p%40ss"),
651			"Password should be fully masked"
652		);
653	}
654
655	#[rstest]
656	fn test_mask_url_password_preserves_non_url() {
657		// Arrange
658		let non_url = "not-a-url-just-a-string";
659
660		// Act
661		let masked = mask_url_password(non_url);
662
663		// Assert
664		assert_eq!(
665			masked, non_url,
666			"Non-URL strings should pass through unchanged"
667		);
668	}
669
670	#[rstest]
671	fn test_handle_try_current_returns_err_outside_runtime() {
672		// Arrange & Act & Assert
673		// Run on a fresh thread to avoid inheriting runtime context
674		// from the test runner's worker thread.
675		let handle = std::thread::spawn(|| {
676			let result = tokio::runtime::Handle::try_current();
677			assert!(
678				result.is_err(),
679				"Handle::try_current() should return Err outside of a tokio runtime"
680			);
681		});
682		handle.join().expect("thread should not panic");
683	}
684
685	#[rstest]
686	fn test_drop_pooled_connection_outside_runtime_does_not_panic() {
687		// Arrange
688		// Create a Tokio runtime and acquire a pooled connection inside it.
689		let rt = tokio::runtime::Runtime::new().expect("failed to create Tokio runtime");
690
691		let (pool, conn) = rt.block_on(async {
692			let config = PoolConfig::default();
693			let pool = ConnectionPool::new_sqlite("sqlite::memory:", config)
694				.await
695				.expect("failed to create ConnectionPool");
696
697			let conn = pool.acquire().await.expect("failed to acquire connection");
698
699			(pool, conn)
700		});
701
702		// Drop the runtime so there is no active Tokio runtime.
703		drop(rt);
704
705		// Act & Assert
706		// Dropping the connection outside any runtime should not panic.
707		drop(conn);
708
709		// Also drop the pool to ensure cleanup does not panic outside a runtime.
710		drop(pool);
711	}
712
713	#[tokio::test]
714	async fn test_pool_events_are_emitted_when_listener_registered() {
715		// Arrange
716		let events = Arc::new(Mutex::new(Vec::new()));
717		let listener = Arc::new(RecordingListener {
718			events: events.clone(),
719		});
720		let pool = ConnectionPool::new_sqlite("sqlite::memory:", PoolConfig::default())
721			.await
722			.expect("failed to create ConnectionPool");
723		pool.add_listener(listener).await;
724
725		// Act
726		let conn = pool.acquire().await.expect("failed to acquire connection");
727		drop(conn);
728		tokio::task::yield_now().await;
729
730		// Assert
731		let recorded = events.lock().expect("events mutex should not be poisoned");
732		assert_eq!(
733			recorded.as_slice(),
734			["created", "acquired", "returned"],
735			"pool listener should observe the first acquire and return events"
736		);
737	}
738}