Skip to main content

qail_qdrant/
pool.rs

1//! Connection pool for Qdrant gRPC driver.
2//!
3//! Maintains a pool of idle connections and reuses them across requests.
4//! Limits concurrency via semaphore and returns connections to the idle
5//! queue on drop.
6
7use crate::driver::QdrantDriver;
8use crate::error::{QdrantError, QdrantResult};
9use http::Uri;
10use std::sync::{Arc, Mutex, MutexGuard};
11use tokio::sync::Semaphore;
12
13/// Configuration for the connection pool.
14#[derive(Debug, Clone)]
15pub struct PoolConfig {
16    /// Maximum number of concurrent connections.
17    pub max_connections: usize,
18    /// Host to connect to.
19    pub host: String,
20    /// gRPC port (default 6334).
21    pub port: u16,
22    /// Whether to use TLS (rustls).
23    pub tls: bool,
24}
25
26impl PoolConfig {
27    /// Create a new pool configuration.
28    pub fn new(host: impl Into<String>, port: u16) -> Self {
29        Self {
30            max_connections: 10,
31            host: host.into(),
32            port,
33            tls: false,
34        }
35    }
36
37    /// Enable TLS for connections.
38    pub fn tls(mut self, enabled: bool) -> Self {
39        self.tls = enabled;
40        self
41    }
42
43    /// Set maximum connections.
44    pub fn max_connections(mut self, max: usize) -> Self {
45        self.max_connections = max;
46        self
47    }
48
49    /// Create config from centralized `QailConfig`.
50    ///
51    /// Reads `[qdrant]` section; returns `None` if section is absent.
52    /// Uses gRPC endpoint (port 6334) by default.
53    pub fn from_qail_config(qail: &qail_core::config::QailConfig) -> Option<Self> {
54        let qdrant = qail.qdrant.as_ref()?;
55        Some(Self::from_qail_config_ref(qdrant))
56    }
57
58    /// Create config directly from a `&QdrantConfig` reference.
59    ///
60    /// Used by the gateway which already has the config extracted.
61    pub fn from_qail_config_ref(qdrant: &qail_core::config::QdrantConfig) -> Self {
62        let endpoint = qdrant.grpc.as_deref().unwrap_or(&qdrant.url);
63        let use_tls = qdrant.tls.unwrap_or_else(|| {
64            endpoint_tls_hint(endpoint).unwrap_or_else(|| qdrant.url.starts_with("https://"))
65        });
66
67        let (host, mut port) = parse_endpoint_host_port(endpoint, 6334);
68        if qdrant.grpc.is_none() && port == 6333 {
69            // `qdrant.url` is commonly an HTTP endpoint; when grpc is omitted,
70            // avoid accidentally dialing REST port 6333 with the gRPC client.
71            port = 6334;
72        }
73
74        Self {
75            max_connections: qdrant.max_connections,
76            host,
77            port,
78            tls: use_tls,
79        }
80    }
81}
82
83fn endpoint_tls_hint(endpoint: &str) -> Option<bool> {
84    let raw = endpoint.trim();
85    if raw.starts_with("https://") {
86        Some(true)
87    } else if raw.starts_with("http://") {
88        Some(false)
89    } else {
90        None
91    }
92}
93
94impl Default for PoolConfig {
95    fn default() -> Self {
96        Self {
97            max_connections: 10,
98            host: "localhost".to_string(),
99            port: 6334,
100            tls: false,
101        }
102    }
103}
104
105/// Shared pool internals behind an Arc for cheap cloning.
106struct PoolInner {
107    config: PoolConfig,
108    /// Idle connections ready for reuse.
109    idle: Mutex<Vec<QdrantDriver>>,
110    /// Semaphore limiting total simultaneous connections.
111    semaphore: Semaphore,
112}
113
114fn lock_idle_connections(idle: &Mutex<Vec<QdrantDriver>>) -> MutexGuard<'_, Vec<QdrantDriver>> {
115    match idle.lock() {
116        Ok(guard) => guard,
117        Err(poisoned) => poisoned.into_inner(),
118    }
119}
120
121/// Connection pool for Qdrant gRPC driver.
122///
123/// Maintains a pool of idle connections and reuses them instead of
124/// creating a new TCP+H2 handshake per request. Connections are
125/// returned to the idle queue when the `PooledConnection` is dropped.
126///
127/// # Example
128/// ```ignore
129/// use qail_qdrant::{QdrantPool, PoolConfig};
130///
131/// let pool = QdrantPool::new(
132///     PoolConfig::new("localhost", 6334).max_connections(20)
133/// ).await?;
134///
135/// // Get a connection from the pool (reuses idle connections)
136/// let mut conn = pool.get().await?;
137/// let results = conn.search("products", &embedding, 10, None).await?;
138/// // conn is returned to the pool when dropped
139/// ```
140#[derive(Clone)]
141pub struct QdrantPool {
142    inner: Arc<PoolInner>,
143}
144
145impl QdrantPool {
146    /// Create a new connection pool.
147    pub async fn new(config: PoolConfig) -> QdrantResult<Self> {
148        let max = config.max_connections;
149        if max == 0 {
150            return Err(QdrantError::Connection(
151                "Qdrant pool max_connections must be greater than zero".to_string(),
152            ));
153        }
154        Ok(Self {
155            inner: Arc::new(PoolInner {
156                config,
157                idle: Mutex::new(Vec::with_capacity(max)),
158                semaphore: Semaphore::new(max),
159            }),
160        })
161    }
162
163    /// Get a connection from the pool.
164    ///
165    /// Returns an idle connection if available, otherwise creates a new one.
166    /// The semaphore limits total connections to `max_connections`.
167    pub async fn get(&self) -> QdrantResult<PooledConnection> {
168        let permit = self
169            .inner
170            .semaphore
171            .acquire()
172            .await
173            .map_err(|e| QdrantError::Connection(format!("Semaphore closed: {}", e)))?;
174
175        // Try to take an idle connection
176        let driver = {
177            let mut idle = lock_idle_connections(&self.inner.idle);
178            idle.pop()
179        };
180
181        let driver = match driver {
182            Some(d) => d,
183            None => {
184                // No idle connection — create a new one
185                if self.inner.config.tls {
186                    QdrantDriver::connect_tls(&self.inner.config.host, self.inner.config.port)
187                        .await?
188                } else {
189                    QdrantDriver::connect(&self.inner.config.host, self.inner.config.port).await?
190                }
191            }
192        };
193
194        // Forget the permit — we'll manually add it back in PooledConnection::drop
195        permit.forget();
196
197        Ok(PooledConnection {
198            driver: Some(driver),
199            pool: Arc::clone(&self.inner),
200        })
201    }
202
203    /// Number of idle connections waiting for reuse.
204    pub async fn idle_count(&self) -> usize {
205        lock_idle_connections(&self.inner.idle).len()
206    }
207
208    /// Number of available permits (connections not in use).
209    pub fn available(&self) -> usize {
210        self.inner.semaphore.available_permits()
211    }
212
213    /// Maximum number of connections.
214    pub fn max_connections(&self) -> usize {
215        self.inner.config.max_connections
216    }
217}
218
219/// A pooled connection that returns itself to the idle queue on drop.
220pub struct PooledConnection {
221    driver: Option<QdrantDriver>,
222    pool: Arc<PoolInner>,
223}
224
225impl std::ops::Deref for PooledConnection {
226    type Target = QdrantDriver;
227
228    fn deref(&self) -> &Self::Target {
229        match self.driver.as_ref() {
230            Some(driver) => driver,
231            None => unreachable!("driver taken after drop"),
232        }
233    }
234}
235
236impl std::ops::DerefMut for PooledConnection {
237    fn deref_mut(&mut self) -> &mut Self::Target {
238        match self.driver.as_mut() {
239            Some(driver) => driver,
240            None => unreachable!("driver taken after drop"),
241        }
242    }
243}
244
245impl Drop for PooledConnection {
246    fn drop(&mut self) {
247        if let Some(driver) = self.driver.take() {
248            let mut idle = lock_idle_connections(&self.pool.idle);
249            if idle.len() < self.pool.config.max_connections {
250                idle.push(driver);
251            }
252            drop(idle);
253            // Always release a permit, even if we couldn't return to idle.
254            self.pool.semaphore.add_permits(1);
255        }
256    }
257}
258
259fn parse_endpoint_host_port(input: &str, default_port: u16) -> (String, u16) {
260    let raw = input.trim();
261    if raw.is_empty() {
262        return ("localhost".to_string(), default_port);
263    }
264
265    if raw.contains("://")
266        && let Ok(uri) = raw.parse::<Uri>()
267    {
268        if let Some(host) = uri.host().filter(|host| !host.is_empty()) {
269            let port = uri.port_u16().unwrap_or(default_port);
270            return (host.to_string(), port);
271        }
272        return (raw.to_string(), default_port);
273    }
274
275    if raw.starts_with('[')
276        && let Some(end) = raw.find(']')
277    {
278        let host = &raw[1..end];
279        if host.is_empty() {
280            return (raw.to_string(), default_port);
281        }
282        let suffix = &raw[end + 1..];
283        let port = if suffix.is_empty() {
284            default_port
285        } else if let Some(port_str) = suffix.strip_prefix(':') {
286            match port_str.parse::<u16>() {
287                Ok(port) => port,
288                Err(_) => return (raw.to_string(), default_port),
289            }
290        } else {
291            return (raw.to_string(), default_port);
292        };
293        return (host.to_string(), port);
294    }
295
296    if let Some((host, port_str)) = raw.rsplit_once(':')
297        && !host.is_empty()
298        && let Ok(port) = port_str.parse::<u16>()
299    {
300        return (host.to_string(), port);
301    }
302
303    (raw.to_string(), default_port)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_pool_config_builder() {
312        let config = PoolConfig::new("localhost", 6334).max_connections(20);
313
314        assert_eq!(config.max_connections, 20);
315        assert_eq!(config.host, "localhost");
316        assert_eq!(config.port, 6334);
317        assert!(!config.tls);
318
319        // With TLS
320        let tls_config = PoolConfig::new("cloud.qdrant.io", 6334).tls(true);
321        assert!(tls_config.tls);
322    }
323
324    #[test]
325    fn test_pool_config_default() {
326        let config = PoolConfig::default();
327        assert_eq!(config.max_connections, 10);
328        assert_eq!(config.host, "localhost");
329        assert_eq!(config.port, 6334);
330    }
331
332    #[tokio::test]
333    async fn pool_rejects_zero_max_connections_instead_of_deadlocking() {
334        let config = PoolConfig::new("localhost", 6334).max_connections(0);
335
336        let err = match QdrantPool::new(config).await {
337            Ok(_) => panic!("zero max_connections must fail during construction"),
338            Err(err) => err,
339        };
340
341        assert!(
342            matches!(err, QdrantError::Connection(message) if message.contains("max_connections"))
343        );
344    }
345
346    #[test]
347    fn test_parse_endpoint_host_port() {
348        assert_eq!(
349            parse_endpoint_host_port("localhost:6334", 6334),
350            ("localhost".to_string(), 6334)
351        );
352        assert_eq!(
353            parse_endpoint_host_port("https://cloud.qdrant.io:443", 6334),
354            ("cloud.qdrant.io".to_string(), 443)
355        );
356        assert_eq!(
357            parse_endpoint_host_port("[::1]:6334", 6334),
358            ("::1".to_string(), 6334)
359        );
360        assert_eq!(
361            parse_endpoint_host_port("qdrant.internal", 6334),
362            ("qdrant.internal".to_string(), 6334)
363        );
364    }
365
366    #[test]
367    fn test_parse_endpoint_host_port_does_not_default_malformed_url_to_localhost() {
368        assert_eq!(
369            parse_endpoint_host_port("http://:6334", 6334),
370            ("http://:6334".to_string(), 6334)
371        );
372        assert_eq!(
373            parse_endpoint_host_port("[]:6334", 6334),
374            ("[]:6334".to_string(), 6334)
375        );
376        assert_eq!(
377            parse_endpoint_host_port("[::1]:bad", 6334),
378            ("[::1]:bad".to_string(), 6334)
379        );
380    }
381
382    #[test]
383    fn test_from_qail_config_ref_defaults_grpc_port_when_url_uses_rest_port() {
384        let cfg = qail_core::config::QdrantConfig {
385            url: "http://localhost:6333".to_string(),
386            grpc: None,
387            max_connections: 7,
388            tls: None,
389        };
390        let pool = PoolConfig::from_qail_config_ref(&cfg);
391        assert_eq!(pool.host, "localhost");
392        assert_eq!(pool.port, 6334);
393        assert_eq!(pool.max_connections, 7);
394        assert!(!pool.tls);
395    }
396
397    #[test]
398    fn test_from_qail_config_ref_autodetects_tls_from_explicit_grpc_endpoint() {
399        let cfg = qail_core::config::QdrantConfig {
400            url: "https://cloud.qdrant.io:443".to_string(),
401            grpc: Some("http://localhost:6334".to_string()),
402            max_connections: 7,
403            tls: None,
404        };
405        let pool = PoolConfig::from_qail_config_ref(&cfg);
406        assert_eq!(pool.host, "localhost");
407        assert_eq!(pool.port, 6334);
408        assert!(!pool.tls, "explicit grpc http endpoint should stay plain");
409
410        let cfg = qail_core::config::QdrantConfig {
411            url: "http://localhost:6333".to_string(),
412            grpc: Some("https://cloud.qdrant.io:443".to_string()),
413            max_connections: 7,
414            tls: None,
415        };
416        let pool = PoolConfig::from_qail_config_ref(&cfg);
417        assert_eq!(pool.host, "cloud.qdrant.io");
418        assert_eq!(pool.port, 443);
419        assert!(pool.tls, "explicit grpc https endpoint should enable TLS");
420
421        let cfg = qail_core::config::QdrantConfig {
422            url: "https://cloud.qdrant.io:443".to_string(),
423            grpc: Some("cloud.qdrant.io:6334".to_string()),
424            max_connections: 7,
425            tls: None,
426        };
427        let pool = PoolConfig::from_qail_config_ref(&cfg);
428        assert!(pool.tls, "schemeless grpc endpoint inherits https URL TLS");
429    }
430}