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/// Default maximum pool size, matching the Go driver's DefaultMaxOpenConns.
10pub const DEFAULT_POOL_MAX_SIZE: usize = 16;
11
12/// Connection pool for managing QUIC connections
13pub struct ConnectionPool {
14    client: Client,
15    connections: Arc<Mutex<Vec<Connection>>>,
16    semaphore: Arc<Semaphore>,
17    max_size: usize,
18}
19
20impl ConnectionPool {
21    /// Create a new connection pool.
22    ///
23    /// # Panics
24    ///
25    /// Panics if `max_size` is 0. A connection pool must have at least one
26    /// connection slot to function properly. (Gap #18: CWE-400, CWE-835)
27    pub fn new(host: impl Into<String>, port: u16, max_size: usize) -> Self {
28        assert!(
29            max_size > 0,
30            "ConnectionPool max_size must be at least 1 (was 0). \
31             A pool with 0 connections would deadlock on acquire()."
32        );
33        Self {
34            client: Client::new(host, port),
35            connections: Arc::new(Mutex::new(Vec::new())),
36            semaphore: Arc::new(Semaphore::new(max_size)),
37            max_size,
38        }
39    }
40
41    /// Build a pool from a DSN string using the default max size (16).
42    ///
43    /// # Errors
44    /// Returns an error if the DSN is invalid.
45    pub fn from_dsn(dsn: &str) -> Result<Self> {
46        Self::from_dsn_with_size(dsn, DEFAULT_POOL_MAX_SIZE)
47    }
48
49    /// Build a pool from a DSN string with an explicit max size.
50    ///
51    /// # Panics
52    /// Panics if `max_size` is 0 (a pool needs at least one slot).
53    ///
54    /// # Errors
55    /// Returns an error if the DSN is invalid.
56    pub fn from_dsn_with_size(dsn: &str, max_size: usize) -> Result<Self> {
57        assert!(max_size > 0, "ConnectionPool max_size must be at least 1");
58        let client = Client::from_dsn(dsn)?;
59        Ok(Self::from_client(client, max_size))
60    }
61
62    /// Build a pool from a pre-configured [`Client`].
63    ///
64    /// # Panics
65    /// Panics if `max_size` is 0.
66    pub fn from_client(client: Client, max_size: usize) -> Self {
67        assert!(max_size > 0, "ConnectionPool max_size must be at least 1");
68        Self {
69            client,
70            connections: Arc::new(Mutex::new(Vec::new())),
71            semaphore: Arc::new(Semaphore::new(max_size)),
72            max_size,
73        }
74    }
75
76    /// Configure to skip TLS verification
77    pub fn skip_verify(mut self, skip: bool) -> Self {
78        self.client = self.client.skip_verify(skip);
79        self
80    }
81
82    /// Set page size for queries
83    pub fn page_size(mut self, size: usize) -> Self {
84        self.client = self.client.page_size(size);
85        self
86    }
87
88    /// Set the authentication username used for new pooled connections.
89    pub fn username(mut self, username: impl Into<String>) -> Self {
90        self.client = self.client.username(username);
91        self
92    }
93
94    /// Set the authentication password used for new pooled connections.
95    pub fn password(mut self, password: impl Into<String>) -> Self {
96        self.client = self.client.password(password);
97        self
98    }
99
100    /// Acquire a connection from the pool
101    ///
102    /// Returns a healthy connection from the pool, or creates a new one if needed.
103    /// Stale connections (those where the underlying QUIC connection has been closed)
104    /// are automatically discarded during acquisition.
105    pub async fn acquire(&self) -> Result<PooledConnection> {
106        let permit = Arc::clone(&self.semaphore)
107            .acquire_owned()
108            .await
109            .map_err(|_| Error::pool("Connection pool has been closed"))?;
110
111        // Try to get a healthy existing connection
112        let connection = loop {
113            let conn = {
114                let mut connections = self.connections.lock().await;
115                connections.pop()
116            };
117
118            match conn {
119                Some(c) if c.is_healthy() => {
120                    // Connection is healthy, use it
121                    break c;
122                }
123                Some(_) => {
124                    // Connection is stale, discard it and try another
125                    // (the connection is dropped here, cleaning up resources)
126                    continue;
127                }
128                None => {
129                    // No pooled connections available, create a new one
130                    let client = self.client.clone();
131                    break client.connect().await?;
132                }
133            }
134        };
135
136        Ok(PooledConnection {
137            connection: Some(connection),
138            pool: self.connections.clone(),
139            _permit: permit,
140        })
141    }
142
143    /// Execute statements as a single batch in one transaction, with the
144    /// default retry policy. Acquires one connection from the pool.
145    ///
146    /// # Errors
147    /// Returns the first failing statement's error (the transaction is rolled
148    /// back). An empty slice is a no-op.
149    pub async fn batch_exec(&self, stmts: &[crate::schema::Statement]) -> Result<()> {
150        if stmts.is_empty() {
151            return Ok(());
152        }
153        let policy = crate::retry::RetryPolicy::default();
154        crate::retry::retry(policy, || async {
155            let mut conn = self.acquire().await?;
156            conn.begin().await?;
157            for (i, stmt) in stmts.iter().enumerate() {
158                if let Err(e) = conn.query(&stmt.query).await {
159                    let _ = conn.rollback().await;
160                    return Err(Error::query(format!("statement {}: {}", i + 1, e)));
161                }
162            }
163            conn.commit().await
164        })
165        .await
166    }
167
168    /// Execute statements concurrently in chunks, each chunk a single
169    /// transaction (via [`batch_exec`](Self::batch_exec) with retry). At most
170    /// `max_workers` chunks run at once. The first error stops dispatching new
171    /// chunks; in-flight chunks complete. `chunk_size`/`max_workers` of 0 use
172    /// the defaults (100 / 8).
173    ///
174    /// # Errors
175    /// Returns the first error recorded across all chunks.
176    pub async fn pipeline_exec(
177        self: &Arc<Self>,
178        stmts: &[crate::schema::Statement],
179        chunk_size: usize,
180        max_workers: usize,
181    ) -> Result<()> {
182        let pool = Arc::clone(self);
183        crate::batch::pipeline_drive(stmts, chunk_size, max_workers, move |chunk| {
184            let pool = Arc::clone(&pool);
185            async move { pool.batch_exec(&chunk).await }
186        })
187        .await
188    }
189
190    /// Get current pool size
191    pub async fn size(&self) -> usize {
192        self.connections.lock().await.len()
193    }
194
195    /// Get the maximum pool size
196    pub fn max_size(&self) -> usize {
197        self.max_size
198    }
199}
200
201/// A pooled connection that returns to the pool when dropped
202pub struct PooledConnection {
203    connection: Option<Connection>,
204    pool: Arc<Mutex<Vec<Connection>>>,
205    _permit: tokio::sync::OwnedSemaphorePermit,
206}
207
208impl PooledConnection {
209    /// Get a reference to the underlying connection.
210    ///
211    /// # Panics
212    ///
213    /// Panics if called after the connection has been dropped or taken.
214    /// This should never happen in normal usage as the connection is only
215    /// taken during Drop.
216    pub fn inner(&self) -> &Connection {
217        self.connection
218            .as_ref()
219            .expect("PooledConnection invariant violated: connection was None")
220    }
221}
222
223impl Drop for PooledConnection {
224    fn drop(&mut self) {
225        if let Some(mut conn) = self.connection.take() {
226            // Only return healthy connections to the pool
227            // Stale connections are dropped, cleaning up their resources
228            if conn.is_healthy() {
229                let pool = self.pool.clone();
230                tokio::spawn(async move {
231                    // Best-effort rollback if the connection was left in a transaction
232                    if conn.in_transaction() {
233                        let _ = conn.rollback().await;
234                    }
235                    let mut connections = pool.lock().await;
236                    connections.push(conn);
237                });
238            }
239            // If unhealthy, conn is dropped here and not returned to pool
240        }
241    }
242}
243
244impl std::ops::Deref for PooledConnection {
245    type Target = Connection;
246
247    fn deref(&self) -> &Self::Target {
248        self.connection
249            .as_ref()
250            .expect("PooledConnection invariant violated: connection was None")
251    }
252}
253
254impl std::ops::DerefMut for PooledConnection {
255    fn deref_mut(&mut self) -> &mut Self::Target {
256        self.connection
257            .as_mut()
258            .expect("PooledConnection invariant violated: connection was None")
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_connection_pool_new() {
268        let pool = ConnectionPool::new("localhost", 3141, 10);
269        assert_eq!(pool.max_size(), 10);
270    }
271
272    #[test]
273    fn test_connection_pool_new_different_host() {
274        let pool = ConnectionPool::new("192.168.1.100", 8443, 5);
275        assert_eq!(pool.max_size(), 5);
276    }
277
278    #[test]
279    fn test_connection_pool_new_string_host() {
280        let host = String::from("geode.example.com");
281        let pool = ConnectionPool::new(host, 3141, 20);
282        assert_eq!(pool.max_size(), 20);
283    }
284
285    #[test]
286    fn test_connection_pool_skip_verify() {
287        let pool = ConnectionPool::new("localhost", 3141, 10).skip_verify(true);
288        // Configuration is passed through to client
289        assert_eq!(pool.max_size(), 10);
290    }
291
292    #[test]
293    fn test_connection_pool_skip_verify_false() {
294        let pool = ConnectionPool::new("localhost", 3141, 10).skip_verify(false);
295        assert_eq!(pool.max_size(), 10);
296    }
297
298    #[test]
299    fn test_connection_pool_page_size() {
300        let pool = ConnectionPool::new("localhost", 3141, 10).page_size(500);
301        assert_eq!(pool.max_size(), 10);
302    }
303
304    #[test]
305    fn test_connection_pool_chained_config() {
306        let pool = ConnectionPool::new("localhost", 3141, 10)
307            .skip_verify(true)
308            .page_size(1000);
309        assert_eq!(pool.max_size(), 10);
310    }
311
312    #[tokio::test]
313    async fn test_connection_pool_initial_size() {
314        let pool = ConnectionPool::new("localhost", 3141, 10);
315        // Pool starts empty
316        assert_eq!(pool.size().await, 0);
317    }
318
319    #[test]
320    #[should_panic(expected = "ConnectionPool max_size must be at least 1")]
321    fn test_connection_pool_max_size_zero_panics() {
322        // Gap #18: max_size=0 would cause deadlock on acquire() since semaphore
323        // would have 0 permits. Now properly panics at construction time.
324        let _pool = ConnectionPool::new("localhost", 3141, 0);
325    }
326
327    #[test]
328    fn test_connection_pool_max_size_one() {
329        let pool = ConnectionPool::new("localhost", 3141, 1);
330        assert_eq!(pool.max_size(), 1);
331    }
332
333    #[test]
334    fn test_connection_pool_max_size_large() {
335        let pool = ConnectionPool::new("localhost", 3141, 1000);
336        assert_eq!(pool.max_size(), 1000);
337    }
338
339    // Note: Full integration tests for acquire() and health checking require
340    // a running Geode server and are covered in the integration test suite.
341    // The health check functionality (is_healthy(), stale connection discard)
342    // is verified in integration tests with real connections (Gap #16).
343
344    // The following tests verify the structural aspects of PooledConnection
345    // without actually establishing connections.
346
347    #[test]
348    fn test_semaphore_permits_match_max_size() {
349        let pool = ConnectionPool::new("localhost", 3141, 5);
350        // Semaphore should have permits equal to max_size
351        assert_eq!(pool.semaphore.available_permits(), 5);
352    }
353
354    #[test]
355    fn test_connections_vec_initially_empty() {
356        let pool = ConnectionPool::new("localhost", 3141, 10);
357        // We can't directly access the mutex contents in a sync test,
358        // but we verified size() returns 0 in the async test above
359        assert_eq!(pool.max_size(), 10);
360    }
361
362    #[test]
363    fn test_pool_from_dsn() {
364        let pool = ConnectionPool::from_dsn("quic://localhost:3141?insecure=true").unwrap();
365        assert_eq!(pool.max_size(), 16); // DEFAULT_POOL_MAX_SIZE
366    }
367
368    #[test]
369    fn test_pool_from_dsn_with_size() {
370        let pool = ConnectionPool::from_dsn_with_size("grpc://localhost:50051?tls=0", 4).unwrap();
371        assert_eq!(pool.max_size(), 4);
372    }
373
374    #[test]
375    fn test_pool_from_dsn_invalid() {
376        assert!(ConnectionPool::from_dsn("http://localhost:1").is_err());
377    }
378
379    #[test]
380    fn test_pool_from_client() {
381        let client = Client::new("localhost", 3141).skip_verify(true);
382        let pool = ConnectionPool::from_client(client, 8);
383        assert_eq!(pool.max_size(), 8);
384    }
385
386    #[tokio::test]
387    async fn test_pool_batch_exec_empty_is_noop() {
388        let pool = ConnectionPool::new("localhost", 3141, 4);
389        // An empty statement slice must not touch the network.
390        assert!(pool.batch_exec(&[]).await.is_ok());
391    }
392
393    #[tokio::test]
394    async fn test_pool_pipeline_exec_empty_is_noop() {
395        let pool = Arc::new(ConnectionPool::new("localhost", 3141, 4));
396        assert!(pool.pipeline_exec(&[], 0, 0).await.is_ok());
397    }
398}