Skip to main content

ipfrs_network/
connection_pool.rs

1//! Connection Pool
2//!
3//! Provides per-peer connection pooling with idle eviction, state transitions,
4//! capacity enforcement, and detailed statistics.
5//!
6//! # Overview
7//!
8//! Maintaining a dedicated connection per request is expensive. This module reuses
9//! existing connections from a per-peer pool and evicts idle ones that have
10//! exceeded the configured `idle_timeout`.
11//!
12//! # Example
13//!
14//! ```
15//! use ipfrs_network::connection_pool::{ConnectionPool, PoolConfig};
16//! use std::time::{Duration, Instant};
17//!
18//! let config = PoolConfig {
19//!     max_connections_per_peer: 4,
20//!     max_total_connections: 100,
21//!     idle_timeout: Duration::from_secs(60),
22//!     connect_timeout: Duration::from_secs(10),
23//! };
24//!
25//! let pool = ConnectionPool::new(config);
26//!
27//! // Acquire (or create) a connection slot for "peer-1".
28//! let conn_id = pool.acquire("peer-1").expect("acquire failed");
29//!
30//! // Transition to active once the handshake completes.
31//! pool.mark_active(conn_id).expect("mark_active failed");
32//!
33//! // Return to idle when the request finishes.
34//! pool.mark_idle(conn_id).expect("mark_idle failed");
35//!
36//! // Evict connections that have been idle too long.
37//! let evicted = pool.evict_idle(Instant::now());
38//! println!("Evicted {} idle connections", evicted);
39//! ```
40
41use std::collections::HashMap;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::RwLock;
44use std::time::{Duration, Instant};
45
46use thiserror::Error;
47
48// ---------------------------------------------------------------------------
49// Error type
50// ---------------------------------------------------------------------------
51
52/// Errors returned by [`ConnectionPool`] operations.
53#[derive(Debug, Error)]
54pub enum PoolError {
55    /// The per-peer connection limit has been reached.
56    #[error("peer capacity exceeded for '{peer_id}' (max {max})")]
57    PeerCapacityExceeded {
58        /// Peer identifier.
59        peer_id: String,
60        /// Configured per-peer maximum.
61        max: usize,
62    },
63
64    /// The global connection limit has been reached.
65    #[error("global capacity exceeded (max {max})")]
66    GlobalCapacityExceeded {
67        /// Configured global maximum.
68        max: usize,
69    },
70
71    /// No connection with the given ID exists in the pool.
72    #[error("connection {0} not found in pool")]
73    ConnectionNotFound(u64),
74}
75
76// ---------------------------------------------------------------------------
77// ConnectionState
78// ---------------------------------------------------------------------------
79
80/// Lifecycle state of a single pooled connection.
81#[derive(Debug, Clone)]
82pub enum ConnectionState {
83    /// Connection is idle and available for reuse.
84    Idle {
85        /// Timestamp when the connection became idle.
86        idle_since: Instant,
87    },
88    /// Connection is currently handling a request.
89    Active {
90        /// Timestamp when the active phase started.
91        since: Instant,
92    },
93    /// Connection is being established (handshake in progress).
94    Connecting {
95        /// Timestamp when the connecting phase started.
96        since: Instant,
97    },
98    /// Connection attempt or in-flight operation failed.
99    Failed {
100        /// Human-readable failure reason.
101        reason: String,
102        /// Timestamp when the failure was recorded.
103        at: Instant,
104    },
105}
106
107impl ConnectionState {
108    /// Returns `true` if the connection is in the [`Idle`](ConnectionState::Idle) state.
109    pub fn is_idle(&self) -> bool {
110        matches!(self, ConnectionState::Idle { .. })
111    }
112
113    /// Returns `true` if the connection is in the [`Active`](ConnectionState::Active) state.
114    pub fn is_active(&self) -> bool {
115        matches!(self, ConnectionState::Active { .. })
116    }
117}
118
119// ---------------------------------------------------------------------------
120// PooledConnection
121// ---------------------------------------------------------------------------
122
123/// A single connection entry managed by the pool.
124#[derive(Debug, Clone)]
125pub struct PooledConnection {
126    /// Identifier of the remote peer.
127    pub peer_id: String,
128    /// Pool-assigned unique connection identifier.
129    pub connection_id: u64,
130    /// Current lifecycle state.
131    pub state: ConnectionState,
132    /// Timestamp when the pool slot was created.
133    pub created_at: Instant,
134    /// Cumulative bytes sent over this connection.
135    pub bytes_sent: u64,
136    /// Cumulative bytes received over this connection.
137    pub bytes_received: u64,
138}
139
140impl PooledConnection {
141    /// Returns `true` when the connection is idle and available for reuse.
142    pub fn is_idle(&self) -> bool {
143        self.state.is_idle()
144    }
145
146    /// Returns `true` when the connection is actively handling a request.
147    pub fn is_active(&self) -> bool {
148        self.state.is_active()
149    }
150
151    /// Returns how long the connection has been idle, or `None` if it is not currently idle.
152    pub fn idle_duration(&self, now: Instant) -> Option<Duration> {
153        if let ConnectionState::Idle { idle_since } = self.state {
154            Some(now.duration_since(idle_since))
155        } else {
156            None
157        }
158    }
159}
160
161// ---------------------------------------------------------------------------
162// PoolConfig
163// ---------------------------------------------------------------------------
164
165/// Configuration knobs for [`ConnectionPool`].
166#[derive(Debug, Clone)]
167pub struct PoolConfig {
168    /// Maximum number of connections per remote peer (default: 4).
169    pub max_connections_per_peer: usize,
170    /// Maximum total connections across all peers (default: 100).
171    pub max_total_connections: usize,
172    /// Duration a connection may remain idle before eviction (default: 60 s).
173    pub idle_timeout: Duration,
174    /// Maximum time allowed to establish a new connection (default: 10 s).
175    pub connect_timeout: Duration,
176}
177
178impl Default for PoolConfig {
179    fn default() -> Self {
180        Self {
181            max_connections_per_peer: 4,
182            max_total_connections: 100,
183            idle_timeout: Duration::from_secs(60),
184            connect_timeout: Duration::from_secs(10),
185        }
186    }
187}
188
189// ---------------------------------------------------------------------------
190// PoolStats
191// ---------------------------------------------------------------------------
192
193/// Atomic counters tracking pool-wide activity.
194#[derive(Debug, Default)]
195pub struct PoolStats {
196    /// Total successful `acquire` calls (reuse + new slots).
197    pub total_acquired: AtomicU64,
198    /// Total `release` calls.
199    pub total_released: AtomicU64,
200    /// Total connections removed by [`ConnectionPool::evict_idle`].
201    pub total_evicted: AtomicU64,
202    /// Total connections that transitioned to the `Failed` state.
203    pub total_failed: AtomicU64,
204    /// Total new connection slots created (excludes reuse of idle slots).
205    pub total_created: AtomicU64,
206}
207
208impl PoolStats {
209    /// Take an immutable snapshot of the current counters.
210    pub fn snapshot(&self) -> PoolStatsSnapshot {
211        PoolStatsSnapshot {
212            total_acquired: self.total_acquired.load(Ordering::Relaxed),
213            total_released: self.total_released.load(Ordering::Relaxed),
214            total_evicted: self.total_evicted.load(Ordering::Relaxed),
215            total_failed: self.total_failed.load(Ordering::Relaxed),
216            total_created: self.total_created.load(Ordering::Relaxed),
217        }
218    }
219}
220
221/// Immutable snapshot of [`PoolStats`] suitable for logging and export.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct PoolStatsSnapshot {
224    /// See [`PoolStats::total_acquired`].
225    pub total_acquired: u64,
226    /// See [`PoolStats::total_released`].
227    pub total_released: u64,
228    /// See [`PoolStats::total_evicted`].
229    pub total_evicted: u64,
230    /// See [`PoolStats::total_failed`].
231    pub total_failed: u64,
232    /// See [`PoolStats::total_created`].
233    pub total_created: u64,
234}
235
236// ---------------------------------------------------------------------------
237// ConnectionPool
238// ---------------------------------------------------------------------------
239
240/// Per-peer connection pool with idle eviction and capacity enforcement.
241///
242/// The pool stores connections grouped by `peer_id`.  On [`acquire`](Self::acquire)
243/// it first looks for an existing idle connection; if none is available it
244/// creates a new `Connecting` slot, subject to both per-peer and global limits.
245pub struct ConnectionPool {
246    /// All connections, keyed by peer identifier.
247    connections: RwLock<HashMap<String, Vec<PooledConnection>>>,
248    /// Monotonically increasing connection-ID generator.
249    next_id: AtomicU64,
250    /// Pool configuration.
251    config: PoolConfig,
252    /// Live counters.
253    stats: PoolStats,
254}
255
256impl ConnectionPool {
257    /// Create a new pool with the given configuration.
258    pub fn new(config: PoolConfig) -> Self {
259        Self {
260            connections: RwLock::new(HashMap::new()),
261            next_id: AtomicU64::new(1),
262            config,
263            stats: PoolStats::default(),
264        }
265    }
266
267    /// Create a new pool with default configuration.
268    pub fn with_defaults() -> Self {
269        Self::new(PoolConfig::default())
270    }
271
272    // ------------------------------------------------------------------
273    // Internal helpers
274    // ------------------------------------------------------------------
275
276    /// Count the total number of connections across all peers (read-lock held by caller).
277    fn total_count_locked(map: &HashMap<String, Vec<PooledConnection>>) -> usize {
278        map.values().map(|v| v.len()).sum()
279    }
280
281    /// Find the first idle connection for `peer_id` and transition it to
282    /// `Connecting`, returning its ID.  Returns `None` when no idle slot exists.
283    fn reuse_idle(map: &mut HashMap<String, Vec<PooledConnection>>, peer_id: &str) -> Option<u64> {
284        let conns = map.get_mut(peer_id)?;
285        let slot = conns.iter_mut().find(|c| c.is_idle())?;
286        let id = slot.connection_id;
287        slot.state = ConnectionState::Connecting {
288            since: Instant::now(),
289        };
290        Some(id)
291    }
292
293    // ------------------------------------------------------------------
294    // Public API
295    // ------------------------------------------------------------------
296
297    /// Acquire a connection to `peer_id`.
298    ///
299    /// 1. If an idle connection exists for the peer it is transitioned to
300    ///    `Connecting` and its ID is returned.
301    /// 2. Otherwise a new slot in `Connecting` state is created, provided
302    ///    neither the per-peer limit nor the global limit would be exceeded.
303    ///
304    /// # Errors
305    ///
306    /// - [`PoolError::PeerCapacityExceeded`] when the per-peer limit is reached.
307    /// - [`PoolError::GlobalCapacityExceeded`] when the global limit is reached.
308    pub fn acquire(&self, peer_id: &str) -> Result<u64, PoolError> {
309        let mut map = self
310            .connections
311            .write()
312            .expect("connection pool lock poisoned");
313
314        // Try to reuse an idle slot first.
315        if let Some(id) = Self::reuse_idle(&mut map, peer_id) {
316            self.stats.total_acquired.fetch_add(1, Ordering::Relaxed);
317            return Ok(id);
318        }
319
320        // Check per-peer capacity.
321        let peer_count = map.get(peer_id).map(|v| v.len()).unwrap_or(0);
322        if peer_count >= self.config.max_connections_per_peer {
323            return Err(PoolError::PeerCapacityExceeded {
324                peer_id: peer_id.to_string(),
325                max: self.config.max_connections_per_peer,
326            });
327        }
328
329        // Check global capacity.
330        let total = Self::total_count_locked(&map);
331        if total >= self.config.max_total_connections {
332            return Err(PoolError::GlobalCapacityExceeded {
333                max: self.config.max_total_connections,
334            });
335        }
336
337        // Allocate a new slot.
338        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
339        let conn = PooledConnection {
340            peer_id: peer_id.to_string(),
341            connection_id: id,
342            state: ConnectionState::Connecting {
343                since: Instant::now(),
344            },
345            created_at: Instant::now(),
346            bytes_sent: 0,
347            bytes_received: 0,
348        };
349        map.entry(peer_id.to_string()).or_default().push(conn);
350
351        self.stats.total_acquired.fetch_add(1, Ordering::Relaxed);
352        self.stats.total_created.fetch_add(1, Ordering::Relaxed);
353        Ok(id)
354    }
355
356    /// Transition `connection_id` to [`Active`](ConnectionState::Active).
357    ///
358    /// # Errors
359    ///
360    /// Returns [`PoolError::ConnectionNotFound`] if no connection with the given
361    /// ID exists.
362    pub fn mark_active(&self, connection_id: u64) -> Result<(), PoolError> {
363        let mut map = self
364            .connections
365            .write()
366            .expect("connection pool lock poisoned");
367
368        for conns in map.values_mut() {
369            if let Some(c) = conns.iter_mut().find(|c| c.connection_id == connection_id) {
370                c.state = ConnectionState::Active {
371                    since: Instant::now(),
372                };
373                return Ok(());
374            }
375        }
376        Err(PoolError::ConnectionNotFound(connection_id))
377    }
378
379    /// Transition `connection_id` to [`Idle`](ConnectionState::Idle).
380    ///
381    /// # Errors
382    ///
383    /// Returns [`PoolError::ConnectionNotFound`] if no connection with the given
384    /// ID exists.
385    pub fn mark_idle(&self, connection_id: u64) -> Result<(), PoolError> {
386        let mut map = self
387            .connections
388            .write()
389            .expect("connection pool lock poisoned");
390
391        for conns in map.values_mut() {
392            if let Some(c) = conns.iter_mut().find(|c| c.connection_id == connection_id) {
393                c.state = ConnectionState::Idle {
394                    idle_since: Instant::now(),
395                };
396                return Ok(());
397            }
398        }
399        Err(PoolError::ConnectionNotFound(connection_id))
400    }
401
402    /// Transition `connection_id` to [`Failed`](ConnectionState::Failed) with
403    /// the supplied human-readable `reason`.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`PoolError::ConnectionNotFound`] if no connection with the given
408    /// ID exists.
409    pub fn mark_failed(&self, connection_id: u64, reason: &str) -> Result<(), PoolError> {
410        let mut map = self
411            .connections
412            .write()
413            .expect("connection pool lock poisoned");
414
415        for conns in map.values_mut() {
416            if let Some(c) = conns.iter_mut().find(|c| c.connection_id == connection_id) {
417                c.state = ConnectionState::Failed {
418                    reason: reason.to_string(),
419                    at: Instant::now(),
420                };
421                self.stats.total_failed.fetch_add(1, Ordering::Relaxed);
422                return Ok(());
423            }
424        }
425        Err(PoolError::ConnectionNotFound(connection_id))
426    }
427
428    /// Remove `connection_id` from the pool entirely.
429    ///
430    /// This is a no-op when the connection does not exist.  Empty peer buckets
431    /// are cleaned up automatically.
432    pub fn release(&self, connection_id: u64) {
433        let mut map = self
434            .connections
435            .write()
436            .expect("connection pool lock poisoned");
437
438        let mut removed = false;
439        for conns in map.values_mut() {
440            let before = conns.len();
441            conns.retain(|c| c.connection_id != connection_id);
442            if conns.len() < before {
443                removed = true;
444                break;
445            }
446        }
447
448        // Remove empty peer buckets to keep the map tidy.
449        map.retain(|_, v| !v.is_empty());
450
451        if removed {
452            self.stats.total_released.fetch_add(1, Ordering::Relaxed);
453        }
454    }
455
456    /// Remove all connections that have been idle longer than
457    /// [`PoolConfig::idle_timeout`], measured against `now`.
458    ///
459    /// Returns the number of connections evicted.
460    pub fn evict_idle(&self, now: Instant) -> usize {
461        let mut map = self
462            .connections
463            .write()
464            .expect("connection pool lock poisoned");
465
466        let timeout = self.config.idle_timeout;
467        let mut evicted: usize = 0;
468
469        for conns in map.values_mut() {
470            let before = conns.len();
471            conns.retain(|c| {
472                if let Some(dur) = c.idle_duration(now) {
473                    dur <= timeout
474                } else {
475                    true
476                }
477            });
478            evicted += before - conns.len();
479        }
480
481        map.retain(|_, v| !v.is_empty());
482
483        self.stats
484            .total_evicted
485            .fetch_add(evicted as u64, Ordering::Relaxed);
486        evicted
487    }
488
489    // ------------------------------------------------------------------
490    // Aggregate queries
491    // ------------------------------------------------------------------
492
493    /// Total number of connections currently in the pool (all states).
494    pub fn connection_count(&self) -> usize {
495        let map = self
496            .connections
497            .read()
498            .expect("connection pool lock poisoned");
499        Self::total_count_locked(&map)
500    }
501
502    /// Number of connections in the [`Idle`](ConnectionState::Idle) state.
503    pub fn idle_count(&self) -> usize {
504        let map = self
505            .connections
506            .read()
507            .expect("connection pool lock poisoned");
508        map.values()
509            .flat_map(|v| v.iter())
510            .filter(|c| c.is_idle())
511            .count()
512    }
513
514    /// Number of connections in the [`Active`](ConnectionState::Active) state.
515    pub fn active_count(&self) -> usize {
516        let map = self
517            .connections
518            .read()
519            .expect("connection pool lock poisoned");
520        map.values()
521            .flat_map(|v| v.iter())
522            .filter(|c| c.is_active())
523            .count()
524    }
525
526    /// Return an immutable snapshot of the pool-wide statistics.
527    pub fn stats(&self) -> PoolStatsSnapshot {
528        self.stats.snapshot()
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Tests
534// ---------------------------------------------------------------------------
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use std::thread;
540    use std::time::Duration;
541
542    fn pool_with_limits(per_peer: usize, total: usize) -> ConnectionPool {
543        ConnectionPool::new(PoolConfig {
544            max_connections_per_peer: per_peer,
545            max_total_connections: total,
546            idle_timeout: Duration::from_secs(60),
547            connect_timeout: Duration::from_secs(10),
548        })
549    }
550
551    // -----------------------------------------------------------------------
552    // 1. acquire returns a valid connection_id
553    // -----------------------------------------------------------------------
554    #[test]
555    fn acquire_returns_connection_id() {
556        let pool = pool_with_limits(4, 100);
557        let id = pool.acquire("peer-a").expect("acquire should succeed");
558        assert!(id > 0, "connection_id must be > 0");
559    }
560
561    // -----------------------------------------------------------------------
562    // 2. acquire increments total_acquired stat
563    // -----------------------------------------------------------------------
564    #[test]
565    fn acquire_increments_stats() {
566        let pool = pool_with_limits(4, 100);
567        pool.acquire("peer-a").expect("first acquire");
568        pool.acquire("peer-a").expect("second acquire");
569        let snap = pool.stats();
570        assert_eq!(snap.total_acquired, 2);
571    }
572
573    // -----------------------------------------------------------------------
574    // 3. New slot starts in Connecting state
575    // -----------------------------------------------------------------------
576    #[test]
577    fn new_connection_starts_in_connecting_state() {
578        let pool = pool_with_limits(4, 100);
579        let id = pool.acquire("peer-b").expect("acquire");
580        let map = pool.connections.read().expect("lock");
581        let conn = map["peer-b"]
582            .iter()
583            .find(|c| c.connection_id == id)
584            .expect("connection should be present");
585        assert!(
586            matches!(conn.state, ConnectionState::Connecting { .. }),
587            "expected Connecting state after acquire"
588        );
589    }
590
591    // -----------------------------------------------------------------------
592    // 4. mark_active transitions state
593    // -----------------------------------------------------------------------
594    #[test]
595    fn mark_active_transitions_state() {
596        let pool = pool_with_limits(4, 100);
597        let id = pool.acquire("peer-c").expect("acquire");
598        pool.mark_active(id).expect("mark_active");
599
600        let map = pool.connections.read().expect("lock");
601        let conn = map["peer-c"]
602            .iter()
603            .find(|c| c.connection_id == id)
604            .expect("connection should exist");
605        assert!(conn.is_active(), "state should be Active after mark_active");
606    }
607
608    // -----------------------------------------------------------------------
609    // 5. mark_idle transitions state
610    // -----------------------------------------------------------------------
611    #[test]
612    fn mark_idle_transitions_state() {
613        let pool = pool_with_limits(4, 100);
614        let id = pool.acquire("peer-d").expect("acquire");
615        pool.mark_active(id).expect("mark_active");
616        pool.mark_idle(id).expect("mark_idle");
617
618        let map = pool.connections.read().expect("lock");
619        let conn = map["peer-d"]
620            .iter()
621            .find(|c| c.connection_id == id)
622            .expect("connection should exist");
623        assert!(conn.is_idle(), "state should be Idle after mark_idle");
624    }
625
626    // -----------------------------------------------------------------------
627    // 6. mark_failed records reason
628    // -----------------------------------------------------------------------
629    #[test]
630    fn mark_failed_records_reason() {
631        let pool = pool_with_limits(4, 100);
632        let id = pool.acquire("peer-e").expect("acquire");
633        pool.mark_failed(id, "timeout").expect("mark_failed");
634
635        let map = pool.connections.read().expect("lock");
636        let conn = map["peer-e"]
637            .iter()
638            .find(|c| c.connection_id == id)
639            .expect("connection should exist");
640        match &conn.state {
641            ConnectionState::Failed { reason, .. } => {
642                assert_eq!(reason, "timeout");
643            }
644            other => panic!("expected Failed state, got {:?}", other),
645        }
646    }
647
648    // -----------------------------------------------------------------------
649    // 7. mark_failed increments total_failed stat
650    // -----------------------------------------------------------------------
651    #[test]
652    fn mark_failed_increments_stats() {
653        let pool = pool_with_limits(4, 100);
654        let id = pool.acquire("peer-f").expect("acquire");
655        pool.mark_failed(id, "connection refused")
656            .expect("mark_failed");
657        assert_eq!(pool.stats().total_failed, 1);
658    }
659
660    // -----------------------------------------------------------------------
661    // 8. release removes connection from pool
662    // -----------------------------------------------------------------------
663    #[test]
664    fn release_removes_connection() {
665        let pool = pool_with_limits(4, 100);
666        let id = pool.acquire("peer-g").expect("acquire");
667        assert_eq!(pool.connection_count(), 1);
668        pool.release(id);
669        assert_eq!(pool.connection_count(), 0);
670    }
671
672    // -----------------------------------------------------------------------
673    // 9. release increments total_released stat
674    // -----------------------------------------------------------------------
675    #[test]
676    fn release_increments_stats() {
677        let pool = pool_with_limits(4, 100);
678        let id = pool.acquire("peer-h").expect("acquire");
679        pool.release(id);
680        assert_eq!(pool.stats().total_released, 1);
681    }
682
683    // -----------------------------------------------------------------------
684    // 10. Per-peer capacity enforcement
685    // -----------------------------------------------------------------------
686    #[test]
687    fn per_peer_capacity_enforced() {
688        let pool = pool_with_limits(2, 100);
689        pool.acquire("peer-i").expect("first");
690        pool.acquire("peer-i").expect("second");
691        let err = pool.acquire("peer-i").expect_err("third should fail");
692        assert!(
693            matches!(err, PoolError::PeerCapacityExceeded { max: 2, .. }),
694            "unexpected error: {err}"
695        );
696    }
697
698    // -----------------------------------------------------------------------
699    // 11. Global capacity enforcement
700    // -----------------------------------------------------------------------
701    #[test]
702    fn global_capacity_enforced() {
703        let pool = pool_with_limits(10, 3);
704        pool.acquire("peer-j").expect("slot 1");
705        pool.acquire("peer-k").expect("slot 2");
706        pool.acquire("peer-l").expect("slot 3");
707        let err = pool.acquire("peer-m").expect_err("slot 4 should fail");
708        assert!(
709            matches!(err, PoolError::GlobalCapacityExceeded { max: 3 }),
710            "unexpected error: {err}"
711        );
712    }
713
714    // -----------------------------------------------------------------------
715    // 12. idle connection is reused on acquire (no new slot created)
716    // -----------------------------------------------------------------------
717    #[test]
718    fn idle_connection_is_reused_on_acquire() {
719        let pool = pool_with_limits(4, 100);
720        let id1 = pool.acquire("peer-n").expect("first acquire");
721        pool.mark_active(id1).expect("mark_active");
722        pool.mark_idle(id1).expect("mark_idle");
723
724        // There is now one idle connection.  A second acquire should reuse it.
725        let id2 = pool.acquire("peer-n").expect("second acquire");
726        assert_eq!(id1, id2, "should reuse the existing idle connection");
727
728        // Still only one connection in the pool.
729        assert_eq!(pool.connection_count(), 1);
730
731        let snap = pool.stats();
732        // total_created should be 1 (only the first slot creation), not 2.
733        assert_eq!(snap.total_created, 1);
734        assert_eq!(snap.total_acquired, 2);
735    }
736
737    // -----------------------------------------------------------------------
738    // 13. evict_idle removes timed-out connections
739    // -----------------------------------------------------------------------
740    #[test]
741    fn evict_idle_removes_timed_out_connections() {
742        let pool = ConnectionPool::new(PoolConfig {
743            max_connections_per_peer: 4,
744            max_total_connections: 100,
745            idle_timeout: Duration::from_millis(50),
746            connect_timeout: Duration::from_secs(10),
747        });
748
749        let id = pool.acquire("peer-o").expect("acquire");
750        pool.mark_active(id).expect("mark_active");
751        pool.mark_idle(id).expect("mark_idle");
752
753        // Sleep past the idle_timeout.
754        thread::sleep(Duration::from_millis(100));
755
756        let evicted = pool.evict_idle(Instant::now());
757        assert_eq!(evicted, 1, "one connection should have been evicted");
758        assert_eq!(pool.connection_count(), 0);
759        assert_eq!(pool.stats().total_evicted, 1);
760    }
761
762    // -----------------------------------------------------------------------
763    // 14. evict_idle does NOT remove non-idle connections
764    // -----------------------------------------------------------------------
765    #[test]
766    fn evict_idle_skips_active_connections() {
767        let pool = ConnectionPool::new(PoolConfig {
768            max_connections_per_peer: 4,
769            max_total_connections: 100,
770            idle_timeout: Duration::from_millis(1),
771            connect_timeout: Duration::from_secs(10),
772        });
773
774        let id = pool.acquire("peer-p").expect("acquire");
775        pool.mark_active(id).expect("mark_active");
776
777        // Even after sleeping, active connections should not be evicted.
778        thread::sleep(Duration::from_millis(10));
779        let evicted = pool.evict_idle(Instant::now());
780        assert_eq!(evicted, 0);
781        assert_eq!(pool.connection_count(), 1);
782    }
783
784    // -----------------------------------------------------------------------
785    // 15. idle_count / active_count correctness
786    // -----------------------------------------------------------------------
787    #[test]
788    fn idle_and_active_counts_are_correct() {
789        let pool = pool_with_limits(10, 100);
790
791        let id1 = pool.acquire("peer-q").expect("conn1");
792        let id2 = pool.acquire("peer-q").expect("conn2");
793        let id3 = pool.acquire("peer-q").expect("conn3");
794
795        pool.mark_active(id1).expect("active id1");
796        pool.mark_active(id2).expect("active id2");
797        pool.mark_idle(id2).expect("idle id2");
798        // id3 stays in Connecting state (neither idle nor active).
799
800        assert_eq!(pool.active_count(), 1, "only id1 is active");
801        assert_eq!(pool.idle_count(), 1, "only id2 is idle");
802        assert_eq!(pool.connection_count(), 3);
803
804        // Release id3 and check totals.
805        pool.release(id3);
806        assert_eq!(pool.connection_count(), 2);
807    }
808
809    // -----------------------------------------------------------------------
810    // 16. Unknown connection_id returns ConnectionNotFound
811    // -----------------------------------------------------------------------
812    #[test]
813    fn unknown_connection_id_returns_not_found() {
814        let pool = pool_with_limits(4, 100);
815        assert!(matches!(
816            pool.mark_active(9999),
817            Err(PoolError::ConnectionNotFound(9999))
818        ));
819        assert!(matches!(
820            pool.mark_idle(9999),
821            Err(PoolError::ConnectionNotFound(9999))
822        ));
823        assert!(matches!(
824            pool.mark_failed(9999, "x"),
825            Err(PoolError::ConnectionNotFound(9999))
826        ));
827    }
828
829    // -----------------------------------------------------------------------
830    // 17. Stats accumulation across mixed operations
831    // -----------------------------------------------------------------------
832    #[test]
833    fn stats_accumulate_correctly() {
834        let pool = pool_with_limits(10, 100);
835
836        let id1 = pool.acquire("peer-r").expect("a1");
837        let id2 = pool.acquire("peer-r").expect("a2");
838        pool.mark_active(id1).expect("active1");
839        pool.mark_failed(id2, "refused").expect("failed2");
840        pool.release(id1);
841
842        let snap = pool.stats();
843        assert_eq!(snap.total_acquired, 2);
844        assert_eq!(snap.total_created, 2);
845        assert_eq!(snap.total_failed, 1);
846        assert_eq!(snap.total_released, 1);
847    }
848
849    // -----------------------------------------------------------------------
850    // 18. release on non-existent id is a no-op (no panic)
851    // -----------------------------------------------------------------------
852    #[test]
853    fn release_nonexistent_is_noop() {
854        let pool = pool_with_limits(4, 100);
855        pool.release(42); // should not panic or change released counter
856        assert_eq!(pool.stats().total_released, 0);
857    }
858
859    // -----------------------------------------------------------------------
860    // 19. idle_duration returns None for non-idle states
861    // -----------------------------------------------------------------------
862    #[test]
863    fn idle_duration_returns_none_for_active() {
864        let conn = PooledConnection {
865            peer_id: "peer-s".to_string(),
866            connection_id: 1,
867            state: ConnectionState::Active {
868                since: Instant::now(),
869            },
870            created_at: Instant::now(),
871            bytes_sent: 0,
872            bytes_received: 0,
873        };
874        assert!(conn.idle_duration(Instant::now()).is_none());
875    }
876
877    // -----------------------------------------------------------------------
878    // 20. Connections from different peers are independent
879    // -----------------------------------------------------------------------
880    #[test]
881    fn different_peers_are_independent() {
882        let pool = pool_with_limits(2, 100);
883
884        // Fill peer-t to capacity.
885        pool.acquire("peer-t").expect("t1");
886        pool.acquire("peer-t").expect("t2");
887        let err = pool.acquire("peer-t").expect_err("t3 over limit");
888        assert!(matches!(err, PoolError::PeerCapacityExceeded { .. }));
889
890        // peer-u should still be able to acquire.
891        pool.acquire("peer-u").expect("u1 should succeed");
892    }
893}
894
895// ===========================================================================
896// PeerConnectionPool — tick-based, lock-free, pure-ownership pool
897// ===========================================================================
898
899/// Lifecycle state for a connection managed by [`PeerConnectionPool`].
900///
901/// Named `PoolConnectionState` to avoid collision with the [`ConnectionState`]
902/// used by the async [`ConnectionPool`] above.
903#[derive(Clone, Copy, Debug, PartialEq, Eq)]
904pub enum PoolConnectionState {
905    /// Available for reuse by a caller.
906    Idle,
907    /// Currently acquired by a caller.
908    InUse,
909    /// Marked for teardown; will be removed on the next [`PeerConnectionPool::evict_idle`] call.
910    Closing,
911}
912
913/// A single connection entry managed by [`PeerConnectionPool`].
914#[derive(Debug, Clone)]
915pub struct PeerPooledConnection {
916    /// Unique identifier for this connection within the pool.
917    pub conn_id: u64,
918    /// Remote peer this connection is associated with.
919    pub peer_id: String,
920    /// Current lifecycle state.
921    pub state: PoolConnectionState,
922    /// Logical tick at which this connection was created.
923    pub created_at_tick: u64,
924    /// Logical tick at which this connection was last used (acquired or released).
925    pub last_used_tick: u64,
926    /// Total number of times this connection has been acquired.
927    pub use_count: u64,
928}
929
930/// Configuration for [`PeerConnectionPool`].
931#[derive(Debug, Clone)]
932pub struct PeerPoolConfig {
933    /// Maximum number of connections allowed per peer (default: 4).
934    pub max_per_peer: usize,
935    /// Close idle connections that have not been used for this many ticks (default: 60).
936    pub idle_timeout_ticks: u64,
937    /// Global cap across all peers (default: 100).
938    pub max_total: usize,
939}
940
941impl Default for PeerPoolConfig {
942    fn default() -> Self {
943        Self {
944            max_per_peer: 4,
945            idle_timeout_ticks: 60,
946            max_total: 100,
947        }
948    }
949}
950
951/// A point-in-time snapshot of [`PeerConnectionPool`] statistics.
952#[derive(Debug, Clone, PartialEq, Eq)]
953pub struct PeerPoolStats {
954    /// Total connections currently tracked by the pool.
955    pub total_connections: usize,
956    /// Connections currently in the [`Idle`](PoolConnectionState::Idle) state.
957    pub idle_connections: usize,
958    /// Connections currently in the [`InUse`](PoolConnectionState::InUse) state.
959    pub in_use_connections: usize,
960    /// Number of distinct peers with at least one connection.
961    pub total_peers: usize,
962    /// Cumulative number of successful acquire calls.
963    pub total_acquired: u64,
964    /// Cumulative number of successful release calls.
965    pub total_released: u64,
966    /// Cumulative number of connections removed by eviction.
967    pub total_evicted: u64,
968}
969
970/// Tick-based, single-threaded peer connection pool.
971///
972/// Manages a set of reusable [`PeerPooledConnection`]s keyed by a monotonically
973/// increasing `conn_id`.  Callers drive time by passing an explicit `tick`
974/// counter; the pool does not use wall-clock time.
975///
976/// # Example
977///
978/// ```
979/// use ipfrs_network::connection_pool::{PeerConnectionPool, PeerPoolConfig};
980///
981/// let mut pool = PeerConnectionPool::new(PeerPoolConfig::default());
982/// let tick = 0_u64;
983///
984/// // Acquire (or create) a connection for "peer-1".
985/// let conn_id = pool.acquire("peer-1", tick).expect("should succeed");
986///
987/// // Return the connection to the idle pool.
988/// assert!(pool.release(conn_id, tick + 1));
989///
990/// // Evict connections that have been idle for too long.
991/// pool.evict_idle(tick + 100);
992/// ```
993pub struct PeerConnectionPool {
994    /// All connections, keyed by their unique `conn_id`.
995    pub connections: HashMap<u64, PeerPooledConnection>,
996    /// Monotonically increasing counter for assigning connection IDs.
997    pub next_conn_id: u64,
998    /// Pool configuration.
999    pub config: PeerPoolConfig,
1000    /// Cumulative successful acquires.
1001    pub total_acquired: u64,
1002    /// Cumulative successful releases.
1003    pub total_released: u64,
1004    /// Cumulative evictions.
1005    pub total_evicted: u64,
1006}
1007
1008impl PeerConnectionPool {
1009    /// Create a new, empty pool with the given configuration.
1010    pub fn new(config: PeerPoolConfig) -> Self {
1011        Self {
1012            connections: HashMap::new(),
1013            next_conn_id: 1,
1014            config,
1015            total_acquired: 0,
1016            total_released: 0,
1017            total_evicted: 0,
1018        }
1019    }
1020
1021    // ------------------------------------------------------------------
1022    // Internal helpers
1023    // ------------------------------------------------------------------
1024
1025    /// Count connections belonging to `peer_id`.
1026    fn peer_connection_count(&self, peer_id: &str) -> usize {
1027        self.connections
1028            .values()
1029            .filter(|c| c.peer_id == peer_id)
1030            .count()
1031    }
1032
1033    /// Allocate the next connection ID.
1034    fn alloc_id(&mut self) -> u64 {
1035        let id = self.next_conn_id;
1036        self.next_conn_id += 1;
1037        id
1038    }
1039
1040    // ------------------------------------------------------------------
1041    // Public API
1042    // ------------------------------------------------------------------
1043
1044    /// Acquire a connection for `peer_id` at the given logical `tick`.
1045    ///
1046    /// Returns `Some(conn_id)` on success:
1047    /// - If an [`Idle`](PoolConnectionState::Idle) connection exists for the
1048    ///   peer it is reused (set to [`InUse`](PoolConnectionState::InUse)).
1049    /// - Otherwise a new connection is created if the per-peer and global caps
1050    ///   allow it.
1051    ///
1052    /// Returns `None` when all capacity limits are exhausted.
1053    pub fn acquire(&mut self, peer_id: &str, tick: u64) -> Option<u64> {
1054        // 1. Reuse an existing idle connection for this peer.
1055        let idle_id = self
1056            .connections
1057            .values()
1058            .find(|c| c.peer_id == peer_id && c.state == PoolConnectionState::Idle)
1059            .map(|c| c.conn_id);
1060
1061        if let Some(id) = idle_id {
1062            let conn = self.connections.get_mut(&id)?;
1063            conn.state = PoolConnectionState::InUse;
1064            conn.last_used_tick = tick;
1065            conn.use_count += 1;
1066            self.total_acquired += 1;
1067            return Some(id);
1068        }
1069
1070        // 2. Check capacity before creating a new connection.
1071        let peer_count = self.peer_connection_count(peer_id);
1072        if peer_count >= self.config.max_per_peer {
1073            return None;
1074        }
1075        if self.connections.len() >= self.config.max_total {
1076            return None;
1077        }
1078
1079        // 3. Create a new InUse connection.
1080        let id = self.alloc_id();
1081        let conn = PeerPooledConnection {
1082            conn_id: id,
1083            peer_id: peer_id.to_string(),
1084            state: PoolConnectionState::InUse,
1085            created_at_tick: tick,
1086            last_used_tick: tick,
1087            use_count: 1,
1088        };
1089        self.connections.insert(id, conn);
1090        self.total_acquired += 1;
1091        Some(id)
1092    }
1093
1094    /// Return a connection to the idle pool.
1095    ///
1096    /// Returns `true` on success, `false` if the connection was not found or
1097    /// was not in the [`InUse`](PoolConnectionState::InUse) state.
1098    pub fn release(&mut self, conn_id: u64, tick: u64) -> bool {
1099        match self.connections.get_mut(&conn_id) {
1100            Some(conn) if conn.state == PoolConnectionState::InUse => {
1101                conn.state = PoolConnectionState::Idle;
1102                conn.last_used_tick = tick;
1103                self.total_released += 1;
1104                true
1105            }
1106            _ => false,
1107        }
1108    }
1109
1110    /// Mark a connection as [`Closing`](PoolConnectionState::Closing).
1111    ///
1112    /// Returns `false` if no connection with the given ID exists.
1113    pub fn close(&mut self, conn_id: u64) -> bool {
1114        match self.connections.get_mut(&conn_id) {
1115            Some(conn) => {
1116                conn.state = PoolConnectionState::Closing;
1117                true
1118            }
1119            None => false,
1120        }
1121    }
1122
1123    /// Evict connections that are stale or marked for teardown.
1124    ///
1125    /// Removes:
1126    /// - [`Idle`](PoolConnectionState::Idle) connections whose
1127    ///   `last_used_tick` is at least `idle_timeout_ticks` ticks in the past.
1128    /// - All [`Closing`](PoolConnectionState::Closing) connections.
1129    ///
1130    /// [`total_evicted`](Self::total_evicted) is incremented for each removed
1131    /// connection.
1132    pub fn evict_idle(&mut self, tick: u64) {
1133        let timeout = self.config.idle_timeout_ticks;
1134        let mut evicted: u64 = 0;
1135
1136        self.connections.retain(|_, conn| {
1137            let should_remove = match conn.state {
1138                PoolConnectionState::Idle => tick.saturating_sub(conn.last_used_tick) >= timeout,
1139                PoolConnectionState::Closing => true,
1140                PoolConnectionState::InUse => false,
1141            };
1142            if should_remove {
1143                evicted += 1;
1144            }
1145            !should_remove
1146        });
1147
1148        self.total_evicted += evicted;
1149    }
1150
1151    /// Return all connections associated with `peer_id`, sorted ascending by
1152    /// `conn_id`.
1153    pub fn connections_for_peer<'a>(&'a self, peer_id: &str) -> Vec<&'a PeerPooledConnection> {
1154        let mut result: Vec<&PeerPooledConnection> = self
1155            .connections
1156            .values()
1157            .filter(|c| c.peer_id == peer_id)
1158            .collect();
1159        result.sort_by_key(|c| c.conn_id);
1160        result
1161    }
1162
1163    /// Return a point-in-time snapshot of pool statistics.
1164    pub fn stats(&self) -> PeerPoolStats {
1165        let total_connections = self.connections.len();
1166        let idle_connections = self
1167            .connections
1168            .values()
1169            .filter(|c| c.state == PoolConnectionState::Idle)
1170            .count();
1171        let in_use_connections = self
1172            .connections
1173            .values()
1174            .filter(|c| c.state == PoolConnectionState::InUse)
1175            .count();
1176
1177        // Count distinct peers that have at least one connection.
1178        let mut peer_set = std::collections::HashSet::new();
1179        for conn in self.connections.values() {
1180            peer_set.insert(conn.peer_id.as_str());
1181        }
1182        let total_peers = peer_set.len();
1183
1184        PeerPoolStats {
1185            total_connections,
1186            idle_connections,
1187            in_use_connections,
1188            total_peers,
1189            total_acquired: self.total_acquired,
1190            total_released: self.total_released,
1191            total_evicted: self.total_evicted,
1192        }
1193    }
1194}
1195
1196// ===========================================================================
1197// PeerConnectionPool tests
1198// ===========================================================================
1199
1200#[cfg(test)]
1201mod peer_pool_tests {
1202    use super::{PeerConnectionPool, PeerPoolConfig, PoolConnectionState};
1203
1204    fn default_pool() -> PeerConnectionPool {
1205        PeerConnectionPool::new(PeerPoolConfig::default())
1206    }
1207
1208    fn small_pool(max_per_peer: usize, max_total: usize) -> PeerConnectionPool {
1209        PeerConnectionPool::new(PeerPoolConfig {
1210            max_per_peer,
1211            idle_timeout_ticks: 60,
1212            max_total,
1213        })
1214    }
1215
1216    // -----------------------------------------------------------------------
1217    // 1. new() starts empty
1218    // -----------------------------------------------------------------------
1219    #[test]
1220    fn new_starts_empty() {
1221        let pool = default_pool();
1222        let stats = pool.stats();
1223        assert_eq!(stats.total_connections, 0);
1224        assert_eq!(stats.idle_connections, 0);
1225        assert_eq!(stats.in_use_connections, 0);
1226        assert_eq!(stats.total_peers, 0);
1227        assert_eq!(stats.total_acquired, 0);
1228        assert_eq!(stats.total_released, 0);
1229        assert_eq!(stats.total_evicted, 0);
1230    }
1231
1232    // -----------------------------------------------------------------------
1233    // 2. acquire creates new connection for a new peer
1234    // -----------------------------------------------------------------------
1235    #[test]
1236    fn acquire_creates_new_connection_for_new_peer() {
1237        let mut pool = default_pool();
1238        let id = pool.acquire("peer-a", 0);
1239        assert!(id.is_some());
1240        assert_eq!(pool.connections.len(), 1);
1241    }
1242
1243    // -----------------------------------------------------------------------
1244    // 3. acquire reuses idle connection
1245    // -----------------------------------------------------------------------
1246    #[test]
1247    fn acquire_reuses_idle_connection() {
1248        let mut pool = default_pool();
1249        let id1 = pool.acquire("peer-b", 0).expect("first acquire");
1250        pool.release(id1, 1);
1251        let id2 = pool.acquire("peer-b", 2).expect("second acquire");
1252        // Should reuse the same slot, not create a new one.
1253        assert_eq!(id1, id2);
1254        assert_eq!(pool.connections.len(), 1);
1255    }
1256
1257    // -----------------------------------------------------------------------
1258    // 4. acquire returns None when peer is at max_per_peer
1259    // -----------------------------------------------------------------------
1260    #[test]
1261    fn acquire_returns_none_at_max_per_peer() {
1262        let mut pool = small_pool(2, 100);
1263        let _a = pool.acquire("peer-c", 0).expect("slot 1");
1264        let _b = pool.acquire("peer-c", 0).expect("slot 2");
1265        let result = pool.acquire("peer-c", 0);
1266        assert!(result.is_none(), "should be None when at per-peer cap");
1267    }
1268
1269    // -----------------------------------------------------------------------
1270    // 5. acquire returns None when total is at max_total
1271    // -----------------------------------------------------------------------
1272    #[test]
1273    fn acquire_returns_none_at_max_total() {
1274        let mut pool = small_pool(10, 2);
1275        pool.acquire("peer-d", 0).expect("slot 1");
1276        pool.acquire("peer-e", 0).expect("slot 2");
1277        let result = pool.acquire("peer-f", 0);
1278        assert!(result.is_none(), "should be None when at global cap");
1279    }
1280
1281    // -----------------------------------------------------------------------
1282    // 6. acquire sets InUse state
1283    // -----------------------------------------------------------------------
1284    #[test]
1285    fn acquire_sets_in_use_state() {
1286        let mut pool = default_pool();
1287        let id = pool.acquire("peer-g", 0).expect("acquire");
1288        let conn = pool.connections.get(&id).expect("conn exists");
1289        assert_eq!(conn.state, PoolConnectionState::InUse);
1290    }
1291
1292    // -----------------------------------------------------------------------
1293    // 7. acquire increments use_count
1294    // -----------------------------------------------------------------------
1295    #[test]
1296    fn acquire_increments_use_count() {
1297        let mut pool = default_pool();
1298        let id = pool.acquire("peer-h", 0).expect("first acquire");
1299        pool.release(id, 1);
1300        pool.acquire("peer-h", 2).expect("second acquire");
1301        let conn = pool.connections.get(&id).expect("conn exists");
1302        assert_eq!(conn.use_count, 2);
1303    }
1304
1305    // -----------------------------------------------------------------------
1306    // 8. release sets Idle state
1307    // -----------------------------------------------------------------------
1308    #[test]
1309    fn release_sets_idle_state() {
1310        let mut pool = default_pool();
1311        let id = pool.acquire("peer-i", 0).expect("acquire");
1312        assert!(pool.release(id, 1));
1313        let conn = pool.connections.get(&id).expect("conn exists");
1314        assert_eq!(conn.state, PoolConnectionState::Idle);
1315    }
1316
1317    // -----------------------------------------------------------------------
1318    // 9. release updates last_used_tick
1319    // -----------------------------------------------------------------------
1320    #[test]
1321    fn release_updates_last_used_tick() {
1322        let mut pool = default_pool();
1323        let id = pool.acquire("peer-j", 0).expect("acquire");
1324        assert!(pool.release(id, 42));
1325        let conn = pool.connections.get(&id).expect("conn exists");
1326        assert_eq!(conn.last_used_tick, 42);
1327    }
1328
1329    // -----------------------------------------------------------------------
1330    // 10. release returns false if not found
1331    // -----------------------------------------------------------------------
1332    #[test]
1333    fn release_false_if_not_found() {
1334        let mut pool = default_pool();
1335        assert!(!pool.release(9999, 0));
1336    }
1337
1338    // -----------------------------------------------------------------------
1339    // 11. release returns false if already Idle
1340    // -----------------------------------------------------------------------
1341    #[test]
1342    fn release_false_if_already_idle() {
1343        let mut pool = default_pool();
1344        let id = pool.acquire("peer-k", 0).expect("acquire");
1345        assert!(pool.release(id, 1)); // first release succeeds
1346        assert!(!pool.release(id, 2)); // second release on Idle fails
1347    }
1348
1349    // -----------------------------------------------------------------------
1350    // 12. close sets Closing state
1351    // -----------------------------------------------------------------------
1352    #[test]
1353    fn close_sets_closing_state() {
1354        let mut pool = default_pool();
1355        let id = pool.acquire("peer-l", 0).expect("acquire");
1356        assert!(pool.close(id));
1357        let conn = pool.connections.get(&id).expect("conn exists");
1358        assert_eq!(conn.state, PoolConnectionState::Closing);
1359    }
1360
1361    // -----------------------------------------------------------------------
1362    // 13. close returns false if not found
1363    // -----------------------------------------------------------------------
1364    #[test]
1365    fn close_false_if_not_found() {
1366        let mut pool = default_pool();
1367        assert!(!pool.close(9999));
1368    }
1369
1370    // -----------------------------------------------------------------------
1371    // 14. evict_idle removes old idle connections
1372    // -----------------------------------------------------------------------
1373    #[test]
1374    fn evict_idle_removes_old_idle() {
1375        let mut pool = PeerConnectionPool::new(PeerPoolConfig {
1376            max_per_peer: 4,
1377            idle_timeout_ticks: 10,
1378            max_total: 100,
1379        });
1380        let id = pool.acquire("peer-m", 0).expect("acquire");
1381        pool.release(id, 0);
1382        // Advance tick beyond idle_timeout_ticks.
1383        pool.evict_idle(11);
1384        assert!(pool.connections.is_empty());
1385    }
1386
1387    // -----------------------------------------------------------------------
1388    // 15. evict_idle keeps recent idle connections
1389    // -----------------------------------------------------------------------
1390    #[test]
1391    fn evict_idle_keeps_recent_idle() {
1392        let mut pool = PeerConnectionPool::new(PeerPoolConfig {
1393            max_per_peer: 4,
1394            idle_timeout_ticks: 10,
1395            max_total: 100,
1396        });
1397        let id = pool.acquire("peer-n", 0).expect("acquire");
1398        pool.release(id, 5);
1399        // Only 4 ticks elapsed — below the threshold.
1400        pool.evict_idle(9);
1401        assert_eq!(pool.connections.len(), 1);
1402    }
1403
1404    // -----------------------------------------------------------------------
1405    // 16. evict_idle removes Closing connections
1406    // -----------------------------------------------------------------------
1407    #[test]
1408    fn evict_idle_removes_closing_connections() {
1409        let mut pool = default_pool();
1410        let id = pool.acquire("peer-o", 0).expect("acquire");
1411        pool.close(id);
1412        pool.evict_idle(0); // tick does not matter for Closing
1413        assert!(pool.connections.is_empty());
1414    }
1415
1416    // -----------------------------------------------------------------------
1417    // 17. evict_idle increments total_evicted
1418    // -----------------------------------------------------------------------
1419    #[test]
1420    fn evict_idle_increments_total_evicted() {
1421        let mut pool = PeerConnectionPool::new(PeerPoolConfig {
1422            max_per_peer: 4,
1423            idle_timeout_ticks: 10,
1424            max_total: 100,
1425        });
1426        let a = pool.acquire("peer-p", 0).expect("a");
1427        let b = pool.acquire("peer-p", 0).expect("b");
1428        pool.release(a, 0);
1429        pool.release(b, 0);
1430        pool.evict_idle(100);
1431        assert_eq!(pool.total_evicted, 2);
1432    }
1433
1434    // -----------------------------------------------------------------------
1435    // 18. connections_for_peer filters correctly
1436    // -----------------------------------------------------------------------
1437    #[test]
1438    fn connections_for_peer_filters_correctly() {
1439        let mut pool = default_pool();
1440        pool.acquire("peer-q", 0).expect("q1");
1441        pool.acquire("peer-q", 0).expect("q2");
1442        pool.acquire("peer-r", 0).expect("r1");
1443
1444        let q_conns = pool.connections_for_peer("peer-q");
1445        assert_eq!(q_conns.len(), 2);
1446        for c in &q_conns {
1447            assert_eq!(c.peer_id, "peer-q");
1448        }
1449    }
1450
1451    // -----------------------------------------------------------------------
1452    // 19. connections_for_peer sorted by conn_id
1453    // -----------------------------------------------------------------------
1454    #[test]
1455    fn connections_for_peer_sorted_by_conn_id() {
1456        let mut pool = default_pool();
1457        pool.acquire("peer-s", 0).expect("s1");
1458        pool.acquire("peer-t", 0).expect("t1"); // interleave other peer
1459        pool.acquire("peer-s", 0).expect("s2");
1460
1461        let s_conns = pool.connections_for_peer("peer-s");
1462        assert_eq!(s_conns.len(), 2);
1463        assert!(s_conns[0].conn_id < s_conns[1].conn_id);
1464    }
1465
1466    // -----------------------------------------------------------------------
1467    // 20. stats total_connections correct
1468    // -----------------------------------------------------------------------
1469    #[test]
1470    fn stats_total_connections_correct() {
1471        let mut pool = default_pool();
1472        pool.acquire("peer-u", 0).expect("u1");
1473        pool.acquire("peer-u", 0).expect("u2");
1474        pool.acquire("peer-v", 0).expect("v1");
1475        assert_eq!(pool.stats().total_connections, 3);
1476    }
1477
1478    // -----------------------------------------------------------------------
1479    // 21. stats idle/in_use counts
1480    // -----------------------------------------------------------------------
1481    #[test]
1482    fn stats_idle_in_use_counts() {
1483        let mut pool = default_pool();
1484        let id1 = pool.acquire("peer-w", 0).expect("w1");
1485        let _id2 = pool.acquire("peer-w", 0).expect("w2");
1486        pool.release(id1, 1);
1487
1488        let s = pool.stats();
1489        assert_eq!(s.idle_connections, 1);
1490        assert_eq!(s.in_use_connections, 1);
1491        assert_eq!(s.total_connections, 2);
1492        assert_eq!(s.total_peers, 1);
1493    }
1494
1495    // -----------------------------------------------------------------------
1496    // 22. stats total_acquired/released/evicted
1497    // -----------------------------------------------------------------------
1498    #[test]
1499    fn stats_total_acquired_released_evicted() {
1500        let mut pool = PeerConnectionPool::new(PeerPoolConfig {
1501            max_per_peer: 4,
1502            idle_timeout_ticks: 5,
1503            max_total: 100,
1504        });
1505
1506        let a = pool.acquire("peer-x", 0).expect("a");
1507        let b = pool.acquire("peer-x", 0).expect("b");
1508        pool.release(a, 1);
1509        pool.release(b, 1);
1510        pool.evict_idle(10);
1511
1512        let s = pool.stats();
1513        assert_eq!(s.total_acquired, 2);
1514        assert_eq!(s.total_released, 2);
1515        assert_eq!(s.total_evicted, 2);
1516    }
1517
1518    // -----------------------------------------------------------------------
1519    // Bonus: idle boundary — exactly at timeout tick is evicted
1520    // -----------------------------------------------------------------------
1521    #[test]
1522    fn evict_idle_boundary_exactly_at_timeout() {
1523        let mut pool = PeerConnectionPool::new(PeerPoolConfig {
1524            max_per_peer: 4,
1525            idle_timeout_ticks: 10,
1526            max_total: 100,
1527        });
1528        let id = pool.acquire("peer-y", 0).expect("acquire");
1529        pool.release(id, 0);
1530        // tick - last_used = 10 == idle_timeout_ticks → should evict.
1531        pool.evict_idle(10);
1532        assert!(pool.connections.is_empty());
1533    }
1534
1535    // -----------------------------------------------------------------------
1536    // Bonus: InUse connections are never evicted
1537    // -----------------------------------------------------------------------
1538    #[test]
1539    fn evict_idle_does_not_remove_in_use() {
1540        let mut pool = PeerConnectionPool::new(PeerPoolConfig {
1541            max_per_peer: 4,
1542            idle_timeout_ticks: 1,
1543            max_total: 100,
1544        });
1545        pool.acquire("peer-z", 0).expect("acquire"); // left InUse
1546        pool.evict_idle(1000);
1547        assert_eq!(pool.connections.len(), 1);
1548    }
1549}