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}