1use crate::client::{LLMClient, Provider};
38use crate::governor::{GovernorConfig, ProviderGovernor};
39use ares_types::types::{AppError, Result};
40#[cfg(test)]
41use async_trait::async_trait;
42use parking_lot::{Mutex, RwLock};
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::sync::Arc;
46use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
47use std::time::{Duration, Instant};
48use tokio::sync::{OwnedSemaphorePermit, Semaphore};
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub enum PoolError {
53 #[serde(rename = "pool_exhausted")]
54 PoolExhausted { max: usize },
55 #[serde(rename = "timeout")]
56 Timeout { timeout_ms: u64 },
57 #[serde(rename = "invalid_client")]
58 InvalidClient { reason: String },
59}
60
61impl std::fmt::Display for PoolError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 Self::PoolExhausted { max } => write!(f, "pool exhausted (max={max})"),
65 Self::Timeout { timeout_ms } => write!(f, "pool acquire timeout after {timeout_ms}ms"),
66 Self::InvalidClient { reason } => write!(f, "invalid pooled client: {reason}"),
67 }
68 }
69}
70
71impl std::error::Error for PoolError {}
72
73impl From<PoolError> for AppError {
74 fn from(err: PoolError) -> Self {
75 AppError::LLM(err.to_string())
76 }
77}
78
79pub(crate) mod duration_secs {
80 use serde::{Deserialize, Deserializer, Serializer};
81 use std::time::Duration;
82
83 pub fn serialize<S>(value: &Duration, serializer: S) -> std::result::Result<S::Ok, S::Error>
84 where
85 S: Serializer,
86 {
87 serializer.serialize_u64(value.as_secs())
88 }
89
90 pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
91 where
92 D: Deserializer<'de>,
93 {
94 Ok(Duration::from_secs(u64::deserialize(deserializer)?))
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct PoolConfig {
101 #[serde(default)]
108 pub max_in_flight: Option<usize>,
109
110 #[serde(default = "default_acquire_timeout_secs", with = "duration_secs")]
113 pub governor_acquire_timeout: Duration,
114
115 pub max_connections_per_provider: usize,
117
118 pub min_idle_connections: usize,
120
121 #[serde(with = "duration_secs")]
123 pub idle_timeout: Duration,
124
125 #[serde(with = "duration_secs")]
127 pub max_lifetime: Duration,
128
129 #[serde(with = "duration_secs")]
131 pub health_check_interval: Duration,
132
133 #[serde(with = "duration_secs")]
135 pub acquire_timeout: Duration,
136
137 pub enable_health_check: bool,
139}
140
141fn default_acquire_timeout_secs() -> Duration {
142 Duration::from_secs(30)
143}
144
145impl Default for PoolConfig {
146 fn default() -> Self {
147 Self {
148 max_in_flight: None,
149 governor_acquire_timeout: default_acquire_timeout_secs(),
150 max_connections_per_provider: 10,
151 min_idle_connections: 2,
152 idle_timeout: Duration::from_secs(300), max_lifetime: Duration::from_secs(1800), health_check_interval: Duration::from_secs(60),
155 acquire_timeout: Duration::from_secs(30),
156 enable_health_check: true,
157 }
158 }
159}
160
161impl PoolConfig {
162 pub fn with_max_connections(mut self, max: usize) -> Self {
164 self.max_connections_per_provider = max;
165 self
166 }
167
168 pub fn with_max_in_flight(mut self, max: usize) -> Self {
175 self.max_in_flight = Some(max);
176 self
177 }
178
179 pub fn with_governor_acquire_timeout(mut self, timeout: Duration) -> Self {
181 self.governor_acquire_timeout = timeout;
182 self
183 }
184
185 pub fn governor_config(&self) -> GovernorConfig {
187 GovernorConfig {
188 max_in_flight: self.max_in_flight,
189 acquire_timeout: self.governor_acquire_timeout,
190 }
191 }
192
193 pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
195 self.idle_timeout = timeout;
196 self
197 }
198
199 pub fn with_max_lifetime(mut self, lifetime: Duration) -> Self {
201 self.max_lifetime = lifetime;
202 self
203 }
204
205 pub fn without_health_check(mut self) -> Self {
207 self.enable_health_check = false;
208 self
209 }
210}
211
212impl std::fmt::Display for PoolConfig {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 write!(
215 f,
216 "PoolConfig(max={}, min_idle={}, health_check={})",
217 self.max_connections_per_provider, self.min_idle_connections, self.enable_health_check
218 )
219 }
220}
221
222#[derive(Debug)]
224struct PooledClientMeta {
225 created_at: Instant,
227 last_used: Instant,
229 #[allow(dead_code)] use_count: AtomicU64,
232}
233
234impl PooledClientMeta {
235 fn new() -> Self {
236 let now = Instant::now();
237 Self {
238 created_at: now,
239 last_used: now,
240 use_count: AtomicU64::new(0),
241 }
242 }
243
244 fn mark_used(&mut self) {
245 self.last_used = Instant::now();
246 self.use_count.fetch_add(1, Ordering::Relaxed);
247 }
248
249 fn is_stale(&self, config: &PoolConfig) -> bool {
250 let now = Instant::now();
251 let idle_duration = now.duration_since(self.last_used);
252 let lifetime = now.duration_since(self.created_at);
253
254 idle_duration > config.idle_timeout || lifetime > config.max_lifetime
255 }
256}
257
258#[derive(Debug)]
259enum BorrowFromIdle {
260 Found(PooledClient),
261 Exhausted,
262}
263
264#[derive(Debug, PartialEq, Eq, Clone, Copy)]
265pub enum ReturnDisposition {
266 Returned,
267 Dropped,
268}
269
270fn borrow_client(idle: &mut Vec<PooledClient>, config: &PoolConfig) -> BorrowFromIdle {
271 let mut found_idx = None;
272 for (idx, pooled) in idle.iter().enumerate() {
273 if health_check(&pooled.meta, config) {
274 found_idx = Some(idx);
275 break;
276 }
277 }
278 if let Some(idx) = found_idx {
279 return BorrowFromIdle::Found(idle.swap_remove(idx));
280 }
281 idle.retain(|c| health_check(&c.meta, config));
282 BorrowFromIdle::Exhausted
283}
284
285fn return_client(
286 idle: &mut Vec<PooledClient>,
287 client: Box<dyn LLMClient>,
288 config: &PoolConfig,
289) -> ReturnDisposition {
290 if idle.len() < config.max_connections_per_provider {
291 idle.push(PooledClient {
292 client,
293 meta: PooledClientMeta::new(),
294 });
295 ReturnDisposition::Returned
296 } else {
297 ReturnDisposition::Dropped
298 }
299}
300
301fn health_check(meta: &PooledClientMeta, config: &PoolConfig) -> bool {
302 if !config.enable_health_check {
303 return true;
304 }
305 !meta.is_stale(config)
306}
307
308fn pool_stats(
309 available: usize,
310 in_use: usize,
311 total_created: u64,
312 max_size: usize,
313 borrow_count: u64,
314 error_count: u64,
315) -> ProviderPoolStats {
316 ProviderPoolStats {
317 available,
318 in_use,
319 total: available.saturating_add(in_use),
320 total_created,
321 max_size,
322 borrow_count,
323 error_count,
324 }
325}
326
327fn validate_pooled_client(client: &dyn LLMClient) -> std::result::Result<(), PoolError> {
328 if client.model_name().trim().is_empty() {
329 return Err(PoolError::InvalidClient {
330 reason: "empty model name".to_string(),
331 });
332 }
333 Ok(())
334}
335
336struct PooledClient {
338 client: Box<dyn LLMClient>,
339 meta: PooledClientMeta,
340}
341
342impl std::fmt::Debug for PooledClient {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 f.debug_struct("PooledClient")
345 .field("meta", &self.meta)
346 .finish()
347 }
348}
349
350#[derive(Debug)]
352struct ProviderPool {
353 provider: Provider,
355 governor: Arc<ProviderGovernor>,
357 clients: Mutex<Vec<PooledClient>>,
359 semaphore: Arc<Semaphore>,
361 in_use_count: AtomicUsize,
363 total_created: AtomicU64,
365 borrow_count: AtomicU64,
366 error_count: AtomicU64,
367 config: PoolConfig,
369}
370
371impl ProviderPool {
372 fn new(provider: Provider, config: PoolConfig) -> Self {
373 let semaphore = Arc::new(Semaphore::new(config.max_connections_per_provider));
374 Self {
375 provider,
376 governor: Arc::new(ProviderGovernor::new(config.governor_config())),
377 clients: Mutex::new(Vec::with_capacity(config.max_connections_per_provider)),
378 semaphore,
379 in_use_count: AtomicUsize::new(0),
380 total_created: AtomicU64::new(0),
381 borrow_count: AtomicU64::new(0),
382 error_count: AtomicU64::new(0),
383 config,
384 }
385 }
386
387 async fn acquire(
389 &self,
390 ) -> std::result::Result<(Box<dyn LLMClient>, OwnedSemaphorePermit), PoolError> {
391 let permit = match tokio::time::timeout(
392 self.config.acquire_timeout,
393 self.semaphore.clone().acquire_owned(),
394 )
395 .await
396 {
397 Ok(Ok(permit)) => permit,
398 Ok(Err(_)) => {
399 self.error_count.fetch_add(1, Ordering::Relaxed);
400 return Err(PoolError::PoolExhausted {
401 max: self.config.max_connections_per_provider,
402 });
403 }
404 Err(_) => {
405 self.error_count.fetch_add(1, Ordering::Relaxed);
406 return Err(PoolError::Timeout {
407 timeout_ms: self.config.acquire_timeout.as_millis() as u64,
408 });
409 }
410 };
411
412 self.borrow_count.fetch_add(1, Ordering::Relaxed);
413
414 let client = {
415 let borrowed = {
416 let mut clients = self.clients.lock();
417 match borrow_client(&mut clients, &self.config) {
418 BorrowFromIdle::Found(mut pooled) => {
419 validate_pooled_client(pooled.client.as_ref())?;
420 pooled.meta.mark_used();
421 Ok(pooled.client)
422 }
423 BorrowFromIdle::Exhausted => Err(()),
424 }
425 };
426 match borrowed {
427 Ok(client) => client,
428 Err(()) => {
429 self.total_created.fetch_add(1, Ordering::Relaxed);
430 let created = self.provider.create_client().await.map_err(|e| {
431 self.error_count.fetch_add(1, Ordering::Relaxed);
432 PoolError::InvalidClient {
433 reason: e.to_string(),
434 }
435 })?;
436 validate_pooled_client(created.as_ref())?;
437 created
438 }
439 }
440 };
441
442 self.in_use_count.fetch_add(1, Ordering::Relaxed);
443 let client = self.governor.wrap_if_limited(client);
448 Ok((client, permit))
449 }
450
451 async fn try_acquire(
452 &self,
453 ) -> std::result::Result<(Box<dyn LLMClient>, OwnedSemaphorePermit), PoolError> {
454 let permit = self.semaphore.clone().try_acquire_owned().map_err(|_| {
455 self.error_count.fetch_add(1, Ordering::Relaxed);
456 PoolError::PoolExhausted {
457 max: self.config.max_connections_per_provider,
458 }
459 })?;
460
461 self.borrow_count.fetch_add(1, Ordering::Relaxed);
462
463 let client = {
464 let borrowed = {
465 let mut clients = self.clients.lock();
466 match borrow_client(&mut clients, &self.config) {
467 BorrowFromIdle::Found(mut pooled) => {
468 validate_pooled_client(pooled.client.as_ref())?;
469 pooled.meta.mark_used();
470 Ok(pooled.client)
471 }
472 BorrowFromIdle::Exhausted => Err(()),
473 }
474 };
475 match borrowed {
476 Ok(client) => client,
477 Err(()) => {
478 self.total_created.fetch_add(1, Ordering::Relaxed);
479 let created = self.provider.create_client().await.map_err(|e| {
480 self.error_count.fetch_add(1, Ordering::Relaxed);
481 PoolError::InvalidClient {
482 reason: e.to_string(),
483 }
484 })?;
485 validate_pooled_client(created.as_ref())?;
486 created
487 }
488 }
489 };
490
491 self.in_use_count.fetch_add(1, Ordering::Relaxed);
492 let client = self.governor.wrap_if_limited(client);
495 Ok((client, permit))
496 }
497
498 fn release(&self, client: Box<dyn LLMClient>) {
500 self.in_use_count.fetch_sub(1, Ordering::Relaxed);
501 let mut clients = self.clients.lock();
502 let _ = return_client(&mut clients, client, &self.config);
503 }
504
505 fn cleanup_stale(&self) -> usize {
507 let mut clients = self.clients.lock();
508 let before = clients.len();
509 clients.retain(|c| !c.meta.is_stale(&self.config));
510 before - clients.len()
511 }
512
513 fn stats(&self) -> ProviderPoolStats {
515 let clients = self.clients.lock();
516 pool_stats(
517 clients.len(),
518 self.in_use_count.load(Ordering::Relaxed),
519 self.total_created.load(Ordering::Relaxed),
520 self.config.max_connections_per_provider,
521 self.borrow_count.load(Ordering::Relaxed),
522 self.error_count.load(Ordering::Relaxed),
523 )
524 }
525
526 fn drain(&self) {
528 let mut clients = self.clients.lock();
529 clients.clear();
530 }
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
535pub struct ProviderPoolStats {
536 pub available: usize,
537 pub in_use: usize,
538 pub total: usize,
539 pub total_created: u64,
540 pub max_size: usize,
541 pub borrow_count: u64,
542 pub error_count: u64,
543}
544
545impl std::fmt::Display for ProviderPoolStats {
546 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 write!(
548 f,
549 "idle={} active={} total={} created={} max={} borrows={} errors={}",
550 self.available,
551 self.in_use,
552 self.total,
553 self.total_created,
554 self.max_size,
555 self.borrow_count,
556 self.error_count
557 )
558 }
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563pub struct PoolStats {
564 pub providers: HashMap<String, ProviderPoolStats>,
565 pub total_available: usize,
566 pub total_in_use: usize,
567 pub total_connections: usize,
568 pub borrow_count: u64,
569 pub error_count: u64,
570}
571
572impl std::fmt::Display for PoolStats {
573 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574 write!(
575 f,
576 "providers={} idle={} active={} total={} borrows={} errors={}",
577 self.providers.len(),
578 self.total_available,
579 self.total_in_use,
580 self.total_connections,
581 self.borrow_count,
582 self.error_count
583 )
584 }
585}
586
587pub struct PooledClientGuard {
589 client: Option<Box<dyn LLMClient>>,
590 pool: Arc<ProviderPool>,
591 _permit: OwnedSemaphorePermit,
592}
593
594impl std::fmt::Debug for PooledClientGuard {
595 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
596 f.debug_struct("PooledClientGuard")
597 .field("has_client", &self.client.is_some())
598 .field("pool", &self.pool)
599 .finish()
600 }
601}
602
603impl PooledClientGuard {
604 pub fn client(&self) -> &dyn LLMClient {
606 self.client.as_ref().expect("Client already taken").as_ref()
607 }
608
609 pub fn client_mut(&mut self) -> &mut dyn LLMClient {
611 self.client.as_mut().expect("Client already taken").as_mut()
612 }
613
614 pub fn take(mut self) -> Box<dyn LLMClient> {
619 self.client.take().expect("Client already taken")
620 }
621}
622
623impl Drop for PooledClientGuard {
624 fn drop(&mut self) {
625 if let Some(client) = self.client.take() {
626 self.pool.release(client);
627 }
628 }
629}
630
631impl std::ops::Deref for PooledClientGuard {
632 type Target = Box<dyn LLMClient>;
633
634 fn deref(&self) -> &Self::Target {
635 self.client.as_ref().expect("Client already taken")
636 }
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
640pub struct LLMPoolSnapshot {
641 pub config: PoolConfig,
642 pub stats: PoolStats,
643 pub providers: Vec<String>,
644 pub shutdown: bool,
645}
646
647pub type LLMPool = ClientPool;
648
649pub struct ClientPool {
654 config: PoolConfig,
655 providers: RwLock<HashMap<String, Arc<ProviderPool>>>,
656 shutdown: std::sync::atomic::AtomicBool,
657}
658
659impl ClientPool {
660 pub fn new(config: PoolConfig) -> Self {
662 Self {
663 config,
664 providers: RwLock::new(HashMap::new()),
665 shutdown: std::sync::atomic::AtomicBool::new(false),
666 }
667 }
668
669 pub fn with_defaults() -> Self {
671 Self::new(PoolConfig::default())
672 }
673
674 #[allow(unreachable_code, unused_variables)]
679 pub fn register_provider(&self, name: &str, provider: Provider) {
680 let pool = Arc::new(ProviderPool::new(provider, self.config.clone()));
681 let mut providers = self.providers.write();
682 providers.insert(name.to_string(), pool);
683 }
684
685 pub fn has_provider(&self, name: &str) -> bool {
687 self.providers.read().contains_key(name)
688 }
689
690 pub fn provider_names(&self) -> Vec<String> {
692 self.providers.read().keys().cloned().collect()
693 }
694
695 pub async fn get(&self, provider_name: &str) -> Result<PooledClientGuard> {
700 self.get_with_error(provider_name).await.map_err(Into::into)
701 }
702
703 pub async fn get_with_error(
704 &self,
705 provider_name: &str,
706 ) -> std::result::Result<PooledClientGuard, PoolError> {
707 if self.shutdown.load(Ordering::Relaxed) {
708 return Err(PoolError::InvalidClient {
709 reason: "pool is shutting down".to_string(),
710 });
711 }
712
713 let pool = {
714 let providers = self.providers.read();
715 providers
716 .get(provider_name)
717 .cloned()
718 .ok_or_else(|| PoolError::InvalidClient {
719 reason: format!("provider '{provider_name}' not registered in pool"),
720 })?
721 };
722
723 let (client, permit) = pool.acquire().await?;
724
725 Ok(PooledClientGuard {
726 client: Some(client),
727 pool,
728 _permit: permit,
729 })
730 }
731
732 pub async fn try_get(
733 &self,
734 provider_name: &str,
735 ) -> std::result::Result<PooledClientGuard, PoolError> {
736 if self.shutdown.load(Ordering::Relaxed) {
737 return Err(PoolError::InvalidClient {
738 reason: "pool is shutting down".to_string(),
739 });
740 }
741
742 let pool = {
743 let providers = self.providers.read();
744 providers
745 .get(provider_name)
746 .cloned()
747 .ok_or_else(|| PoolError::InvalidClient {
748 reason: format!("provider '{provider_name}' not registered in pool"),
749 })?
750 };
751
752 let (client, permit) = pool.try_acquire().await?;
753
754 Ok(PooledClientGuard {
755 client: Some(client),
756 pool,
757 _permit: permit,
758 })
759 }
760
761 pub fn stats(&self) -> PoolStats {
763 let providers = self.providers.read();
764 let mut stats = PoolStats {
765 providers: HashMap::new(),
766 total_available: 0,
767 total_in_use: 0,
768 total_connections: 0,
769 borrow_count: 0,
770 error_count: 0,
771 };
772
773 for (name, pool) in providers.iter() {
774 let provider_stats = pool.stats();
775 stats.total_available += provider_stats.available;
776 stats.total_in_use += provider_stats.in_use;
777 stats.total_connections += provider_stats.total;
778 stats.borrow_count += provider_stats.borrow_count;
779 stats.error_count += provider_stats.error_count;
780 stats.providers.insert(name.clone(), provider_stats);
781 }
782
783 stats
784 }
785
786 pub fn cleanup_stale(&self) -> usize {
790 let providers = self.providers.read();
791 providers.values().map(|p| p.cleanup_stale()).sum()
792 }
793
794 pub fn start_cleanup_task(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
798 let pool = Arc::clone(self);
799 let interval = pool.config.health_check_interval;
800
801 tokio::spawn(async move {
802 let mut interval_timer = tokio::time::interval(interval);
803 loop {
804 interval_timer.tick().await;
805
806 if pool.shutdown.load(Ordering::Relaxed) {
807 break;
808 }
809
810 let removed = pool.cleanup_stale();
811 if removed > 0 {
812 tracing::debug!("Pool cleanup: removed {} stale connections", removed);
813 }
814 }
815 })
816 }
817
818 pub fn shutdown(&self) {
823 self.shutdown.store(true, Ordering::Relaxed);
824
825 let providers = self.providers.read();
826 for pool in providers.values() {
827 pool.drain();
828 }
829 }
830
831 pub fn is_shutdown(&self) -> bool {
833 self.shutdown.load(Ordering::Relaxed)
834 }
835
836 pub fn snapshot(&self) -> LLMPoolSnapshot {
837 LLMPoolSnapshot {
838 config: self.config.clone(),
839 stats: self.stats(),
840 providers: self.provider_names(),
841 shutdown: self.is_shutdown(),
842 }
843 }
844}
845
846impl std::fmt::Display for ClientPool {
847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848 let snap = self.snapshot();
849 write!(f, "LLMPool(shutdown={}, {})", snap.shutdown, snap.stats)
850 }
851}
852
853impl Default for ClientPool {
854 fn default() -> Self {
855 Self::with_defaults()
856 }
857}
858
859pub struct ClientPoolBuilder {
861 config: PoolConfig,
862 providers: Vec<(String, Provider)>,
863}
864
865impl ClientPoolBuilder {
866 pub fn new() -> Self {
868 Self {
869 config: PoolConfig::default(),
870 providers: Vec::new(),
871 }
872 }
873
874 pub fn config(mut self, config: PoolConfig) -> Self {
876 self.config = config;
877 self
878 }
879
880 pub fn provider(mut self, name: impl Into<String>, provider: Provider) -> Self {
882 self.providers.push((name.into(), provider));
883 self
884 }
885
886 pub fn build(self) -> ClientPool {
888 let pool = ClientPool::new(self.config);
889 for (name, provider) in self.providers {
890 pool.register_provider(&name, provider);
891 }
892 pool
893 }
894
895 pub fn build_arc(self) -> Arc<ClientPool> {
897 Arc::new(self.build())
898 }
899}
900
901impl Default for ClientPoolBuilder {
902 fn default() -> Self {
903 Self::new()
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use crate::client::test_support::MockLLMClient;
911 use std::sync::atomic::Ordering as AtomicOrdering;
912 fn mock_client(model: &str) -> Box<dyn LLMClient> {
913 Box::new(MockLLMClient::new(model))
914 }
915
916 fn test_stub_provider() -> Provider {
917 Provider::TestStub {
918 model: "mock".to_string(),
919 }
920 }
921
922 fn provider_pool(config: PoolConfig) -> Arc<ProviderPool> {
923 Arc::new(ProviderPool::new(test_stub_provider(), config))
924 }
925
926 fn seed_pool(pool: &ProviderPool, clients: Vec<Box<dyn LLMClient>>) {
927 let mut guard = pool.clients.lock();
928 for client in clients {
929 guard.push(PooledClient {
930 client,
931 meta: PooledClientMeta::new(),
932 });
933 }
934 }
935
936 fn register_seeded_pool(
937 client_pool: &ClientPool,
938 name: &str,
939 config: PoolConfig,
940 clients: Vec<Box<dyn LLMClient>>,
941 ) {
942 let sub = provider_pool(config);
943 seed_pool(&sub, clients);
944 client_pool.providers.write().insert(name.to_string(), sub);
945 }
946
947 #[test]
948 fn test_pool_config_defaults() {
949 let config = PoolConfig::default();
950 assert_eq!(config.max_connections_per_provider, 10);
951 assert_eq!(config.min_idle_connections, 2);
952 assert_eq!(config.idle_timeout, Duration::from_secs(300));
953 assert_eq!(config.max_lifetime, Duration::from_secs(1800));
954 assert_eq!(config.health_check_interval, Duration::from_secs(60));
955 assert_eq!(config.acquire_timeout, Duration::from_secs(30));
956 assert!(config.enable_health_check);
957 }
958
959 #[test]
960 fn test_pool_config_builder() {
961 let config = PoolConfig::default()
962 .with_max_connections(20)
963 .with_idle_timeout(Duration::from_secs(60))
964 .without_health_check();
965
966 assert_eq!(config.max_connections_per_provider, 20);
967 assert_eq!(config.idle_timeout, Duration::from_secs(60));
968 assert!(!config.enable_health_check);
969 }
970
971 #[test]
972 fn test_pool_config_with_max_lifetime() {
973 let config = PoolConfig::default().with_max_lifetime(Duration::from_secs(42));
974 assert_eq!(config.max_lifetime, Duration::from_secs(42));
975 }
976
977 #[test]
978 fn test_pool_config_clone_preserves_fields() {
979 let config = PoolConfig::default()
980 .with_max_connections(7)
981 .with_idle_timeout(Duration::from_secs(11))
982 .with_max_lifetime(Duration::from_secs(22))
983 .without_health_check();
984 let cloned = config.clone();
985 assert_eq!(cloned.max_connections_per_provider, 7);
986 assert_eq!(cloned.idle_timeout, Duration::from_secs(11));
987 assert_eq!(cloned.max_lifetime, Duration::from_secs(22));
988 assert!(!cloned.enable_health_check);
989 }
990
991 #[test]
992 fn test_pool_config_debug_format() {
993 let debug = format!("{:?}", PoolConfig::default().with_max_connections(3));
994 assert!(debug.contains("max_connections_per_provider"));
995 assert!(debug.contains('3'));
996 }
997
998 #[test]
999 fn test_pool_config_max_lifetime_stale() {
1000 let config = PoolConfig::default()
1001 .with_idle_timeout(Duration::from_millis(1))
1002 .with_max_lifetime(Duration::from_millis(5));
1003 let meta = PooledClientMeta::new();
1004 std::thread::sleep(Duration::from_millis(6));
1005 assert!(meta.is_stale(&config));
1006 }
1007
1008 #[test]
1009 fn test_pooled_client_meta_stale_detection() {
1010 let config = PoolConfig::default()
1011 .with_idle_timeout(Duration::from_millis(10))
1012 .with_max_lifetime(Duration::from_millis(50));
1013
1014 let meta = PooledClientMeta::new();
1015 assert!(!meta.is_stale(&config));
1016
1017 std::thread::sleep(Duration::from_millis(15));
1018 assert!(meta.is_stale(&config));
1019 }
1020
1021 #[test]
1022 fn test_pooled_client_meta_mark_used_increments() {
1023 let mut meta = PooledClientMeta::new();
1024 meta.mark_used();
1025 meta.mark_used();
1026 assert_eq!(meta.use_count.load(AtomicOrdering::Relaxed), 2);
1027 }
1028
1029 #[test]
1030 fn test_pooled_client_meta_mark_used_refreshes_idle_timer() {
1031 let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(30));
1032 let mut meta = PooledClientMeta::new();
1033 std::thread::sleep(Duration::from_millis(20));
1034 meta.mark_used();
1035 assert!(!meta.is_stale(&config));
1036 }
1037
1038 #[test]
1039 fn test_pooled_client_debug_output() {
1040 let pooled = PooledClient {
1041 client: mock_client("debug-model"),
1042 meta: PooledClientMeta::new(),
1043 };
1044 let debug = format!("{pooled:?}");
1045 assert!(debug.contains("PooledClient"));
1046 assert!(debug.contains("meta"));
1047 }
1048
1049 #[test]
1050 fn test_provider_pool_stats_clone_and_debug() {
1051 let stats = pool_stats(2, 1, 5, 10, 3, 1);
1052 let cloned = stats.clone();
1053 assert_eq!(cloned.available, 2);
1054 assert_eq!(cloned.in_use, 1);
1055 let debug = format!("{stats:?}");
1056 assert!(debug.contains("available"));
1057 }
1058
1059 #[test]
1060 fn test_pool_stats_clone_and_debug() {
1061 let mut providers = HashMap::new();
1062 providers.insert("p1".to_string(), pool_stats(1, 0, 1, 3, 0, 0));
1063 let stats = PoolStats {
1064 providers,
1065 total_available: 1,
1066 total_in_use: 0,
1067 total_connections: 1,
1068 borrow_count: 0,
1069 error_count: 0,
1070 };
1071 let cloned = stats.clone();
1072 assert_eq!(cloned.total_available, 1);
1073 assert_eq!(cloned.providers.len(), 1);
1074 assert!(format!("{stats:?}").contains("total_in_use"));
1075 }
1076
1077 #[test]
1078 fn test_client_pool_default_impl() {
1079 let pool = ClientPool::default();
1080 assert!(!pool.is_shutdown());
1081 assert_eq!(pool.stats().total_available, 0);
1082 }
1083
1084 #[test]
1085 fn test_client_pool_builder_default_impl() {
1086 let builder = ClientPoolBuilder::default();
1087 let pool = builder.build();
1088 assert!(!pool.has_provider("anything"));
1089 }
1090
1091 #[test]
1092 fn test_builder_empty_build() {
1093 let pool = ClientPoolBuilder::new().build();
1094 assert!(pool.provider_names().is_empty());
1095 assert!(!pool.has_provider("missing"));
1096 }
1097
1098 #[test]
1099 fn test_builder_build_arc() {
1100 let pool = ClientPoolBuilder::new()
1101 .config(PoolConfig::default().with_max_connections(4))
1102 .build_arc();
1103 assert_eq!(pool.stats().total_available, 0);
1104 assert!(!pool.is_shutdown());
1105 }
1106
1107 #[test]
1108 fn test_pool_stats() {
1109 let pool = ClientPool::with_defaults();
1110 let stats = pool.stats();
1111 assert_eq!(stats.total_available, 0);
1112 assert_eq!(stats.total_in_use, 0);
1113 assert!(stats.providers.is_empty());
1114 }
1115
1116 #[test]
1117 fn test_cleanup_stale_on_empty_pool() {
1118 let pool = ClientPool::with_defaults();
1119 assert_eq!(pool.cleanup_stale(), 0);
1120 }
1121
1122 #[test]
1123 fn test_pool_shutdown() {
1124 let pool = ClientPool::with_defaults();
1125 assert!(!pool.is_shutdown());
1126 pool.shutdown();
1127 assert!(pool.is_shutdown());
1128 }
1129
1130 #[test]
1131 fn test_pool_double_shutdown_is_safe() {
1132 let pool = ClientPool::with_defaults();
1133 pool.shutdown();
1134 pool.shutdown();
1135 assert!(pool.is_shutdown());
1136 }
1137
1138 #[test]
1139 fn test_provider_registration() {
1140 let pool = ClientPool::with_defaults();
1141 pool.register_provider("ollama", test_stub_provider());
1142 assert!(pool.has_provider("ollama"));
1143 assert!(!pool.has_provider("openai"));
1144 assert_eq!(pool.provider_names(), vec!["ollama"]);
1145 }
1146
1147 #[test]
1148 fn test_builder_pattern() {
1149 let pool = ClientPoolBuilder::new()
1150 .config(PoolConfig::default().with_max_connections(5))
1151 .provider("ollama", test_stub_provider())
1152 .build();
1153 assert!(pool.has_provider("ollama"));
1154 }
1155
1156 #[test]
1157 fn test_builder_multiple_providers() {
1158 let pool = ClientPoolBuilder::new()
1159 .provider("a", test_stub_provider())
1160 .provider("b", test_stub_provider())
1161 .build();
1162 let mut names = pool.provider_names();
1163 names.sort();
1164 assert_eq!(names, vec!["a", "b"]);
1165 }
1166
1167 #[tokio::test]
1168 async fn test_get_unregistered_provider_error() {
1169 let pool = ClientPool::with_defaults();
1170 let result = pool.get("nonexistent").await;
1171 assert!(result.is_err());
1172 assert!(matches!(result.unwrap_err(), AppError::LLM(_)));
1173 }
1174
1175 #[tokio::test]
1176 async fn test_acquire_reuses_seeded_mock_without_network() {
1177 let config = PoolConfig::default()
1178 .with_max_connections(2)
1179 .without_health_check();
1180 let pool = ClientPool::new(config.clone());
1181 register_seeded_pool(&pool, "mock", config, vec![mock_client("seeded")]);
1182
1183 let guard = pool
1184 .get("mock")
1185 .await
1186 .expect("seeded client should be available");
1187 assert_eq!(guard.client().model_name(), "seeded");
1188 assert_eq!(pool.stats().providers["mock"].total_created, 0);
1189 }
1190
1191 #[tokio::test]
1192 async fn test_acquire_skips_stale_prefers_first_fresh() {
1193 let config = PoolConfig::default()
1194 .with_idle_timeout(Duration::from_millis(5))
1195 .with_max_lifetime(Duration::from_secs(60));
1196 let sub = provider_pool(config);
1197
1198 {
1199 let mut guard = sub.clients.lock();
1200 guard.push(PooledClient {
1201 client: mock_client("stale"),
1202 meta: PooledClientMeta::new(),
1203 });
1204 std::thread::sleep(Duration::from_millis(8));
1205 guard.push(PooledClient {
1206 client: mock_client("fresh"),
1207 meta: PooledClientMeta::new(),
1208 });
1209 }
1210
1211 let (client, permit) = sub.acquire().await.expect("acquire fresh client");
1212 assert_eq!(client.model_name(), "fresh");
1213 drop(client);
1214 drop(permit);
1215 }
1216
1217 #[tokio::test]
1218 async fn test_release_and_reacquire_reuses_pooled_client() {
1219 let config = PoolConfig::default()
1220 .with_max_connections(1)
1221 .without_health_check();
1222 let pool = ClientPool::new(config.clone());
1223 register_seeded_pool(&pool, "mock", config, vec![mock_client("reusable")]);
1224
1225 {
1226 let guard = pool.get("mock").await.expect("first acquire");
1227 assert_eq!(guard.client().model_name(), "reusable");
1228 }
1229
1230 let guard = pool.get("mock").await.expect("second acquire");
1231 assert_eq!(guard.client().model_name(), "reusable");
1232 let stats = pool.stats().providers["mock"].clone();
1233 assert_eq!(stats.total_created, 0);
1234 assert_eq!(stats.in_use, 1);
1235 }
1236
1237 #[tokio::test]
1238 async fn test_release_drops_client_when_idle_pool_full() {
1239 let config = PoolConfig::default()
1240 .with_max_connections(1)
1241 .without_health_check();
1242 let sub = provider_pool(config);
1243 seed_pool(&sub, vec![mock_client("idle-0")]);
1244
1245 let (client, permit) = sub.acquire().await.expect("acquire seeded client");
1246 sub.release(client);
1247 drop(permit);
1248 assert_eq!(sub.stats().available, 1);
1249 assert_eq!(sub.stats().in_use, 0);
1250
1251 let (overflow, permit2) = sub.acquire().await.expect("acquire again");
1252 sub.release(overflow);
1253 drop(permit2);
1254
1255 let stats = sub.stats();
1256 assert_eq!(stats.available, 1);
1257 assert_eq!(stats.in_use, 0);
1258 assert_eq!(stats.total_created, 0);
1259 }
1260
1261 #[tokio::test]
1262 async fn test_provider_pool_in_use_accounting() {
1263 let config = PoolConfig::default()
1264 .with_max_connections(2)
1265 .without_health_check();
1266 let sub = provider_pool(config);
1267 seed_pool(&sub, vec![mock_client("a")]);
1268
1269 let (_client, permit) = sub.acquire().await.expect("acquire");
1270 let stats = sub.stats();
1271 assert_eq!(stats.in_use, 1);
1272 assert_eq!(stats.available, 0);
1273 drop(permit);
1274 }
1275
1276 #[tokio::test]
1277 async fn test_cleanup_stale_removes_idle_clients() {
1278 let config = PoolConfig::default()
1279 .with_idle_timeout(Duration::from_millis(5))
1280 .without_health_check();
1281 let sub = provider_pool(config);
1282 {
1283 let mut guard = sub.clients.lock();
1284 guard.push(PooledClient {
1285 client: mock_client("old"),
1286 meta: PooledClientMeta::new(),
1287 });
1288 std::thread::sleep(Duration::from_millis(8));
1289 }
1290 let removed = sub.cleanup_stale();
1291 assert_eq!(removed, 1);
1292 assert_eq!(sub.stats().available, 0);
1293 }
1294
1295 #[tokio::test]
1296 async fn test_pooled_client_guard_debug_and_deref() {
1297 let config = PoolConfig::default()
1298 .with_max_connections(1)
1299 .without_health_check();
1300 let pool = ClientPool::new(config.clone());
1301 register_seeded_pool(&pool, "mock", config, vec![mock_client("guard")]);
1302
1303 let guard = pool.get("mock").await.expect("guard acquire");
1304 let debug = format!("{guard:?}");
1305 assert!(debug.contains("PooledClientGuard"));
1306 assert!(debug.contains("has_client"));
1307 assert_eq!(guard.model_name(), "guard");
1308 }
1309
1310 #[test]
1311 fn test_register_provider_overwrites_existing_name() {
1312 let pool = ClientPool::with_defaults();
1313 pool.register_provider("ollama", test_stub_provider());
1314 pool.register_provider("ollama", test_stub_provider());
1315 assert_eq!(pool.provider_names(), vec!["ollama"]);
1316 }
1317
1318 #[tokio::test]
1319 async fn test_stats_aggregate_multiple_providers() {
1320 let config = PoolConfig::default()
1321 .with_max_connections(2)
1322 .without_health_check();
1323 let pool = ClientPool::new(config.clone());
1324 register_seeded_pool(&pool, "a", config.clone(), vec![mock_client("a1")]);
1325 register_seeded_pool(&pool, "b", config, vec![mock_client("b1")]);
1326
1327 let _ga = pool.get("a").await.expect("provider a");
1328 let stats = pool.stats();
1329 assert_eq!(stats.providers.len(), 2);
1330 assert_eq!(stats.total_in_use, 1);
1331 assert_eq!(stats.total_available, 1);
1332 }
1333
1334 #[tokio::test]
1335 async fn test_shutdown_drains_seeded_clients() {
1336 let config = PoolConfig::default()
1337 .with_max_connections(2)
1338 .without_health_check();
1339 let pool = ClientPool::new(config.clone());
1340 register_seeded_pool(&pool, "mock", config, vec![mock_client("seeded")]);
1341 assert_eq!(pool.stats().total_available, 1);
1342
1343 pool.shutdown();
1344 assert!(pool.is_shutdown());
1345 assert_eq!(pool.stats().total_available, 0);
1346 }
1347
1348 #[tokio::test]
1349 async fn test_acquire_creates_client_when_pool_empty() {
1350 let config = PoolConfig::default()
1351 .with_max_connections(1)
1352 .without_health_check();
1353 let sub = provider_pool(config);
1354
1355 let (client, permit) = sub.acquire().await.expect("create via TestStub");
1356 assert_eq!(client.model_name(), "mock");
1357 drop(client);
1358 drop(permit);
1359 assert_eq!(sub.stats().total_created, 1);
1360 }
1361
1362 #[tokio::test]
1363 async fn test_pooled_client_guard_client_mut_and_take() {
1364 let config = PoolConfig::default()
1365 .with_max_connections(1)
1366 .without_health_check();
1367 let pool = ClientPool::new(config.clone());
1368 register_seeded_pool(&pool, "mock", config, vec![mock_client("guard-mut")]);
1369
1370 let mut guard = pool.get("mock").await.expect("guard acquire");
1371 assert_eq!(guard.client_mut().model_name(), "guard-mut");
1372 let taken = guard.take();
1373 assert_eq!(taken.model_name(), "guard-mut");
1374 }
1375
1376 fn serde_roundtrip<T>(value: &T) -> T
1377 where
1378 T: serde::Serialize + for<'de> serde::Deserialize<'de> + PartialEq + std::fmt::Debug,
1379 {
1380 let json = serde_json::to_string(value).unwrap();
1381 let parsed: T = serde_json::from_str(&json).unwrap();
1382 assert_eq!(*value, parsed);
1383 parsed
1384 }
1385
1386 #[test]
1387 fn test_pool_config_serde_roundtrip() {
1388 let config = PoolConfig::default()
1389 .with_max_connections(4)
1390 .with_idle_timeout(Duration::from_secs(90))
1391 .without_health_check();
1392 serde_roundtrip(&config);
1393 }
1394
1395 #[test]
1396 fn test_pool_stats_serde_roundtrip() {
1397 let mut providers = HashMap::new();
1398 providers.insert("mock".into(), pool_stats(1, 2, 3, 4, 5, 6));
1399 let stats = PoolStats {
1400 providers,
1401 total_available: 1,
1402 total_in_use: 2,
1403 total_connections: 3,
1404 borrow_count: 5,
1405 error_count: 6,
1406 };
1407 serde_roundtrip(&stats);
1408 }
1409
1410 #[test]
1411 fn test_provider_pool_stats_serde_roundtrip() {
1412 serde_roundtrip(&pool_stats(2, 1, 9, 10, 7, 2));
1413 }
1414
1415 #[test]
1416 fn test_llm_pool_snapshot_serde_roundtrip() {
1417 let pool = ClientPoolBuilder::new()
1418 .provider("mock", test_stub_provider())
1419 .build();
1420 serde_roundtrip(&pool.snapshot());
1421 }
1422
1423 #[test]
1424 fn test_llm_pool_type_alias() {
1425 let pool: LLMPool = ClientPool::with_defaults();
1426 assert_eq!(pool.stats().total_available, 0);
1427 }
1428
1429 #[test]
1430 fn test_pool_error_serde_roundtrip_exhausted() {
1431 serde_roundtrip(&PoolError::PoolExhausted { max: 3 });
1432 }
1433
1434 #[test]
1435 fn test_pool_error_serde_roundtrip_timeout() {
1436 serde_roundtrip(&PoolError::Timeout { timeout_ms: 250 });
1437 }
1438
1439 #[test]
1440 fn test_pool_error_serde_roundtrip_invalid_client() {
1441 serde_roundtrip(&PoolError::InvalidClient {
1442 reason: "bad".into(),
1443 });
1444 }
1445
1446 #[test]
1447 fn test_pool_error_display_variants() {
1448 assert!(
1449 PoolError::PoolExhausted { max: 2 }
1450 .to_string()
1451 .contains("pool exhausted")
1452 );
1453 assert!(
1454 PoolError::Timeout { timeout_ms: 10 }
1455 .to_string()
1456 .contains("timeout")
1457 );
1458 assert!(
1459 PoolError::InvalidClient { reason: "x".into() }
1460 .to_string()
1461 .contains("invalid")
1462 );
1463 }
1464
1465 #[test]
1466 fn test_pool_error_clone_debug() {
1467 let err = PoolError::PoolExhausted { max: 1 };
1468 assert_eq!(err, err.clone());
1469 assert!(format!("{err:?}").contains("PoolExhausted"));
1470 }
1471
1472 #[test]
1473 fn test_pool_config_display() {
1474 let s = PoolConfig::default().with_max_connections(6).to_string();
1475 assert!(s.contains("max=6"));
1476 }
1477
1478 #[test]
1479 fn test_pool_stats_display() {
1480 let stats = PoolStats {
1481 providers: HashMap::new(),
1482 total_available: 1,
1483 total_in_use: 2,
1484 total_connections: 3,
1485 borrow_count: 4,
1486 error_count: 5,
1487 };
1488 let s = stats.to_string();
1489 assert!(s.contains("idle=1"));
1490 assert!(s.contains("errors=5"));
1491 }
1492
1493 #[test]
1494 fn test_provider_pool_stats_display() {
1495 let s = pool_stats(1, 2, 0, 4, 9, 1).to_string();
1496 assert!(s.contains("borrows=9"));
1497 assert!(s.contains("total=3"));
1498 }
1499
1500 #[test]
1501 fn test_client_pool_display() {
1502 let pool = ClientPool::with_defaults();
1503 let s = pool.to_string();
1504 assert!(s.contains("LLMPool"));
1505 assert!(s.contains("shutdown=false"));
1506 }
1507
1508 #[test]
1509 fn test_borrow_client_returns_first_healthy() {
1510 let config = PoolConfig::default().without_health_check();
1511 let mut idle = vec![PooledClient {
1512 client: mock_client("a"),
1513 meta: PooledClientMeta::new(),
1514 }];
1515 match borrow_client(&mut idle, &config) {
1516 BorrowFromIdle::Found(p) => assert_eq!(p.client.model_name(), "a"),
1517 _ => panic!("expected found"),
1518 }
1519 assert!(idle.is_empty());
1520 }
1521
1522 #[test]
1523 fn test_borrow_client_purges_stale_entries() {
1524 let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(1));
1525 let mut idle = vec![PooledClient {
1526 client: mock_client("stale"),
1527 meta: PooledClientMeta::new(),
1528 }];
1529 std::thread::sleep(Duration::from_millis(3));
1530 assert!(matches!(
1531 borrow_client(&mut idle, &config),
1532 BorrowFromIdle::Exhausted
1533 ));
1534 assert!(idle.is_empty());
1535 }
1536
1537 #[test]
1538 fn test_return_client_respects_capacity() {
1539 let config = PoolConfig::default()
1540 .with_max_connections(1)
1541 .without_health_check();
1542 let mut idle = vec![];
1543 assert_eq!(
1544 return_client(&mut idle, mock_client("a"), &config),
1545 ReturnDisposition::Returned
1546 );
1547 assert_eq!(
1548 return_client(&mut idle, mock_client("b"), &config),
1549 ReturnDisposition::Dropped
1550 );
1551 assert_eq!(idle.len(), 1);
1552 }
1553
1554 #[test]
1555 fn test_health_check_disabled_ignores_stale_meta() {
1556 let config = PoolConfig::default()
1557 .with_idle_timeout(Duration::from_millis(1))
1558 .without_health_check();
1559 let meta = PooledClientMeta::new();
1560 std::thread::sleep(Duration::from_millis(3));
1561 assert!(health_check(&meta, &config));
1562 }
1563
1564 #[test]
1565 fn test_health_check_enabled_rejects_stale_meta() {
1566 let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(1));
1567 let meta = PooledClientMeta::new();
1568 std::thread::sleep(Duration::from_millis(3));
1569 assert!(!health_check(&meta, &config));
1570 }
1571
1572 #[test]
1573 fn test_pool_stats_helper_totals() {
1574 let stats = pool_stats(2, 3, 10, 8, 4, 1);
1575 assert_eq!(stats.total, 5);
1576 assert_eq!(stats.borrow_count, 4);
1577 assert_eq!(stats.error_count, 1);
1578 }
1579
1580 #[test]
1581 fn test_validate_pooled_client_rejects_empty_model() {
1582 let client = mock_client("");
1583 let err = validate_pooled_client(client.as_ref()).unwrap_err();
1584 assert!(matches!(err, PoolError::InvalidClient { .. }));
1585 }
1586
1587 #[test]
1588 fn test_validate_pooled_client_accepts_named_model() {
1589 validate_pooled_client(mock_client("ok").as_ref()).unwrap();
1590 }
1591
1592 #[tokio::test]
1593 async fn test_try_get_pool_exhausted_when_at_capacity() {
1594 let config = PoolConfig::default()
1595 .with_max_connections(1)
1596 .without_health_check();
1597 let pool = ClientPool::new(config.clone());
1598 register_seeded_pool(&pool, "mock", config, vec![mock_client("only")]);
1599
1600 let _guard = pool.try_get("mock").await.expect("first borrow");
1601 let err = pool.try_get("mock").await.unwrap_err();
1602 assert!(matches!(err, PoolError::PoolExhausted { max: 1 }));
1603 assert_eq!(pool.stats().providers["mock"].error_count, 1);
1604 }
1605
1606 #[tokio::test]
1607 async fn test_acquire_timeout_increments_error_count() {
1608 let mut config = PoolConfig::default()
1609 .with_max_connections(1)
1610 .with_idle_timeout(Duration::from_secs(60))
1611 .without_health_check();
1612 config.acquire_timeout = Duration::from_millis(50);
1613 let sub = provider_pool(config);
1614 seed_pool(&sub, vec![mock_client("held")]);
1615
1616 let (_c, permit) = sub.acquire().await.expect("hold permit");
1617 let err = match sub.acquire().await {
1618 Err(e) => e,
1619 Ok(_) => panic!("expected pool acquire timeout"),
1620 };
1621 assert!(matches!(err, PoolError::Timeout { .. }));
1622 assert_eq!(sub.stats().error_count, 1);
1623 drop(permit);
1624 }
1625
1626 #[tokio::test]
1627 async fn test_borrow_count_increments_on_success() {
1628 let config = PoolConfig::default()
1629 .with_max_connections(2)
1630 .without_health_check();
1631 let sub = provider_pool(config);
1632 seed_pool(&sub, vec![mock_client("x")]);
1633 let (_c, permit) = sub.acquire().await.unwrap();
1634 drop(permit);
1635 assert_eq!(sub.stats().borrow_count, 1);
1636 }
1637
1638 #[tokio::test]
1639 async fn test_concurrent_borrows_respect_max_connections() {
1640 let config = PoolConfig::default()
1641 .with_max_connections(2)
1642 .without_health_check();
1643 let pool = Arc::new(ClientPool::new(config.clone()));
1644 register_seeded_pool(
1645 &pool,
1646 "mock",
1647 config,
1648 vec![mock_client("c1"), mock_client("c2")],
1649 );
1650
1651 let (tx, mut rx) = tokio::sync::oneshot::channel();
1652 let pool_bg = Arc::clone(&pool);
1653 tokio::spawn(async move {
1654 let result = pool_bg.get("mock").await;
1655 let _ = tx.send(result);
1656 });
1657
1658 let g1 = pool.get("mock").await.expect("first");
1659 let g2 = pool.get("mock").await.expect("second");
1660 assert!(
1661 tokio::time::timeout(Duration::from_millis(100), &mut rx)
1662 .await
1663 .is_err(),
1664 "third borrow should still be waiting"
1665 );
1666 drop(g1);
1667 drop(g2);
1668 let third_result = rx
1669 .await
1670 .expect("channel")
1671 .expect("third completes after release");
1672 drop(third_result);
1673 }
1674
1675 #[tokio::test]
1676 async fn test_get_with_error_unregistered_invalid_client() {
1677 let pool = ClientPool::with_defaults();
1678 let err = pool.get_with_error("missing").await.unwrap_err();
1679 assert!(matches!(err, PoolError::InvalidClient { .. }));
1680 }
1681
1682 #[tokio::test]
1683 async fn test_get_with_error_shutdown_invalid_client() {
1684 let pool = ClientPool::with_defaults();
1685 pool.shutdown();
1686 let err = pool.get_with_error("anything").await.unwrap_err();
1687 assert!(matches!(err, PoolError::InvalidClient { .. }));
1688 }
1689
1690 #[tokio::test]
1691 async fn test_cleanup_stale_client_pool_aggregates() {
1692 let config = PoolConfig::default()
1693 .with_idle_timeout(Duration::from_millis(5))
1694 .without_health_check();
1695 let pool = ClientPool::new(config.clone());
1696 register_seeded_pool(&pool, "mock", config, vec![mock_client("old")]);
1697 std::thread::sleep(Duration::from_millis(8));
1698 assert_eq!(pool.cleanup_stale(), 1);
1699 assert_eq!(pool.stats().total_available, 0);
1700 }
1701
1702 #[tokio::test]
1703 async fn test_stats_track_aggregate_borrow_and_error_counts() {
1704 let config = PoolConfig::default()
1705 .with_max_connections(1)
1706 .without_health_check();
1707 let pool = ClientPool::new(config.clone());
1708 register_seeded_pool(&pool, "mock", config, vec![mock_client("one")]);
1709 let _g = pool.try_get("mock").await.unwrap();
1710 let _ = pool.try_get("mock").await;
1711 let stats = pool.stats();
1712 assert_eq!(stats.borrow_count, 1);
1713 assert_eq!(stats.error_count, 1);
1714 }
1715
1716 #[tokio::test]
1717 async fn test_acquire_removes_stale_before_creating_client() {
1718 let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(5));
1719 let sub = provider_pool(config);
1720 {
1721 let mut guard = sub.clients.lock();
1722 guard.push(PooledClient {
1723 client: mock_client("stale-only"),
1724 meta: PooledClientMeta::new(),
1725 });
1726 std::thread::sleep(Duration::from_millis(8));
1727 }
1728 let (client, permit) = sub.acquire().await.expect("creates fresh client");
1729 assert_eq!(client.model_name(), "mock");
1730 assert_eq!(sub.stats().total_created, 1);
1731 drop(client);
1732 drop(permit);
1733 }
1734
1735 #[tokio::test]
1736 async fn test_race_release_then_reacquire() {
1737 let config = PoolConfig::default()
1738 .with_max_connections(1)
1739 .without_health_check();
1740 let pool = Arc::new(ClientPool::new(config.clone()));
1741 register_seeded_pool(&pool, "mock", config, vec![mock_client("race")]);
1742
1743 let g1 = pool.get("mock").await.expect("first");
1744 let pool2 = Arc::clone(&pool);
1745 let j = tokio::spawn(async move {
1746 tokio::time::sleep(Duration::from_millis(5)).await;
1747 pool2.get("mock").await
1748 });
1749 drop(g1);
1750 let g2 = j
1751 .await
1752 .expect("join")
1753 .expect("second acquire after release");
1754 assert_eq!(g2.model_name(), "race");
1755 }
1756
1757 #[test]
1758 fn test_return_disposition_debug_clone() {
1759 let d = ReturnDisposition::Returned;
1760 assert_eq!(d, d);
1761 assert!(format!("{d:?}").contains("Returned"));
1762 }
1763
1764 #[test]
1765 fn test_pool_stats_clone_preserves_aggregate_fields() {
1766 let stats = PoolStats {
1767 providers: HashMap::new(),
1768 total_available: 0,
1769 total_in_use: 0,
1770 total_connections: 0,
1771 borrow_count: 2,
1772 error_count: 3,
1773 };
1774 let cloned = stats.clone();
1775 assert_eq!(cloned.borrow_count, 2);
1776 assert_eq!(cloned.error_count, 3);
1777 }
1778
1779 #[tokio::test]
1780 async fn test_provider_pool_stats_after_release_shows_idle() {
1781 let config = PoolConfig::default()
1782 .with_max_connections(1)
1783 .without_health_check();
1784 let sub = provider_pool(config);
1785 let (client, permit) = sub.acquire().await.unwrap();
1786 sub.release(client);
1787 drop(permit);
1788 let stats = sub.stats();
1789 assert_eq!(stats.available, 1);
1790 assert_eq!(stats.in_use, 0);
1791 assert_eq!(stats.total, 1);
1792 }
1793
1794 #[test]
1795 fn test_pool_stats_default_aggregate_fields() {
1796 let stats = ClientPool::with_defaults().stats();
1797 assert_eq!(stats.total_connections, 0);
1798 assert_eq!(stats.borrow_count, 0);
1799 assert_eq!(stats.error_count, 0);
1800 }
1801
1802 #[test]
1803 fn test_return_disposition_variants() {
1804 assert_ne!(ReturnDisposition::Returned, ReturnDisposition::Dropped);
1805 }
1806
1807 #[tokio::test]
1808 async fn test_try_get_success_returns_guard() {
1809 let config = PoolConfig::default()
1810 .with_max_connections(1)
1811 .without_health_check();
1812 let pool = ClientPool::new(config.clone());
1813 register_seeded_pool(&pool, "mock", config, vec![mock_client("ok")]);
1814 let guard = pool.try_get("mock").await.expect("try_get ok");
1815 assert_eq!(guard.model_name(), "ok");
1816 }
1817
1818 #[tokio::test]
1819 async fn test_get_with_error_success_path() {
1820 let config = PoolConfig::default()
1821 .with_max_connections(1)
1822 .without_health_check();
1823 let pool = ClientPool::new(config.clone());
1824 register_seeded_pool(&pool, "mock", config, vec![mock_client("ok2")]);
1825 let guard = pool.get_with_error("mock").await.expect("get ok");
1826 assert_eq!(guard.model_name(), "ok2");
1827 }
1828
1829 #[test]
1830 fn test_llm_pool_snapshot_lists_providers() {
1831 let pool = ClientPoolBuilder::new()
1832 .provider("a", test_stub_provider())
1833 .provider("b", test_stub_provider())
1834 .build();
1835 let snap = pool.snapshot();
1836 let mut names = snap.providers;
1837 names.sort();
1838 assert_eq!(names, vec!["a", "b"]);
1839 }
1840
1841 #[test]
1842 fn test_health_check_fresh_client_meta() {
1843 let config = PoolConfig::default().with_idle_timeout(Duration::from_secs(60));
1844 let meta = PooledClientMeta::new();
1845 assert!(health_check(&meta, &config));
1846 }
1847
1848 #[tokio::test]
1849 async fn test_cleanup_stale_on_provider_pool() {
1850 let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(5));
1851 let sub = provider_pool(config);
1852 {
1853 let mut guard = sub.clients.lock();
1854 guard.push(PooledClient {
1855 client: mock_client("gone"),
1856 meta: PooledClientMeta::new(),
1857 });
1858 std::thread::sleep(Duration::from_millis(8));
1859 }
1860 assert_eq!(sub.cleanup_stale(), 1);
1861 }
1862
1863 #[tokio::test]
1864 async fn test_get_after_shutdown() {
1865 let pool = ClientPool::with_defaults();
1866 pool.shutdown();
1867
1868 let result = pool.get("anything").await;
1869 assert!(result.is_err());
1870 assert!(matches!(result.unwrap_err(), AppError::LLM(_)));
1871 }
1872
1873 use crate::client::LLMResponse;
1875 use ares_types::types::ToolDefinition;
1876 use futures::Stream;
1877
1878 struct SlowMockClient {
1881 delay: Duration,
1882 in_flight: Arc<AtomicUsize>,
1883 max_observed: Arc<AtomicUsize>,
1884 }
1885
1886 impl SlowMockClient {
1887 fn new(
1888 delay: Duration,
1889 in_flight: Arc<AtomicUsize>,
1890 max_observed: Arc<AtomicUsize>,
1891 ) -> Self {
1892 Self {
1893 delay,
1894 in_flight,
1895 max_observed,
1896 }
1897 }
1898 }
1899
1900 #[async_trait]
1901 impl LLMClient for SlowMockClient {
1902 async fn generate(&self, _prompt: &str) -> Result<String> {
1903 let now = self.in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
1904 self.max_observed.fetch_max(now, AtomicOrdering::Relaxed);
1905 tokio::time::sleep(self.delay).await;
1906 self.in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
1907 Ok("slow".into())
1908 }
1909
1910 async fn generate_with_system(&self, _system: &str, prompt: &str) -> Result<String> {
1911 self.generate(prompt).await
1912 }
1913
1914 async fn generate_with_history(
1915 &self,
1916 messages: &[(String, String)],
1917 ) -> Result<LLMResponse> {
1918 let content = self
1919 .generate(messages.first().map(|(_, c)| c.as_str()).unwrap_or(""))
1920 .await?;
1921 Ok(LLMResponse {
1922 content,
1923 tool_calls: vec![],
1924 finish_reason: "stop".into(),
1925 usage: None,
1926 })
1927 }
1928
1929 async fn generate_with_tools(
1930 &self,
1931 prompt: &str,
1932 _tools: &[ToolDefinition],
1933 ) -> Result<LLMResponse> {
1934 let content = self.generate(prompt).await?;
1935 Ok(LLMResponse {
1936 content,
1937 tool_calls: vec![],
1938 finish_reason: "stop".into(),
1939 usage: None,
1940 })
1941 }
1942
1943 async fn generate_with_tools_and_history(
1944 &self,
1945 messages: &[crate::coordinator::ConversationMessage],
1946 _tools: &[ToolDefinition],
1947 ) -> Result<LLMResponse> {
1948 let content = self
1949 .generate(messages.first().map(|m| m.content.as_str()).unwrap_or(""))
1950 .await?;
1951 Ok(LLMResponse {
1952 content,
1953 tool_calls: vec![],
1954 finish_reason: "stop".into(),
1955 usage: None,
1956 })
1957 }
1958
1959 async fn stream(
1960 &self,
1961 _prompt: &str,
1962 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1963 Err(AppError::Internal("stream unused here".into()))
1964 }
1965
1966 async fn stream_with_system(
1967 &self,
1968 _system: &str,
1969 _prompt: &str,
1970 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1971 Err(AppError::Internal("stream unused here".into()))
1972 }
1973
1974 async fn stream_with_history(
1975 &self,
1976 _messages: &[(String, String)],
1977 ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
1978 Err(AppError::Internal("stream unused here".into()))
1979 }
1980
1981 fn model_name(&self) -> &str {
1982 "slow-mock"
1983 }
1984 }
1985
1986 #[tokio::test(flavor = "multi_thread")]
1987 async fn governor_caps_concurrent_dispatch() {
1988 const PERMITS: usize = 2;
1989 const CALLS: usize = 8;
1990
1991 let in_flight = Arc::new(AtomicUsize::new(0));
1995 let max_observed = Arc::new(AtomicUsize::new(0));
1996 let slow_clients: Vec<Box<dyn LLMClient>> = (0..CALLS)
1997 .map(|_| {
1998 Box::new(SlowMockClient::new(
1999 Duration::from_millis(30),
2000 Arc::clone(&in_flight),
2001 Arc::clone(&max_observed),
2002 )) as Box<dyn LLMClient>
2003 })
2004 .collect();
2005
2006 let config = PoolConfig::default()
2007 .with_max_in_flight(PERMITS)
2008 .with_governor_acquire_timeout(Duration::from_secs(5))
2009 .without_health_check();
2010 let pool = Arc::new(ClientPool::new(config.clone()));
2011 register_seeded_pool(&pool, "mock", config, slow_clients);
2012
2013 let mut handles = Vec::new();
2014 for _ in 0..CALLS {
2015 let pool = Arc::clone(&pool);
2016 handles.push(tokio::spawn(async move {
2017 let guard = pool.get("mock").await.expect("governed checkout");
2018 let _ = guard.client().generate("hello").await;
2019 }));
2020 }
2021 for handle in handles {
2022 handle.await.expect("task joins");
2023 }
2024
2025 let observed = max_observed.load(AtomicOrdering::Relaxed);
2026 assert_eq!(
2027 in_flight.load(AtomicOrdering::Relaxed),
2028 0,
2029 "all dispatches finished"
2030 );
2031 assert!(
2032 observed <= PERMITS,
2033 "max observed in-flight ({observed}) exceeded permits ({PERMITS})"
2034 );
2035 assert_eq!(observed, PERMITS, "cap should be reached under load");
2036 }
2037
2038 #[tokio::test(flavor = "multi_thread")]
2039 async fn unlimited_default_preserves_behavior() {
2040 let config = PoolConfig::default().without_health_check();
2043 let pool = ClientPool::new(config.clone());
2044 register_seeded_pool(&pool, "mock", config, vec![mock_client("free")]);
2045
2046 let guard = pool.get("mock").await.expect("checkout works");
2047 assert_eq!(guard.client().model_name(), "free");
2048 let taken: Box<dyn LLMClient> = guard.take();
2050 assert_eq!(taken.model_name(), "free");
2051 drop(taken);
2052
2053 let unlimited = ProviderGovernor::new(GovernorConfig::default());
2055 let wrapped = unlimited.wrap_if_limited(mock_client("passthrough"));
2056 assert_eq!(
2057 wrapped.model_name(),
2058 "passthrough",
2059 "unlimited governors must not install wrappers"
2060 );
2061
2062 let defaults = PoolConfig::default();
2063 assert_eq!(defaults.max_in_flight, None);
2064 assert_eq!(defaults.governor_config(), GovernorConfig::default());
2065 }
2066
2067 #[tokio::test]
2068 async fn permit_released_on_error_path() {
2069 let config = PoolConfig::default()
2070 .with_max_in_flight(1)
2071 .with_governor_acquire_timeout(Duration::from_millis(100))
2072 .without_health_check();
2073 let sub = provider_pool(config.clone());
2074
2075 let failing = SlowMockFailing;
2078 let guarded = sub.governor.wrap_if_limited(Box::new(failing));
2079 let err = guarded.generate("boom").await.unwrap_err();
2080 assert!(matches!(err, AppError::LLM(_)));
2081
2082 let ok_client = sub.governor.wrap_if_limited(Box::new(SlowMockClient::new(
2085 Duration::from_millis(1),
2086 Arc::new(AtomicUsize::new(0)),
2087 Arc::new(AtomicUsize::new(0)),
2088 )));
2089 let started = std::time::Instant::now();
2090 ok_client.generate("fine").await.expect("slot was released");
2091 assert!(
2092 started.elapsed() < Duration::from_millis(90),
2093 "second dispatch should not wait: slot was released by the error path"
2094 );
2095 }
2096
2097 struct SlowMockFailing;
2099
2100 #[async_trait]
2101 impl LLMClient for SlowMockFailing {
2102 async fn generate(&self, _prompt: &str) -> Result<String> {
2103 Err(AppError::LLM("upstream rejected".into()))
2104 }
2105
2106 async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
2107 Err(AppError::LLM("upstream rejected".into()))
2108 }
2109
2110 async fn generate_with_history(
2111 &self,
2112 _messages: &[(String, String)],
2113 ) -> Result<LLMResponse> {
2114 Err(AppError::LLM("upstream rejected".into()))
2115 }
2116
2117 async fn generate_with_tools(
2118 &self,
2119 _prompt: &str,
2120 _tools: &[ToolDefinition],
2121 ) -> Result<LLMResponse> {
2122 Err(AppError::LLM("upstream rejected".into()))
2123 }
2124
2125 async fn generate_with_tools_and_history(
2126 &self,
2127 _messages: &[crate::coordinator::ConversationMessage],
2128 _tools: &[ToolDefinition],
2129 ) -> Result<LLMResponse> {
2130 Err(AppError::LLM("upstream rejected".into()))
2131 }
2132
2133 async fn stream(
2134 &self,
2135 _prompt: &str,
2136 ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
2137 Err(AppError::Internal("stream failed at setup".into()))
2138 }
2139
2140 async fn stream_with_system(
2141 &self,
2142 _system: &str,
2143 _prompt: &str,
2144 ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
2145 Err(AppError::Internal("stream failed at setup".into()))
2146 }
2147
2148 async fn stream_with_history(
2149 &self,
2150 _messages: &[(String, String)],
2151 ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
2152 Err(AppError::Internal("stream failed at setup".into()))
2153 }
2154
2155 fn model_name(&self) -> &str {
2156 "failing-mock"
2157 }
2158 }
2159}