Skip to main content

pingora_pool/
connection.rs

1// Copyright 2026 Cloudflare, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Generic connection pooling
16
17use dashmap::DashMap;
18use log::{debug, warn};
19use parking_lot::Mutex;
20use pingora_timeout::{sleep, timeout};
21use std::collections::HashMap;
22use std::io;
23use std::sync::Arc;
24use std::time::Duration;
25use tokio::io::{AsyncRead, AsyncReadExt};
26use tokio::sync::{oneshot, watch, Notify, OwnedMutexGuard};
27
28use super::lru::Lru;
29use futures::FutureExt;
30
31type GroupKey = u64;
32#[cfg(unix)]
33type ID = i32;
34#[cfg(windows)]
35type ID = usize;
36
37/// the metadata of a connection
38#[derive(Clone, Debug)]
39pub struct ConnectionMeta {
40    /// The group key. All connections under the same key are considered the same for connection reuse.
41    pub key: GroupKey,
42    /// The unique ID of a connection.
43    pub id: ID,
44}
45
46impl ConnectionMeta {
47    /// Create a new [ConnectionMeta]
48    pub fn new(key: GroupKey, id: ID) -> Self {
49        ConnectionMeta { key, id }
50    }
51}
52
53struct PoolConnection<S> {
54    pub notify_use: oneshot::Sender<bool>,
55    pub connection: S,
56}
57
58impl<S> PoolConnection<S> {
59    pub fn new(notify_use: oneshot::Sender<bool>, connection: S) -> Self {
60        PoolConnection {
61            notify_use,
62            connection,
63        }
64    }
65
66    pub fn release(self) -> S {
67        // notify the idle watcher to release the connection
68        let _ = self.notify_use.send(true);
69        // wait for the watcher to release
70        self.connection
71    }
72}
73
74use crossbeam_queue::ArrayQueue;
75
76/// A pool of exchangeable items
77pub struct PoolNode<T> {
78    connections: Mutex<HashMap<ID, T>>,
79    // a small lock free queue to avoid lock contention
80    hot_queue: ArrayQueue<(ID, T)>,
81    // to avoid race between 2 evictions on the queue
82    hot_queue_remove_lock: Mutex<()>,
83    // TODO: store the GroupKey to avoid hash collision?
84}
85
86// Keep the queue size small because eviction is O(n) in the queue
87const HOT_QUEUE_SIZE: usize = 16;
88
89impl<T> PoolNode<T> {
90    /// Create a new [PoolNode]
91    pub fn new() -> Self {
92        PoolNode {
93            connections: Mutex::new(HashMap::new()),
94            hot_queue: ArrayQueue::new(HOT_QUEUE_SIZE),
95            hot_queue_remove_lock: Mutex::new(()),
96        }
97    }
98
99    /// Get any item from the pool
100    pub fn get_any(&self) -> Option<(ID, T)> {
101        let hot_conn = self.hot_queue.pop();
102        if hot_conn.is_some() {
103            return hot_conn;
104        }
105        let mut connections = self.connections.lock();
106        // find one connection, any connection will do
107        let id = match connections.iter().next() {
108            Some((k, _)) => *k, // OK to copy i32
109            None => return None,
110        };
111        // unwrap is safe since we just found it
112        let connection = connections.remove(&id).unwrap();
113        /* NOTE: we don't resize or drop empty connections hashmap
114         * We may want to do it if they consume too much memory
115         * maybe we should use trees to save memory */
116        Some((id, connection))
117        // connections.lock released here
118    }
119
120    /// Insert an item with the given unique ID into the pool
121    pub fn insert(&self, id: ID, conn: T) {
122        if let Err(node) = self.hot_queue.push((id, conn)) {
123            // hot queue is full
124            let mut connections = self.connections.lock();
125            connections.insert(node.0, node.1); // TODO: check dup
126        }
127    }
128
129    /// Returns `true` if the pool node contains no connections in either the hot queue
130    /// or the overflow hash map.
131    ///
132    /// # Concurrency note
133    ///
134    /// This check is not atomic across the two internal stores (`hot_queue` and
135    /// `connections`). Between checking one and the other, a concurrent `insert` or
136    /// `get_any` could change the state. This is acceptable because callers use
137    /// `is_empty` only as a hint to attempt cleanup, and always re-verify under
138    /// an exclusive (write) lock before actually removing the node from the parent
139    /// pool HashMap. A false-negative simply defers cleanup to the next opportunity;
140    /// a false-positive is largely mitigated by the re-check (see
141    /// [`ConnectionPool::try_remove_empty_node`] for residual race-window analysis).
142    pub fn is_empty(&self) -> bool {
143        // Check the lock-free queue first (cheap atomic load) to avoid acquiring
144        // the mutex in the common case where connections are present.
145        self.hot_queue.is_empty() && self.connections.lock().is_empty()
146    }
147
148    // This function acquires 2 locks and iterates over the entire hot queue.
149    // But it should be fine because remove() rarely happens on a busy PoolNode.
150    /// Remove the item associated with the id from the pool. The item is returned
151    /// if it is found and removed.
152    pub fn remove(&self, id: ID) -> Option<T> {
153        // check the table first as least recent used ones are likely there
154        let removed = self.connections.lock().remove(&id);
155        if removed.is_some() {
156            return removed;
157        } // lock drops here
158
159        let _queue_lock = self.hot_queue_remove_lock.lock();
160        // check the hot queue, note that the queue can be accessed in parallel by insert and get
161        let max_len = self.hot_queue.len();
162        for _ in 0..max_len {
163            if let Some((conn_id, conn)) = self.hot_queue.pop() {
164                if conn_id == id {
165                    // this is the item, it is already popped
166                    return Some(conn);
167                } else {
168                    // not this item, put back to hot queue, but it could also be full
169                    self.insert(conn_id, conn);
170                }
171            } else {
172                // other threads grab all the connections
173                return None;
174            }
175        }
176        None
177        // _queue_lock drops here
178    }
179}
180
181type Pool<S> = PoolNode<PoolConnection<S>>;
182
183/// Connection pool
184///
185/// [ConnectionPool] holds reusable connections. A reusable connection is released to this pool to
186/// be picked up by another user/request.
187pub struct ConnectionPool<S> {
188    // Concurrent per-key pool index; each value handles per-key connection storage.
189    pools: DashMap<GroupKey, Arc<Pool<S>>>,
190    lru: Lru<ID, ConnectionMeta>,
191}
192
193impl<S> ConnectionPool<S> {
194    /// Create a new [ConnectionPool] with a global size limit.
195    ///
196    /// When a connection is released to this pool and total occupancy is at
197    /// or above `size`, the least recently used connection is dropped.
198    pub fn new(size: usize) -> Self {
199        ConnectionPool {
200            pools: DashMap::with_capacity(size),
201            lru: Lru::new(size),
202        }
203    }
204
205    /// Insert a connection under `key` while the DashMap entry guard is held.
206    ///
207    /// Holding the guard through [`PoolNode::insert`] prevents empty-node cleanup
208    /// from removing the map entry between looking it up and repopulating it.
209    fn insert_pool_connection(&self, key: GroupKey, id: ID, connection: PoolConnection<S>) {
210        let pool_node = self
211            .pools
212            .entry(key)
213            .or_insert_with(|| Arc::new(PoolNode::new()));
214        pool_node.insert(id, connection);
215    }
216
217    /// Attempt to remove an empty [`PoolNode`] entry from the pool `HashMap`.
218    ///
219    /// This prevents unbounded growth of the pool map when many unique group keys
220    /// are seen over the lifetime of the pool (e.g. connecting to many distinct
221    /// upstreams). Without this cleanup, each unique `GroupKey` leaves an
222    /// empty `PoolNode` behind even after all its connections are gone.
223    ///
224    /// The method acquires the pool write lock and re-checks emptiness to avoid
225    /// removing a node that was concurrently repopulated between the caller's
226    /// initial `is_empty()` hint and this write-lock acquisition.
227    ///
228    /// Insertions go through [`Self::insert_pool_connection`], which holds the
229    /// DashMap entry guard until the connection is in the node. That prevents
230    /// this cleanup from removing a node between an inserter's entry lookup and
231    /// its [`PoolNode::insert`] call.
232    fn try_remove_empty_node(&self, key: GroupKey) {
233        if let Some(node) = self.pools.get(&key) {
234            if node.is_empty() {
235                // Release the DashMap read guard before remove_if() acquires
236                // mutable access to the same shard. Re-check emptiness in the
237                // predicate because another thread may repopulate the node in
238                // between dropping this guard and attempting removal.
239                drop(node);
240                self.pools.remove_if(&key, |_, node| node.is_empty());
241            }
242        }
243    }
244
245    // only remove from the pool because lru already removed it
246    fn pop_evicted(&self, meta: &ConnectionMeta) {
247        let pool_node = match self.pools.get(&meta.key) {
248            Some(v) => v.value().clone(),
249            None => {
250                warn!("Fail to get pool node for {meta:?}");
251                return;
252            } // nothing to pop, should return error?
253        };
254
255        pool_node.remove(meta.id);
256        debug!("evict fd: {} from key {}", meta.id, meta.key);
257
258        // Clean up the PoolNode entry if it is now empty, to prevent unbounded
259        // growth of the pool HashMap.
260        // The is_empty() check avoids acquiring the write lock in the common case
261        // where other connections still exist under this key.
262        if pool_node.is_empty() {
263            self.try_remove_empty_node(meta.key);
264        }
265    }
266
267    pub fn pop_closed(&self, meta: &ConnectionMeta) {
268        // NOTE: which of these should be done first?
269        self.pop_evicted(meta);
270        self.lru.pop(&meta.id);
271    }
272
273    /// Get a connection from this pool under the same group key
274    pub fn get(&self, key: &GroupKey) -> Option<S> {
275        let pool_node = self.pools.get(key)?.value().clone();
276
277        if let Some((id, connection)) = pool_node.get_any() {
278            self.lru.pop(&id); // the notified is not needed
279
280            // Clean up the now-empty node. This path is important because when a
281            // connection is retrieved (not evicted), the idle_poll/idle_timeout
282            // tasks exit via the watch_use channel and never call pop_closed(),
283            // so pop_evicted's cleanup would never run for this key.
284            if pool_node.is_empty() {
285                self.try_remove_empty_node(*key);
286            }
287
288            Some(connection.release())
289        } else {
290            // The node exists but has no connections. Clean it up.
291            self.try_remove_empty_node(*key);
292            None
293        }
294    }
295
296    /// Release a connection to this pool for reuse
297    ///
298    /// - The returned [`Arc<Notify>`] will notify any listener when the connection is evicted from the pool.
299    /// - The returned [`oneshot::Receiver<bool>`] will notify when the connection is being picked up by [Self::get()].
300    pub fn put(
301        &self,
302        meta: &ConnectionMeta,
303        connection: S,
304    ) -> (Arc<Notify>, oneshot::Receiver<bool>) {
305        let (notify_close, evicted) = self.lru.add(meta.id, meta.clone());
306        for meta in &evicted {
307            self.pop_evicted(meta);
308        }
309        let (notify_use, watch_use) = oneshot::channel();
310        let connection = PoolConnection::new(notify_use, connection);
311        self.insert_pool_connection(meta.key, meta.id, connection);
312        (notify_close, watch_use)
313    }
314
315    /// Actively monitor the health of a connection that is already released to this pool
316    ///
317    /// When the connection breaks, or the optional `timeout` is reached this function will
318    /// remove it from the pool and drop the connection.
319    ///
320    /// If the connection is reused via [Self::get()] or being evicted, this function will just exit.
321    ///
322    /// Returns `true` if the connection was evicted from the pool, and `false` otherwise.
323    pub async fn idle_poll<Stream>(
324        &self,
325        connection: OwnedMutexGuard<Stream>,
326        meta: &ConnectionMeta,
327        timeout: Option<Duration>,
328        notify_evicted: Arc<Notify>,
329        watch_use: oneshot::Receiver<bool>,
330    ) -> bool
331    where
332        Stream: AsyncRead + Unpin + Send,
333    {
334        // Reuse this same Notified future in the watch_use branch: notify_one()
335        // may deliver the wakeup to an already-polled future, so creating a new
336        // notified() future after watch_use resolves could miss the eviction.
337        let evicted = notify_evicted.notified();
338        tokio::pin!(evicted);
339
340        let read_result = tokio::select! {
341            biased;
342            event = watch_use => {
343                return match event {
344                    Ok(_) => {
345                        debug!("idle connection is being picked up");
346                        false
347                    }
348                    // `watch_use` also resolves when the sender is dropped.
349                    // During LRU eviction, pop_evicted() removes the
350                    // PoolConnection, dropping the sender after notify_evicted
351                    // has been signaled. Keep this biased branch first for the
352                    // common reuse path, but confirm the eviction signal before
353                    // classifying sender drop as eviction.
354                    Err(_) => evicted.now_or_never().is_some(),
355                };
356            },
357            _ = &mut evicted => {
358                debug!("idle connection is being evicted");
359                // TODO: gracefully close the connection?
360                return true
361            },
362            read_result = read_with_timeout(connection , timeout) => read_result
363        };
364
365        match read_result {
366            Ok(n) => {
367                if n > 0 {
368                    warn!("Data received on idle client connection, close it");
369                } else {
370                    debug!("Peer closed the idle connection or timeout");
371                }
372            }
373
374            Err(e) => {
375                debug!("error with the idle connection, close it {:?}", e);
376            }
377        };
378        // connection terminated from either peer or timer
379        self.pop_closed(meta);
380        false
381    }
382
383    /// Passively wait to close the connection after the timeout
384    ///
385    /// If this connection is not being picked up or evicted before the timeout is reach, this
386    /// function will remove it from the pool and close the connection.
387    ///
388    /// Returns `true` if the connection was evicted from the pool, and `false` otherwise.
389    pub async fn idle_timeout(
390        &self,
391        meta: &ConnectionMeta,
392        timeout: Option<Duration>,
393        notify_evicted: Arc<Notify>,
394        mut notify_closed: watch::Receiver<bool>,
395        watch_use: oneshot::Receiver<bool>,
396    ) -> bool {
397        // Reuse this same Notified future in the watch_use branch: notify_one()
398        // may deliver the wakeup to an already-polled future, so creating a new
399        // notified() future after watch_use resolves could miss the eviction.
400        let evicted = notify_evicted.notified();
401        tokio::pin!(evicted);
402
403        tokio::select! {
404            biased;
405            event = watch_use => {
406                match event {
407                    Ok(_) => {
408                        debug!("idle connection is being picked up");
409                        false
410                    }
411                    // `watch_use` also resolves when the sender is dropped.
412                    // During LRU eviction, pop_evicted() removes the
413                    // PoolConnection, dropping the sender after notify_evicted
414                    // has been signaled. Keep this biased branch first for the
415                    // common reuse path, but confirm the eviction signal before
416                    // classifying sender drop as eviction.
417                    Err(_) => evicted.now_or_never().is_some(),
418                }
419            },
420            _ = &mut evicted => {
421                debug!("idle connection is being evicted");
422                // TODO: gracefully close the connection?
423                true
424            },
425            _ = notify_closed.changed() => {
426                // assume always changed from false to true
427                debug!("idle connection is being closed");
428                self.pop_closed(meta);
429                false
430            }
431            // async expression is evaluated if timeout is None but it's never polled, set it to MAX
432            _ = sleep(timeout.unwrap_or(Duration::MAX)), if timeout.is_some() => {
433                debug!("idle connection is being evicted");
434                self.pop_closed(meta);
435                false
436            }
437        }
438    }
439}
440
441async fn read_with_timeout<S>(
442    mut connection: OwnedMutexGuard<S>,
443    timeout_duration: Option<Duration>,
444) -> io::Result<usize>
445where
446    S: AsyncRead + Unpin + Send,
447{
448    let mut buf = [0; 1];
449    let read_event = connection.read(&mut buf[..]);
450    match timeout_duration {
451        Some(d) => match timeout(d, read_event).await {
452            Ok(res) => res,
453            Err(e) => {
454                debug!("keepalive timeout {:?} reached, {:?}", d, e);
455                Ok(0)
456            }
457        },
458        _ => read_event.await,
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use log::debug;
466    use tokio::sync::Mutex as AsyncMutex;
467    use tokio_test::io::{Builder, Mock};
468
469    fn pool_len<S>(pool: &ConnectionPool<S>) -> usize {
470        pool.pools.len()
471    }
472
473    fn pool_contains<S>(pool: &ConnectionPool<S>, key: GroupKey) -> bool {
474        pool.pools.contains_key(&key)
475    }
476
477    #[tokio::test]
478    async fn test_lookup() {
479        let meta1 = ConnectionMeta::new(101, 1);
480        let value1 = "v1".to_string();
481        let meta2 = ConnectionMeta::new(102, 2);
482        let value2 = "v2".to_string();
483        let meta3 = ConnectionMeta::new(101, 3);
484        let value3 = "v3".to_string();
485        let cp: ConnectionPool<String> = ConnectionPool::new(3); //#CP3
486        cp.put(&meta1, value1.clone());
487        cp.put(&meta2, value2.clone());
488        cp.put(&meta3, value3.clone());
489
490        let found_b = cp.get(&meta2.key).unwrap();
491        assert_eq!(found_b, value2);
492
493        let found_a1 = cp.get(&meta1.key).unwrap();
494        let found_a2 = cp.get(&meta1.key).unwrap();
495
496        assert!(
497            found_a1 == value1 && found_a2 == value3 || found_a2 == value1 && found_a1 == value3
498        );
499    }
500
501    #[tokio::test]
502    async fn test_pop() {
503        let meta1 = ConnectionMeta::new(101, 1);
504        let value1 = "v1".to_string();
505        let meta2 = ConnectionMeta::new(102, 2);
506        let value2 = "v2".to_string();
507        let meta3 = ConnectionMeta::new(101, 3);
508        let value3 = "v3".to_string();
509        let cp: ConnectionPool<String> = ConnectionPool::new(3); //#CP3
510        cp.put(&meta1, value1);
511        cp.put(&meta2, value2);
512        cp.put(&meta3, value3.clone());
513
514        cp.pop_closed(&meta1);
515
516        let found_a1 = cp.get(&meta1.key).unwrap();
517        assert_eq!(found_a1, value3);
518
519        cp.pop_closed(&meta1);
520        assert!(cp.get(&meta1.key).is_none())
521    }
522
523    #[tokio::test]
524    async fn test_eviction() {
525        let meta1 = ConnectionMeta::new(101, 1);
526        let value1 = "v1".to_string();
527        let meta2 = ConnectionMeta::new(102, 2);
528        let value2 = "v2".to_string();
529        let meta3 = ConnectionMeta::new(101, 3);
530        let value3 = "v3".to_string();
531        let cp: ConnectionPool<String> = ConnectionPool::new(2);
532        let (notify_close1, _) = cp.put(&meta1, value1.clone());
533        let (notify_close2, _) = cp.put(&meta2, value2.clone());
534        let (notify_close3, _) = cp.put(&meta3, value3.clone()); // meta 1 should be evicted
535
536        let closed_item = tokio::select! {
537            _ = notify_close1.notified() => {debug!("notifier1"); 1},
538            _ = notify_close2.notified() => {debug!("notifier2"); 2},
539            _ = notify_close3.notified() => {debug!("notifier3"); 3},
540        };
541        assert_eq!(closed_item, 1);
542
543        let found_a1 = cp.get(&meta1.key).unwrap();
544        assert_eq!(found_a1, value3);
545        assert_eq!(cp.get(&meta1.key), None)
546    }
547
548    #[tokio::test]
549    #[should_panic(expected = "There is still data left to read.")]
550    async fn test_read_close() {
551        let meta1 = ConnectionMeta::new(101, 1);
552        let mock_io1 = Arc::new(AsyncMutex::new(Builder::new().read(b"garbage").build()));
553        let meta2 = ConnectionMeta::new(102, 2);
554        let mock_io2 = Arc::new(AsyncMutex::new(
555            Builder::new().wait(Duration::from_secs(99)).build(),
556        ));
557        let meta3 = ConnectionMeta::new(101, 3);
558        let mock_io3 = Arc::new(AsyncMutex::new(
559            Builder::new().wait(Duration::from_secs(99)).build(),
560        ));
561        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(3);
562        let (c1, u1) = cp.put(&meta1, mock_io1.clone());
563        let (c2, u2) = cp.put(&meta2, mock_io2.clone());
564        let (c3, u3) = cp.put(&meta3, mock_io3.clone());
565
566        let closed_item = tokio::select! {
567            _ = cp.idle_poll(mock_io1.try_lock_owned().unwrap(), &meta1, None, c1, u1) => {debug!("notifier1"); 1},
568            _ = cp.idle_poll(mock_io2.try_lock_owned().unwrap(), &meta2, None, c2, u2) => {debug!("notifier2"); 2},
569            _ = cp.idle_poll(mock_io3.try_lock_owned().unwrap(), &meta3, None, c3, u3) => {debug!("notifier3"); 3},
570        };
571        assert_eq!(closed_item, 1);
572
573        let _ = cp.get(&meta1.key).unwrap(); // mock_io3 should be selected
574        assert!(cp.get(&meta1.key).is_none()) // mock_io1 should already be removed by idle_poll
575    }
576
577    #[tokio::test]
578    async fn test_read_timeout() {
579        let meta1 = ConnectionMeta::new(101, 1);
580        let mock_io1 = Arc::new(AsyncMutex::new(
581            Builder::new().wait(Duration::from_secs(99)).build(),
582        ));
583        let meta2 = ConnectionMeta::new(102, 2);
584        let mock_io2 = Arc::new(AsyncMutex::new(
585            Builder::new().wait(Duration::from_secs(99)).build(),
586        ));
587        let meta3 = ConnectionMeta::new(101, 3);
588        let mock_io3 = Arc::new(AsyncMutex::new(
589            Builder::new().wait(Duration::from_secs(99)).build(),
590        ));
591        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(3);
592        let (c1, u1) = cp.put(&meta1, mock_io1.clone());
593        let (c2, u2) = cp.put(&meta2, mock_io2.clone());
594        let (c3, u3) = cp.put(&meta3, mock_io3.clone());
595
596        let closed_item = tokio::select! {
597            _ = cp.idle_poll(mock_io1.try_lock_owned().unwrap(), &meta1, Some(Duration::from_secs(1)), c1, u1) => {debug!("notifier1"); 1},
598            _ = cp.idle_poll(mock_io2.try_lock_owned().unwrap(), &meta2, Some(Duration::from_secs(2)), c2, u2) => {debug!("notifier2"); 2},
599            _ = cp.idle_poll(mock_io3.try_lock_owned().unwrap(), &meta3, Some(Duration::from_secs(3)), c3, u3) => {debug!("notifier3"); 3},
600        };
601        assert_eq!(closed_item, 1);
602
603        let _ = cp.get(&meta1.key).unwrap(); // mock_io3 should be selected
604        assert!(cp.get(&meta1.key).is_none()) // mock_io1 should already be removed by idle_poll
605    }
606
607    #[tokio::test]
608    async fn test_evict_poll() {
609        let meta1 = ConnectionMeta::new(101, 1);
610        let mock_io1 = Arc::new(AsyncMutex::new(
611            Builder::new().wait(Duration::from_secs(99)).build(),
612        ));
613        let meta2 = ConnectionMeta::new(102, 2);
614        let mock_io2 = Arc::new(AsyncMutex::new(
615            Builder::new().wait(Duration::from_secs(99)).build(),
616        ));
617        let meta3 = ConnectionMeta::new(101, 3);
618        let mock_io3 = Arc::new(AsyncMutex::new(
619            Builder::new().wait(Duration::from_secs(99)).build(),
620        ));
621        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(2);
622        let (c1, u1) = cp.put(&meta1, mock_io1.clone());
623        let (c2, u2) = cp.put(&meta2, mock_io2.clone());
624        let (c3, u3) = cp.put(&meta3, mock_io3.clone()); // 1 should be evicted at this point
625
626        let closed_item = tokio::select! {
627            _ = cp.idle_poll(mock_io1.try_lock_owned().unwrap(), &meta1, None, c1, u1) => {debug!("notifier1"); 1},
628            _ = cp.idle_poll(mock_io2.try_lock_owned().unwrap(), &meta2, None, c2, u2) => {debug!("notifier2"); 2},
629            _ = cp.idle_poll(mock_io3.try_lock_owned().unwrap(), &meta3, None, c3, u3) => {debug!("notifier3"); 3},
630        };
631        assert_eq!(closed_item, 1);
632
633        let _ = cp.get(&meta1.key).unwrap(); // mock_io3 should be selected
634        assert!(cp.get(&meta1.key).is_none()) // mock_io1 should already be removed by idle_poll
635    }
636
637    #[tokio::test]
638    async fn test_idle_poll_reports_notify_evicted() {
639        let meta1 = ConnectionMeta::new(101, 1);
640        let mock_io1 = Arc::new(AsyncMutex::new(
641            Builder::new().wait(Duration::from_secs(99)).build(),
642        ));
643        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
644
645        let (notify_evicted, watch_use) = cp.put(&meta1, mock_io1.clone());
646        notify_evicted.notify_one();
647
648        let evicted = cp
649            .idle_poll(
650                mock_io1.try_lock_owned().unwrap(),
651                &meta1,
652                None,
653                notify_evicted,
654                watch_use,
655            )
656            .await;
657
658        assert!(evicted, "notify_evicted should report eviction");
659    }
660
661    #[tokio::test]
662    async fn test_idle_poll_reports_lru_eviction_after_pool_remove() {
663        let meta1 = ConnectionMeta::new(101, 1);
664        let mock_io1 = Arc::new(AsyncMutex::new(
665            Builder::new().wait(Duration::from_secs(99)).build(),
666        ));
667        let meta2 = ConnectionMeta::new(202, 2);
668        let mock_io2 = Arc::new(AsyncMutex::new(
669            Builder::new().wait(Duration::from_secs(99)).build(),
670        ));
671        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
672
673        let (notify_evicted, watch_use) = cp.put(&meta1, mock_io1.clone());
674        cp.put(&meta2, mock_io2);
675
676        let evicted = cp
677            .idle_poll(
678                mock_io1.try_lock_owned().unwrap(),
679                &meta1,
680                None,
681                notify_evicted,
682                watch_use,
683            )
684            .await;
685
686        assert!(evicted, "LRU eviction should report eviction");
687    }
688
689    #[tokio::test]
690    async fn test_idle_poll_reports_sender_drop_without_notify_not_evicted() {
691        let meta = ConnectionMeta::new(101, 1);
692        let mock_io = Arc::new(AsyncMutex::new(
693            Builder::new().wait(Duration::from_secs(99)).build(),
694        ));
695        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
696
697        let (notify_evicted, watch_use) = cp.put(&meta, mock_io.clone());
698        cp.pop_closed(&meta);
699
700        let evicted = cp
701            .idle_poll(
702                mock_io.try_lock_owned().unwrap(),
703                &meta,
704                None,
705                notify_evicted,
706                watch_use,
707            )
708            .await;
709
710        assert!(
711            !evicted,
712            "sender drop without notify should not report eviction"
713        );
714    }
715
716    #[tokio::test]
717    async fn test_idle_poll_reports_reuse_not_evicted() {
718        let meta = ConnectionMeta::new(101, 1);
719        let mock_io = Arc::new(AsyncMutex::new(
720            Builder::new().wait(Duration::from_secs(99)).build(),
721        ));
722        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
723
724        let (notify_evicted, watch_use) = cp.put(&meta, mock_io.clone());
725        assert!(cp.get(&meta.key).is_some());
726
727        let evicted = cp
728            .idle_poll(
729                mock_io.try_lock_owned().unwrap(),
730                &meta,
731                None,
732                notify_evicted,
733                watch_use,
734            )
735            .await;
736
737        assert!(!evicted, "reused connection should not report eviction");
738    }
739
740    #[tokio::test]
741    async fn test_idle_poll_reports_peer_close_not_evicted() {
742        let meta = ConnectionMeta::new(101, 1);
743        let mock_io = Arc::new(AsyncMutex::new(Builder::new().read(b"").build()));
744        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
745
746        let (notify_evicted, watch_use) = cp.put(&meta, mock_io.clone());
747
748        let evicted = cp
749            .idle_poll(
750                mock_io.try_lock_owned().unwrap(),
751                &meta,
752                None,
753                notify_evicted,
754                watch_use,
755            )
756            .await;
757
758        assert!(!evicted, "peer close should not report eviction");
759        assert!(cp.get(&meta.key).is_none());
760    }
761
762    #[tokio::test]
763    async fn test_idle_poll_reports_unexpected_data_not_evicted() {
764        let meta = ConnectionMeta::new(101, 1);
765        let mock_io = Arc::new(AsyncMutex::new(Builder::new().read(b"x").build()));
766        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
767
768        let (notify_evicted, watch_use) = cp.put(&meta, mock_io.clone());
769
770        let evicted = cp
771            .idle_poll(
772                mock_io.try_lock_owned().unwrap(),
773                &meta,
774                None,
775                notify_evicted,
776                watch_use,
777            )
778            .await;
779
780        assert!(!evicted, "unexpected data should not report eviction");
781        assert!(cp.get(&meta.key).is_none());
782    }
783
784    #[tokio::test]
785    async fn test_idle_poll_reports_read_error_not_evicted() {
786        let meta = ConnectionMeta::new(101, 1);
787        let mock_io = Arc::new(AsyncMutex::new(
788            Builder::new()
789                .read_error(io::Error::other("read failed"))
790                .build(),
791        ));
792        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
793
794        let (notify_evicted, watch_use) = cp.put(&meta, mock_io.clone());
795
796        let evicted = cp
797            .idle_poll(
798                mock_io.try_lock_owned().unwrap(),
799                &meta,
800                None,
801                notify_evicted,
802                watch_use,
803            )
804            .await;
805
806        assert!(!evicted, "read error should not report eviction");
807        assert!(cp.get(&meta.key).is_none());
808    }
809
810    #[tokio::test]
811    async fn test_idle_poll_reports_timeout_not_evicted() {
812        let meta = ConnectionMeta::new(101, 1);
813        let mock_io = Arc::new(AsyncMutex::new(
814            Builder::new().wait(Duration::from_secs(99)).build(),
815        ));
816        let cp: ConnectionPool<Arc<AsyncMutex<Mock>>> = ConnectionPool::new(1);
817
818        let (notify_evicted, watch_use) = cp.put(&meta, mock_io.clone());
819
820        let evicted = cp
821            .idle_poll(
822                mock_io.try_lock_owned().unwrap(),
823                &meta,
824                Some(Duration::from_millis(10)),
825                notify_evicted,
826                watch_use,
827            )
828            .await;
829
830        assert!(!evicted, "idle poll timeout should not report eviction");
831        assert!(cp.get(&meta.key).is_none());
832    }
833
834    #[tokio::test]
835    async fn test_idle_timeout_reports_timeout_not_evicted() {
836        let meta = ConnectionMeta::new(101, 1);
837        let cp: ConnectionPool<String> = ConnectionPool::new(1);
838        let (notify_evicted, watch_use) = cp.put(&meta, "v1".to_string());
839        let (_notify_closed, notify_closed_rx) = watch::channel(false);
840
841        let evicted = cp
842            .idle_timeout(
843                &meta,
844                Some(Duration::from_millis(10)),
845                notify_evicted,
846                notify_closed_rx,
847                watch_use,
848            )
849            .await;
850
851        assert!(!evicted, "idle timeout should not report eviction");
852        assert!(cp.get(&meta.key).is_none());
853    }
854
855    #[tokio::test]
856    async fn test_idle_timeout_reports_reuse_not_evicted() {
857        let meta = ConnectionMeta::new(101, 1);
858        let cp: ConnectionPool<String> = ConnectionPool::new(1);
859        let (notify_evicted, watch_use) = cp.put(&meta, "v1".to_string());
860        let (_notify_closed, notify_closed_rx) = watch::channel(false);
861
862        assert_eq!(cp.get(&meta.key), Some("v1".to_string()));
863
864        let evicted = cp
865            .idle_timeout(&meta, None, notify_evicted, notify_closed_rx, watch_use)
866            .await;
867
868        assert!(!evicted, "reused connection should not report eviction");
869    }
870
871    #[tokio::test]
872    async fn test_idle_timeout_reports_notify_evicted() {
873        let meta = ConnectionMeta::new(101, 1);
874        let cp: ConnectionPool<String> = ConnectionPool::new(1);
875        let (notify_evicted, watch_use) = cp.put(&meta, "v1".to_string());
876        let (_notify_closed, notify_closed_rx) = watch::channel(false);
877
878        notify_evicted.notify_one();
879
880        let evicted = cp
881            .idle_timeout(&meta, None, notify_evicted, notify_closed_rx, watch_use)
882            .await;
883
884        assert!(evicted, "notify_evicted should report eviction");
885    }
886
887    #[tokio::test]
888    async fn test_idle_timeout_reports_lru_eviction_after_pool_remove() {
889        let meta1 = ConnectionMeta::new(101, 1);
890        let meta2 = ConnectionMeta::new(202, 2);
891        let cp: ConnectionPool<String> = ConnectionPool::new(1);
892        let (notify_evicted, watch_use) = cp.put(&meta1, "v1".to_string());
893        let (_notify_closed, notify_closed_rx) = watch::channel(false);
894
895        cp.put(&meta2, "v2".to_string());
896
897        let evicted = cp
898            .idle_timeout(&meta1, None, notify_evicted, notify_closed_rx, watch_use)
899            .await;
900
901        assert!(evicted, "LRU eviction should report eviction");
902    }
903
904    #[tokio::test]
905    async fn test_idle_timeout_reports_lru_eviction_after_notify_registered() {
906        let meta1 = ConnectionMeta::new(101, 1);
907        let meta2 = ConnectionMeta::new(202, 2);
908        let cp = Arc::new(ConnectionPool::new(1));
909        let (notify_evicted, watch_use) = cp.put(&meta1, "v1".to_string());
910        let (_notify_closed, notify_closed_rx) = watch::channel(false);
911
912        let idle_cp = cp.clone();
913        let idle_meta = meta1.clone();
914        let idle_task = tokio::spawn(async move {
915            idle_cp
916                .idle_timeout(
917                    &idle_meta,
918                    None,
919                    notify_evicted,
920                    notify_closed_rx,
921                    watch_use,
922                )
923                .await
924        });
925
926        tokio::task::yield_now().await;
927        cp.put(&meta2, "v2".to_string());
928
929        assert!(
930            idle_task.await.unwrap(),
931            "LRU eviction should report eviction after notify future was registered"
932        );
933    }
934
935    #[tokio::test]
936    async fn test_idle_timeout_reports_sender_drop_without_notify_not_evicted() {
937        let meta = ConnectionMeta::new(101, 1);
938        let cp: ConnectionPool<String> = ConnectionPool::new(1);
939        let (notify_evicted, watch_use) = cp.put(&meta, "v1".to_string());
940        let (_notify_closed, notify_closed_rx) = watch::channel(false);
941
942        cp.pop_closed(&meta);
943
944        let evicted = cp
945            .idle_timeout(&meta, None, notify_evicted, notify_closed_rx, watch_use)
946            .await;
947
948        assert!(
949            !evicted,
950            "sender drop without notify should not report eviction"
951        );
952    }
953
954    #[tokio::test]
955    async fn test_idle_timeout_reports_notify_closed_not_evicted() {
956        let meta = ConnectionMeta::new(101, 1);
957        let cp: ConnectionPool<String> = ConnectionPool::new(1);
958        let (notify_evicted, watch_use) = cp.put(&meta, "v1".to_string());
959        let (notify_closed, notify_closed_rx) = watch::channel(false);
960
961        notify_closed.send(true).unwrap();
962
963        let evicted = cp
964            .idle_timeout(&meta, None, notify_evicted, notify_closed_rx, watch_use)
965            .await;
966
967        assert!(!evicted, "notify_closed should not report eviction");
968        assert!(cp.get(&meta.key).is_none());
969    }
970
971    #[test]
972    fn test_pool_node_is_empty() {
973        let node: PoolNode<String> = PoolNode::new();
974        assert!(node.is_empty(), "newly created node should be empty");
975
976        node.insert(1, "v1".to_string());
977        assert!(!node.is_empty(), "node with one item should not be empty");
978
979        // get_any removes the item
980        let item = node.get_any();
981        assert!(item.is_some());
982        assert!(node.is_empty(), "node should be empty after get_any");
983
984        // insert then remove by id
985        node.insert(2, "v2".to_string());
986        assert!(!node.is_empty());
987
988        let removed = node.remove(2);
989        assert!(removed.is_some());
990        assert!(node.is_empty(), "node should be empty after remove");
991    }
992
993    #[test]
994    fn test_pool_node_is_empty_overflow_to_connections() {
995        // Fill the hot queue (capacity = HOT_QUEUE_SIZE = 16), then overflow
996        // into the connections HashMap, and verify is_empty drains both.
997        let node: PoolNode<String> = PoolNode::new();
998
999        for i in 0..(HOT_QUEUE_SIZE as i32 + 4) {
1000            node.insert(i, format!("v{i}"));
1001        }
1002        assert!(!node.is_empty());
1003
1004        // Drain all items via get_any
1005        while node.get_any().is_some() {}
1006        assert!(node.is_empty(), "node should be empty after draining all");
1007    }
1008
1009    #[tokio::test]
1010    async fn test_empty_node_removed_after_pop_closed() {
1011        // Reproducer from GitHub issue #748: a single connection is added and
1012        // then closed. The PoolNode entry in the pool HashMap must be removed.
1013        let meta = ConnectionMeta::new(101, 1);
1014        let cp: ConnectionPool<String> = ConnectionPool::new(2);
1015        cp.put(&meta, "v1".to_string());
1016
1017        assert_eq!(pool_len(&cp), 1, "pool should have 1 node");
1018
1019        cp.pop_closed(&meta);
1020
1021        assert_eq!(
1022            pool_len(&cp),
1023            0,
1024            "empty PoolNode should be removed after pop_closed"
1025        );
1026    }
1027
1028    #[tokio::test]
1029    async fn test_empty_node_removed_after_get() {
1030        // When the last connection is retrieved via get(), the PoolNode should
1031        // be cleaned up. This path is distinct from pop_closed because the
1032        // idle_poll/idle_timeout tasks exit via the watch_use channel and never
1033        // call pop_closed.
1034        let meta = ConnectionMeta::new(101, 1);
1035        let cp: ConnectionPool<String> = ConnectionPool::new(2);
1036        cp.put(&meta, "v1".to_string());
1037
1038        assert_eq!(pool_len(&cp), 1);
1039
1040        let conn = cp.get(&meta.key);
1041        assert!(conn.is_some());
1042
1043        assert_eq!(
1044            pool_len(&cp),
1045            0,
1046            "empty PoolNode should be removed after get() takes the last connection"
1047        );
1048    }
1049
1050    #[tokio::test]
1051    async fn test_empty_node_removed_when_get_finds_empty_node() {
1052        // If a node exists but has no connections (e.g. they were all evicted
1053        // by the LRU), get() should clean up the empty node.
1054        let meta1 = ConnectionMeta::new(101, 1);
1055        let meta2 = ConnectionMeta::new(101, 2);
1056        let cp: ConnectionPool<String> = ConnectionPool::new(4);
1057        cp.put(&meta1, "v1".to_string());
1058        cp.put(&meta2, "v2".to_string());
1059
1060        // Remove both connections via pop_closed, but the first pop_closed
1061        // won't remove the node since meta2 is still there.
1062        cp.pop_closed(&meta1);
1063        assert_eq!(pool_len(&cp), 1, "node should still exist");
1064
1065        cp.pop_closed(&meta2);
1066        assert_eq!(
1067            pool_len(&cp),
1068            0,
1069            "node should be removed after last connection is popped"
1070        );
1071    }
1072
1073    #[tokio::test]
1074    async fn test_node_not_removed_when_connections_remain() {
1075        // Removing one connection from a node that has others must NOT remove
1076        // the node itself.
1077        let meta1 = ConnectionMeta::new(101, 1);
1078        let meta2 = ConnectionMeta::new(101, 2);
1079        let cp: ConnectionPool<String> = ConnectionPool::new(4);
1080        cp.put(&meta1, "v1".to_string());
1081        cp.put(&meta2, "v2".to_string());
1082
1083        cp.pop_closed(&meta1);
1084
1085        assert!(
1086            pool_contains(&cp, 101),
1087            "node should still exist because meta2's connection is still in it"
1088        );
1089        assert_eq!(pool_len(&cp), 1);
1090
1091        // The remaining connection should still be retrievable
1092        let conn = cp.get(&meta1.key);
1093        assert!(conn.is_some());
1094    }
1095
1096    #[tokio::test]
1097    async fn test_empty_node_cleanup_only_affects_target_key() {
1098        // Cleaning up an empty node for one key must not affect other keys.
1099        let meta_a = ConnectionMeta::new(101, 1);
1100        let meta_b = ConnectionMeta::new(202, 2);
1101        let cp: ConnectionPool<String> = ConnectionPool::new(4);
1102        cp.put(&meta_a, "a".to_string());
1103        cp.put(&meta_b, "b".to_string());
1104
1105        assert_eq!(pool_len(&cp), 2);
1106
1107        // Remove all connections for key 101
1108        cp.pop_closed(&meta_a);
1109
1110        assert_eq!(
1111            pool_len(&cp),
1112            1,
1113            "only key 101's empty node should be removed"
1114        );
1115        assert!(!pool_contains(&cp, 101), "key 101 should be gone");
1116        assert!(pool_contains(&cp, 202), "key 202 should remain");
1117
1118        // key 202's connection should still be retrievable
1119        let conn = cp.get(&meta_b.key);
1120        assert_eq!(conn, Some("b".to_string()));
1121    }
1122
1123    #[tokio::test]
1124    async fn test_empty_node_cleaned_after_lru_eviction() {
1125        // When LRU eviction removes the last connection for a key, the empty
1126        // node should be cleaned up by pop_evicted (called from put()).
1127        let meta1 = ConnectionMeta::new(101, 1);
1128        let meta2 = ConnectionMeta::new(202, 2);
1129        let cp: ConnectionPool<String> = ConnectionPool::new(1);
1130
1131        cp.put(&meta1, "v1".to_string());
1132        assert_eq!(pool_len(&cp), 1);
1133
1134        // This put evicts meta1 (LRU size = 1), making key 101's node empty.
1135        cp.put(&meta2, "v2".to_string());
1136
1137        assert!(
1138            !pool_contains(&cp, 101),
1139            "key 101's empty node should be removed after its only connection was evicted"
1140        );
1141        assert!(pool_contains(&cp, 202));
1142    }
1143
1144    #[test]
1145    fn test_concurrent_empty_node_cleanup_does_not_orphan_put() {
1146        const KEY: GroupKey = 101;
1147        let cp = Arc::new(ConnectionPool::new(2_000));
1148        let start = Arc::new(std::sync::Barrier::new(2));
1149
1150        let cleanup_cp = cp.clone();
1151        let cleanup_start = start.clone();
1152        let cleanup = std::thread::spawn(move || {
1153            cleanup_start.wait();
1154            for _ in 0..10_000 {
1155                cleanup_cp.try_remove_empty_node(KEY);
1156                std::thread::yield_now();
1157            }
1158        });
1159
1160        start.wait();
1161        for id in 1..=1_000 {
1162            let value = format!("v{id}");
1163            cp.put(&ConnectionMeta::new(KEY, id), value.clone());
1164            assert_eq!(
1165                cp.get(&KEY),
1166                Some(value),
1167                "put connection should remain reachable during empty-node cleanup"
1168            );
1169            std::thread::yield_now();
1170        }
1171
1172        cleanup.join().unwrap();
1173    }
1174
1175    #[tokio::test]
1176    async fn test_node_reusable_after_cleanup() {
1177        // After an empty node is cleaned up, inserting a new connection for the
1178        // same key should work correctly (a new PoolNode is created).
1179        let meta1 = ConnectionMeta::new(101, 1);
1180        let cp: ConnectionPool<String> = ConnectionPool::new(4);
1181        cp.put(&meta1, "first".to_string());
1182
1183        cp.pop_closed(&meta1);
1184        assert_eq!(pool_len(&cp), 0, "node should be cleaned up");
1185
1186        // Re-insert for the same key
1187        let meta2 = ConnectionMeta::new(101, 2);
1188        cp.put(&meta2, "second".to_string());
1189
1190        assert_eq!(pool_len(&cp), 1);
1191        let conn = cp.get(&meta2.key);
1192        assert_eq!(conn, Some("second".to_string()));
1193
1194        assert_eq!(
1195            pool_len(&cp),
1196            0,
1197            "node should be cleaned up again after get"
1198        );
1199    }
1200}