1use crate::driver::QdrantDriver;
8use crate::error::{QdrantError, QdrantResult};
9use http::Uri;
10use std::sync::{Arc, Mutex, MutexGuard};
11use tokio::sync::Semaphore;
12
13#[derive(Debug, Clone)]
15pub struct PoolConfig {
16 pub max_connections: usize,
18 pub host: String,
20 pub port: u16,
22 pub tls: bool,
24}
25
26impl PoolConfig {
27 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 pub fn tls(mut self, enabled: bool) -> Self {
39 self.tls = enabled;
40 self
41 }
42
43 pub fn max_connections(mut self, max: usize) -> Self {
45 self.max_connections = max;
46 self
47 }
48
49 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 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 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
105struct PoolInner {
107 config: PoolConfig,
108 idle: Mutex<Vec<QdrantDriver>>,
110 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#[derive(Clone)]
141pub struct QdrantPool {
142 inner: Arc<PoolInner>,
143}
144
145impl QdrantPool {
146 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 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 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 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 permit.forget();
196
197 Ok(PooledConnection {
198 driver: Some(driver),
199 pool: Arc::clone(&self.inner),
200 })
201 }
202
203 pub async fn idle_count(&self) -> usize {
205 lock_idle_connections(&self.inner.idle).len()
206 }
207
208 pub fn available(&self) -> usize {
210 self.inner.semaphore.available_permits()
211 }
212
213 pub fn max_connections(&self) -> usize {
215 self.inner.config.max_connections
216 }
217}
218
219pub 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 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 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}