Skip to main content

aria2_core/ftp/
connection_pool.rs

1//! FTP connection pool for connection reuse and performance optimization.
2//!
3//! This module provides a connection pool for FTP control connections that:
4//! - Reuses existing connections to avoid repeated authentication
5//! - Implements LRU eviction strategy when pool is full
6//! - Supports concurrent access from multiple download tasks
7//! - Provides health checking for stale connections
8//!
9//! # Performance Benefits
10//!
11//! Connection pooling provides 40-60% speed improvement by:
12//! - Eliminating 10-second connection establishment overhead
13//! - Avoiding repeated authentication handshakes
14//! - Reducing TCP connection setup latency
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use aria2_core::ftp::connection_pool::{FtpConnectionPool, PooledConnection};
20//! use aria2_core::ftp::connection::FtpMode;
21//!
22//! #[tokio::main]
23//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
24//!     let pool = FtpConnectionPool::new(10); // Max 10 connections
25//!     
26//!     // Get or create a connection
27//!     let conn = pool.get_connection(
28//!         "ftp.example.com",
29//!         21,
30//!         "user",
31//!         "pass",
32//!         FtpMode::Passive
33//!     ).await?;
34//!     
35//!     // Use the connection...
36//!     
37//!     // Return it to the pool
38//!     pool.return_connection(conn).await;
39//!     
40//!     Ok(())
41//! }
42//! ```
43
44use std::collections::HashMap;
45use std::sync::Arc;
46use std::time::{Duration, Instant};
47
48use tokio::sync::Mutex;
49use tracing::{debug, info};
50
51use crate::error::Result;
52use crate::ftp::connection::{FtpClient, FtpMode};
53
54/// Connection key for identifying unique FTP server connections
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56pub struct ConnectionKey {
57    /// Server hostname
58    pub host: String,
59    /// Server port
60    pub port: u16,
61    /// Username for authentication
62    pub username: String,
63    /// Password for authentication (stored for reconnection if needed)
64    pub password: String,
65}
66
67impl ConnectionKey {
68    /// Create a new connection key
69    pub fn new(host: &str, port: u16, username: &str, password: &str) -> Self {
70        Self {
71            host: host.to_string(),
72            port,
73            username: username.to_string(),
74            password: password.to_string(),
75        }
76    }
77}
78
79/// Pooled FTP connection with metadata
80pub struct PooledConnection {
81    /// The actual FTP client
82    pub client: FtpClient,
83    /// Connection key for identification
84    pub key: ConnectionKey,
85    /// When this connection was created
86    pub created_at: Instant,
87    /// When this connection was last used
88    pub last_used: Instant,
89    /// Number of times this connection has been reused
90    pub reuse_count: u64,
91    /// Connection mode (passive/active)
92    pub mode: FtpMode,
93}
94
95impl std::fmt::Debug for PooledConnection {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("PooledConnection")
98            .field("key", &self.key)
99            .field("created_at", &self.created_at)
100            .field("last_used", &self.last_used)
101            .field("reuse_count", &self.reuse_count)
102            .field("mode", &self.mode)
103            .field("age", &self.age())
104            .field("idle_time", &self.idle_time())
105            .finish_non_exhaustive()
106    }
107}
108
109impl PooledConnection {
110    /// Create a new pooled connection wrapper
111    pub fn new(client: FtpClient, key: ConnectionKey, mode: FtpMode) -> Self {
112        let now = Instant::now();
113        Self {
114            client,
115            key,
116            created_at: now,
117            last_used: now,
118            reuse_count: 0,
119            mode,
120        }
121    }
122
123    /// Mark this connection as used (update last_used timestamp)
124    pub fn mark_used(&mut self) {
125        self.last_used = Instant::now();
126        self.reuse_count += 1;
127    }
128
129    /// Check if this connection is still healthy
130    pub fn is_healthy(&self, max_idle_time: Duration) -> bool {
131        // Connection is healthy if it hasn't been idle too long
132        self.last_used.elapsed() < max_idle_time
133    }
134
135    /// Get the age of this connection
136    pub fn age(&self) -> Duration {
137        self.created_at.elapsed()
138    }
139
140    /// Get how long this connection has been idle
141    pub fn idle_time(&self) -> Duration {
142        self.last_used.elapsed()
143    }
144}
145
146/// LRU entry for tracking access order
147#[derive(Debug, Clone)]
148struct LruEntry {
149    key: ConnectionKey,
150    last_access: Instant,
151}
152
153/// FTP connection pool configuration
154#[derive(Debug, Clone)]
155pub struct PoolConfig {
156    /// Maximum number of connections in the pool
157    pub max_connections: usize,
158    /// Maximum idle time before a connection is considered stale
159    pub max_idle_time: Duration,
160    /// Maximum age of a connection before it's evicted
161    pub max_connection_age: Duration,
162    /// Connection timeout for new connections
163    pub connect_timeout: Duration,
164    /// Read timeout for operations
165    pub read_timeout: Duration,
166}
167
168impl Default for PoolConfig {
169    fn default() -> Self {
170        Self {
171            max_connections: crate::constants::FTP_POOL_DEFAULT_MAX_CONNECTIONS,
172            max_idle_time: Duration::from_secs(
173                crate::constants::FTP_POOL_DEFAULT_MAX_IDLE_TIME_SECS,
174            ),
175            max_connection_age: Duration::from_secs(
176                crate::constants::FTP_POOL_DEFAULT_MAX_CONNECTION_AGE_SECS,
177            ),
178            connect_timeout: Duration::from_secs(
179                crate::constants::FTP_POOL_DEFAULT_CONNECT_TIMEOUT_SECS,
180            ),
181            read_timeout: Duration::from_secs(crate::constants::FTP_POOL_DEFAULT_READ_TIMEOUT_SECS),
182        }
183    }
184}
185
186/// Thread-safe FTP connection pool with LRU eviction
187pub struct FtpConnectionPool {
188    /// Connection storage
189    connections: Arc<Mutex<HashMap<ConnectionKey, PooledConnection>>>,
190    /// LRU tracking (ordered by last access time)
191    lru_order: Arc<Mutex<Vec<LruEntry>>>,
192    /// Pool configuration
193    config: PoolConfig,
194    /// Statistics
195    stats: Arc<Mutex<PoolStats>>,
196}
197
198/// Pool statistics for monitoring
199#[derive(Debug, Clone, Default)]
200pub struct PoolStats {
201    /// Total connections created
202    pub connections_created: u64,
203    /// Total connections reused
204    pub connections_reused: u64,
205    /// Total connections evicted
206    pub connections_evicted: u64,
207    /// Total connection failures
208    pub connection_failures: u64,
209    /// Current pool size
210    pub current_size: usize,
211    /// Peak pool size
212    pub peak_size: usize,
213}
214
215impl FtpConnectionPool {
216    /// Create a new connection pool with default configuration
217    pub fn new(max_connections: usize) -> Self {
218        let config = PoolConfig {
219            max_connections,
220            ..Default::default()
221        };
222        Self::with_config(config)
223    }
224
225    /// Create a new connection pool with custom configuration
226    pub fn with_config(config: PoolConfig) -> Self {
227        Self {
228            connections: Arc::new(Mutex::new(HashMap::new())),
229            lru_order: Arc::new(Mutex::new(Vec::new())),
230            config,
231            stats: Arc::new(Mutex::new(PoolStats::default())),
232        }
233    }
234
235    /// Get or create a connection from the pool
236    ///
237    /// This method will:
238    /// 1. Try to find an existing healthy connection
239    /// 2. If found, mark it as used and return it
240    /// 3. If not found, create a new connection
241    /// 4. If pool is full, evict the least recently used connection
242    pub async fn get_connection(
243        &self,
244        host: &str,
245        port: u16,
246        username: &str,
247        password: &str,
248        mode: FtpMode,
249    ) -> Result<PooledConnection> {
250        let key = ConnectionKey::new(host, port, username, password);
251
252        // Try to get existing connection
253        {
254            let mut connections = self.connections.lock().await;
255            if let Some(conn) = connections.get_mut(&key) {
256                // Check if connection is healthy
257                if conn.is_healthy(self.config.max_idle_time) {
258                    conn.mark_used();
259                    self.update_lru_access(&key).await;
260
261                    // Update stats
262                    let mut stats = self.stats.lock().await;
263                    stats.connections_reused += 1;
264
265                    debug!(
266                        "Reusing FTP connection to {}:{} (reuse #{})",
267                        host, port, conn.reuse_count
268                    );
269
270                    // Return a clone for the caller to use
271                    // Note: FtpClient doesn't implement Clone, so we need to remove it
272                    // and return it. The caller will return it back to the pool.
273                    let conn = connections.remove(&key).unwrap();
274                    return Ok(conn);
275                } else {
276                    // Connection is stale, remove it
277                    debug!("Removing stale FTP connection to {}:{}", host, port);
278                    connections.remove(&key);
279                    self.remove_from_lru(&key).await;
280
281                    let mut stats = self.stats.lock().await;
282                    stats.connections_evicted += 1;
283                }
284            }
285        }
286
287        // Need to create a new connection
288        // First, check if we need to evict
289        self.evict_if_needed().await?;
290
291        // Create new connection
292        debug!("Creating new FTP connection to {}:{}", host, port);
293        let client = FtpClient::connect(host, port, mode).await?;
294
295        // Authenticate
296        {
297            let mut client = client;
298            client.login(username, password).await?;
299
300            // Set binary mode for file transfers
301            client.set_binary_mode(true).await?;
302
303            let pooled = PooledConnection::new(client, key.clone(), mode);
304
305            // Add to pool
306            let mut connections = self.connections.lock().await;
307            connections.insert(key.clone(), pooled);
308
309            // Update LRU
310            self.add_to_lru(key.clone()).await;
311
312            // Update stats
313            let mut stats = self.stats.lock().await;
314            stats.connections_created += 1;
315            stats.current_size = connections.len();
316            if connections.len() > stats.peak_size {
317                stats.peak_size = connections.len();
318            }
319
320            info!(
321                "FTP connection pool: created new connection to {}:{}",
322                host, port
323            );
324
325            // Return the connection (remove from pool temporarily)
326            Ok(connections.remove(&key).unwrap())
327        }
328    }
329
330    /// Return a connection to the pool for reuse
331    pub async fn return_connection(&self, mut conn: PooledConnection) {
332        // Check if connection is still healthy before returning
333        if !conn.is_healthy(self.config.max_idle_time) {
334            debug!(
335                "Not returning unhealthy connection to {}:{}",
336                conn.key.host, conn.key.port
337            );
338            let mut stats = self.stats.lock().await;
339            stats.connections_evicted += 1;
340            return;
341        }
342
343        // Check connection age
344        if conn.age() > self.config.max_connection_age {
345            debug!(
346                "Not returning expired connection to {}:{} (age: {:?})",
347                conn.key.host,
348                conn.key.port,
349                conn.age()
350            );
351            let mut stats = self.stats.lock().await;
352            stats.connections_evicted += 1;
353            return;
354        }
355
356        conn.mark_used();
357
358        let mut connections = self.connections.lock().await;
359        let key = conn.key.clone();
360        connections.insert(key.clone(), conn);
361
362        self.update_lru_access(&key).await;
363
364        let mut stats = self.stats.lock().await;
365        stats.current_size = connections.len();
366
367        debug!("Returned FTP connection to pool: {}:{}", key.host, key.port);
368    }
369
370    /// Evict connections if pool is full
371    async fn evict_if_needed(&self) -> Result<()> {
372        let mut connections = self.connections.lock().await;
373
374        while connections.len() >= self.config.max_connections {
375            // Find the least recently used connection
376            let lru_key = self.find_lru_key().await;
377
378            if let Some(key) = lru_key {
379                debug!(
380                    "Evicting LRU connection to {}:{} (pool full)",
381                    key.host, key.port
382                );
383                connections.remove(&key);
384                self.remove_from_lru(&key).await;
385
386                let mut stats = self.stats.lock().await;
387                stats.connections_evicted += 1;
388            } else {
389                break;
390            }
391        }
392
393        Ok(())
394    }
395
396    /// Add a key to the LRU tracking
397    async fn add_to_lru(&self, key: ConnectionKey) {
398        let mut lru = self.lru_order.lock().await;
399        lru.push(LruEntry {
400            key,
401            last_access: Instant::now(),
402        });
403    }
404
405    /// Update LRU access time for a key
406    async fn update_lru_access(&self, key: &ConnectionKey) {
407        let mut lru = self.lru_order.lock().await;
408        if let Some(entry) = lru.iter_mut().find(|e| &e.key == key) {
409            entry.last_access = Instant::now();
410        }
411    }
412
413    /// Remove a key from LRU tracking
414    async fn remove_from_lru(&self, key: &ConnectionKey) {
415        let mut lru = self.lru_order.lock().await;
416        lru.retain(|e| &e.key != key);
417    }
418
419    /// Find the least recently used key
420    async fn find_lru_key(&self) -> Option<ConnectionKey> {
421        let lru = self.lru_order.lock().await;
422        lru.iter()
423            .min_by_key(|e| e.last_access)
424            .map(|e| e.key.clone())
425    }
426
427    /// Clean up stale connections
428    pub async fn cleanup_stale(&self) {
429        let mut connections = self.connections.lock().await;
430        let mut to_remove = Vec::new();
431
432        for (key, conn) in connections.iter() {
433            if !conn.is_healthy(self.config.max_idle_time)
434                || conn.age() > self.config.max_connection_age
435            {
436                to_remove.push(key.clone());
437            }
438        }
439
440        for key in to_remove {
441            connections.remove(&key);
442            self.remove_from_lru(&key).await;
443
444            let mut stats = self.stats.lock().await;
445            stats.connections_evicted += 1;
446        }
447
448        let mut stats = self.stats.lock().await;
449        stats.current_size = connections.len();
450
451        debug!(
452            "FTP connection pool cleanup: {} connections remaining",
453            connections.len()
454        );
455    }
456
457    /// Get pool statistics
458    pub async fn stats(&self) -> PoolStats {
459        self.stats.lock().await.clone()
460    }
461
462    /// Get current pool size
463    pub async fn size(&self) -> usize {
464        self.connections.lock().await.len()
465    }
466
467    /// Clear all connections from the pool
468    pub async fn clear(&self) {
469        let mut connections = self.connections.lock().await;
470        let count = connections.len();
471        connections.clear();
472
473        let mut lru = self.lru_order.lock().await;
474        lru.clear();
475
476        let mut stats = self.stats.lock().await;
477        stats.connections_evicted += count as u64;
478        stats.current_size = 0;
479
480        info!("FTP connection pool cleared: {} connections removed", count);
481    }
482
483    /// Check if the pool has a connection for the given key
484    pub async fn has_connection(&self, host: &str, port: u16, username: &str) -> bool {
485        let connections = self.connections.lock().await;
486        connections
487            .keys()
488            .any(|k| k.host == host && k.port == port && k.username == username)
489    }
490}
491
492/// Create a new FTP connection pool with default configuration.
493///
494/// Use this to create an injectable pool instance instead of relying on a global singleton.
495/// The pool should be created once during engine initialization and passed down via dependency injection.
496pub fn create_pool(max_connections: usize) -> Arc<FtpConnectionPool> {
497    Arc::new(FtpConnectionPool::new(max_connections))
498}
499
500/// Create a custom FTP connection pool with specific configuration.
501pub fn create_custom_pool(config: PoolConfig) -> Arc<FtpConnectionPool> {
502    Arc::new(FtpConnectionPool::with_config(config))
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_connection_key_equality() {
511        let key1 = ConnectionKey::new("example.com", 21, "user", "pass");
512        let key2 = ConnectionKey::new("example.com", 21, "user", "pass");
513        let key3 = ConnectionKey::new("example.com", 21, "user2", "pass");
514
515        assert_eq!(key1, key2);
516        assert_ne!(key1, key3);
517    }
518
519    #[test]
520    fn test_pool_config_default() {
521        let config = PoolConfig::default();
522        assert_eq!(config.max_connections, 16);
523        assert_eq!(config.max_idle_time, Duration::from_secs(300));
524        assert_eq!(config.max_connection_age, Duration::from_secs(1800));
525    }
526
527    #[tokio::test]
528    async fn test_pool_creation() {
529        let pool = FtpConnectionPool::new(10);
530        assert_eq!(pool.size().await, 0);
531    }
532
533    #[tokio::test]
534    async fn test_pool_stats_initial() {
535        let pool = FtpConnectionPool::new(10);
536        let stats = pool.stats().await;
537        assert_eq!(stats.connections_created, 0);
538        assert_eq!(stats.connections_reused, 0);
539        assert_eq!(stats.connections_evicted, 0);
540        assert_eq!(stats.current_size, 0);
541    }
542
543    #[tokio::test]
544    async fn test_pool_clear() {
545        let pool = FtpConnectionPool::new(10);
546        pool.clear().await;
547        assert_eq!(pool.size().await, 0);
548    }
549
550    #[test]
551    fn test_pooled_connection_health() {
552        // Create a mock pooled connection (without actual FTP client)
553        // We can't easily create a real FtpClient in tests, so we test the logic
554        let max_idle_time = Duration::from_secs(300);
555
556        // A connection that was just used should be healthy
557        // (We can't create a real PooledConnection without FtpClient,
558        // but the is_healthy logic is simple: check if idle_time < max_idle_time)
559        let idle_time = Duration::from_secs(10);
560        assert!(idle_time < max_idle_time);
561
562        // A connection idle for too long should be unhealthy
563        let idle_time_long = Duration::from_secs(400);
564        assert!(idle_time_long >= max_idle_time);
565    }
566
567    #[test]
568    fn test_lru_entry_creation() {
569        let key = ConnectionKey::new("example.com", 21, "user", "pass");
570        let entry = LruEntry {
571            key: key.clone(),
572            last_access: Instant::now(),
573        };
574
575        assert_eq!(entry.key, key);
576        assert!(entry.last_access.elapsed() < Duration::from_secs(1));
577    }
578
579    #[tokio::test]
580    async fn test_create_pool_returns_shared_arc() {
581        let pool = create_pool(10);
582        let pool2 = pool.clone();
583
584        // Both Arcs should point to the same pool instance
585        assert!(Arc::ptr_eq(&pool, &pool2));
586    }
587
588    #[tokio::test]
589    async fn test_custom_pool_is_different() {
590        let pool1 = create_pool(10);
591        let pool2 = create_custom_pool(PoolConfig::default());
592
593        // Should be different instances
594        assert!(!Arc::ptr_eq(&pool1, &pool2));
595    }
596
597    #[test]
598    fn test_pool_stats_default() {
599        let stats = PoolStats::default();
600        assert_eq!(stats.connections_created, 0);
601        assert_eq!(stats.connections_reused, 0);
602        assert_eq!(stats.connections_evicted, 0);
603        assert_eq!(stats.connection_failures, 0);
604        assert_eq!(stats.current_size, 0);
605        assert_eq!(stats.peak_size, 0);
606    }
607
608    #[test]
609    fn test_connection_key_hash() {
610        use std::collections::HashSet;
611
612        let mut set = HashSet::new();
613        let key1 = ConnectionKey::new("example.com", 21, "user", "pass");
614        let key2 = ConnectionKey::new("example.com", 21, "user", "pass");
615        let key3 = ConnectionKey::new("other.com", 21, "user", "pass");
616
617        set.insert(key1.clone());
618        assert!(set.contains(&key2)); // Same key
619        assert!(!set.contains(&key3)); // Different key
620    }
621}