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