Skip to main content

geode_client/
pool.rs

1//! Connection pooling for Geode connections.
2
3use std::sync::Arc;
4use tokio::sync::{Mutex, Semaphore};
5
6use crate::client::{Client, Connection};
7use crate::error::{Error, Result};
8
9/// Connection pool for managing QUIC connections
10pub struct ConnectionPool {
11    client: Client,
12    connections: Arc<Mutex<Vec<Connection>>>,
13    semaphore: Arc<Semaphore>,
14    max_size: usize,
15}
16
17impl ConnectionPool {
18    /// Create a new connection pool.
19    ///
20    /// # Panics
21    ///
22    /// Panics if `max_size` is 0. A connection pool must have at least one
23    /// connection slot to function properly. (Gap #18: CWE-400, CWE-835)
24    pub fn new(host: impl Into<String>, port: u16, max_size: usize) -> Self {
25        assert!(
26            max_size > 0,
27            "ConnectionPool max_size must be at least 1 (was 0). \
28             A pool with 0 connections would deadlock on acquire()."
29        );
30        Self {
31            client: Client::new(host, port),
32            connections: Arc::new(Mutex::new(Vec::new())),
33            semaphore: Arc::new(Semaphore::new(max_size)),
34            max_size,
35        }
36    }
37
38    /// Configure to skip TLS verification
39    pub fn skip_verify(mut self, skip: bool) -> Self {
40        self.client = self.client.skip_verify(skip);
41        self
42    }
43
44    /// Set page size for queries
45    pub fn page_size(mut self, size: usize) -> Self {
46        self.client = self.client.page_size(size);
47        self
48    }
49
50    /// Set the authentication username used for new pooled connections.
51    pub fn username(mut self, username: impl Into<String>) -> Self {
52        self.client = self.client.username(username);
53        self
54    }
55
56    /// Set the authentication password used for new pooled connections.
57    pub fn password(mut self, password: impl Into<String>) -> Self {
58        self.client = self.client.password(password);
59        self
60    }
61
62    /// Acquire a connection from the pool
63    ///
64    /// Returns a healthy connection from the pool, or creates a new one if needed.
65    /// Stale connections (those where the underlying QUIC connection has been closed)
66    /// are automatically discarded during acquisition.
67    pub async fn acquire(&self) -> Result<PooledConnection> {
68        let permit = Arc::clone(&self.semaphore)
69            .acquire_owned()
70            .await
71            .map_err(|_| Error::pool("Connection pool has been closed"))?;
72
73        // Try to get a healthy existing connection
74        let connection = loop {
75            let conn = {
76                let mut connections = self.connections.lock().await;
77                connections.pop()
78            };
79
80            match conn {
81                Some(c) if c.is_healthy() => {
82                    // Connection is healthy, use it
83                    break c;
84                }
85                Some(_) => {
86                    // Connection is stale, discard it and try another
87                    // (the connection is dropped here, cleaning up resources)
88                    continue;
89                }
90                None => {
91                    // No pooled connections available, create a new one
92                    let client = self.client.clone();
93                    break client.connect().await?;
94                }
95            }
96        };
97
98        Ok(PooledConnection {
99            connection: Some(connection),
100            pool: self.connections.clone(),
101            _permit: permit,
102        })
103    }
104
105    /// Get current pool size
106    pub async fn size(&self) -> usize {
107        self.connections.lock().await.len()
108    }
109
110    /// Get the maximum pool size
111    pub fn max_size(&self) -> usize {
112        self.max_size
113    }
114}
115
116/// A pooled connection that returns to the pool when dropped
117pub struct PooledConnection {
118    connection: Option<Connection>,
119    pool: Arc<Mutex<Vec<Connection>>>,
120    _permit: tokio::sync::OwnedSemaphorePermit,
121}
122
123impl PooledConnection {
124    /// Get a reference to the underlying connection.
125    ///
126    /// # Panics
127    ///
128    /// Panics if called after the connection has been dropped or taken.
129    /// This should never happen in normal usage as the connection is only
130    /// taken during Drop.
131    pub fn inner(&self) -> &Connection {
132        self.connection
133            .as_ref()
134            .expect("PooledConnection invariant violated: connection was None")
135    }
136}
137
138impl Drop for PooledConnection {
139    fn drop(&mut self) {
140        if let Some(mut conn) = self.connection.take() {
141            // Only return healthy connections to the pool
142            // Stale connections are dropped, cleaning up their resources
143            if conn.is_healthy() {
144                let pool = self.pool.clone();
145                tokio::spawn(async move {
146                    // Best-effort rollback if the connection was left in a transaction
147                    if conn.in_transaction() {
148                        let _ = conn.rollback().await;
149                    }
150                    let mut connections = pool.lock().await;
151                    connections.push(conn);
152                });
153            }
154            // If unhealthy, conn is dropped here and not returned to pool
155        }
156    }
157}
158
159impl std::ops::Deref for PooledConnection {
160    type Target = Connection;
161
162    fn deref(&self) -> &Self::Target {
163        self.connection
164            .as_ref()
165            .expect("PooledConnection invariant violated: connection was None")
166    }
167}
168
169impl std::ops::DerefMut for PooledConnection {
170    fn deref_mut(&mut self) -> &mut Self::Target {
171        self.connection
172            .as_mut()
173            .expect("PooledConnection invariant violated: connection was None")
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_connection_pool_new() {
183        let pool = ConnectionPool::new("localhost", 3141, 10);
184        assert_eq!(pool.max_size(), 10);
185    }
186
187    #[test]
188    fn test_connection_pool_new_different_host() {
189        let pool = ConnectionPool::new("192.168.1.100", 8443, 5);
190        assert_eq!(pool.max_size(), 5);
191    }
192
193    #[test]
194    fn test_connection_pool_new_string_host() {
195        let host = String::from("geode.example.com");
196        let pool = ConnectionPool::new(host, 3141, 20);
197        assert_eq!(pool.max_size(), 20);
198    }
199
200    #[test]
201    fn test_connection_pool_skip_verify() {
202        let pool = ConnectionPool::new("localhost", 3141, 10).skip_verify(true);
203        // Configuration is passed through to client
204        assert_eq!(pool.max_size(), 10);
205    }
206
207    #[test]
208    fn test_connection_pool_skip_verify_false() {
209        let pool = ConnectionPool::new("localhost", 3141, 10).skip_verify(false);
210        assert_eq!(pool.max_size(), 10);
211    }
212
213    #[test]
214    fn test_connection_pool_page_size() {
215        let pool = ConnectionPool::new("localhost", 3141, 10).page_size(500);
216        assert_eq!(pool.max_size(), 10);
217    }
218
219    #[test]
220    fn test_connection_pool_chained_config() {
221        let pool = ConnectionPool::new("localhost", 3141, 10)
222            .skip_verify(true)
223            .page_size(1000);
224        assert_eq!(pool.max_size(), 10);
225    }
226
227    #[tokio::test]
228    async fn test_connection_pool_initial_size() {
229        let pool = ConnectionPool::new("localhost", 3141, 10);
230        // Pool starts empty
231        assert_eq!(pool.size().await, 0);
232    }
233
234    #[test]
235    #[should_panic(expected = "ConnectionPool max_size must be at least 1")]
236    fn test_connection_pool_max_size_zero_panics() {
237        // Gap #18: max_size=0 would cause deadlock on acquire() since semaphore
238        // would have 0 permits. Now properly panics at construction time.
239        let _pool = ConnectionPool::new("localhost", 3141, 0);
240    }
241
242    #[test]
243    fn test_connection_pool_max_size_one() {
244        let pool = ConnectionPool::new("localhost", 3141, 1);
245        assert_eq!(pool.max_size(), 1);
246    }
247
248    #[test]
249    fn test_connection_pool_max_size_large() {
250        let pool = ConnectionPool::new("localhost", 3141, 1000);
251        assert_eq!(pool.max_size(), 1000);
252    }
253
254    // Note: Full integration tests for acquire() and health checking require
255    // a running Geode server and are covered in the integration test suite.
256    // The health check functionality (is_healthy(), stale connection discard)
257    // is verified in integration tests with real connections (Gap #16).
258
259    // The following tests verify the structural aspects of PooledConnection
260    // without actually establishing connections.
261
262    #[test]
263    fn test_semaphore_permits_match_max_size() {
264        let pool = ConnectionPool::new("localhost", 3141, 5);
265        // Semaphore should have permits equal to max_size
266        assert_eq!(pool.semaphore.available_permits(), 5);
267    }
268
269    #[test]
270    fn test_connections_vec_initially_empty() {
271        let pool = ConnectionPool::new("localhost", 3141, 10);
272        // We can't directly access the mutex contents in a sync test,
273        // but we verified size() returns 0 in the async test above
274        assert_eq!(pool.max_size(), 10);
275    }
276}