Skip to main content

compio_pool/
exchange.rs

1//! Cross-thread connection exchange: the overflow / steal pool.
2//!
3//! A [`Pool`](crate::Pool) keeps one independent shard per compio thread. The
4//! `Exchange` is the optional back channel between those shards: it lets an
5//! idle connection on a quiet thread be picked up by a busy one instead of
6//! sitting unused until it ages out.
7//!
8//! [`NoExchange`] (the default) is a zero-sized no-op — pure thread-per-core,
9//! no atomics, no lock. [`Reservoir`] is the real implementation: a bounded,
10//! lock-free [`ArrayQueue`] that every thread shares.
11//!
12//! # How a connection crosses a thread
13//!
14//! ```text
15//!   thread A                  shared ArrayQueue              thread B
16//!   --------                  -----------------              --------
17//!   local free list
18//!   over min_idle
19//!        |
20//!        |  Detach::detach      push (never blocks)
21//!        +--- fd out of ------------> [ fd, fd, fd ] ---+
22//!             A's driver                                |  pop, then
23//!                                                       |  Detach::attach
24//!                                                       +---> re-wrapped in
25//!                                                             B's driver
26//! ```
27//!
28//! Only **idle** connections take this path. A connection is offered here only
29//! after its [`Pooled`](crate::Pooled) guard has been dropped, and a checkout
30//! cancelled mid-operation is poisoned and destroyed instead — so nothing with
31//! an operation in flight can be handed to another driver. [`Detach::detach`]
32//! re-checks that invariant rather than trusting it.
33//!
34//! A thread dips into the queue only when its own free list is empty, and
35//! *before* it dials: stealing a warm socket beats a handshake.
36//!
37//! # Why a queue and not a stack
38//!
39//! [`ArrayQueue`] is FIFO, so the connection that has been parked longest is
40//! claimed first. That is the right bias for an overflow pool: residency is
41//! bounded, so a parked connection cannot sit at the bottom of a stack going
42//! stale while newer arrivals churn above it. The cost is a little cache
43//! warmth, which matters far less than a socket the far end has quietly
44//! dropped.
45
46use std::{
47    future::Future,
48    sync::atomic::{
49        AtomicUsize,
50        Ordering::{AcqRel, Acquire},
51    },
52};
53
54use crossbeam_queue::ArrayQueue;
55
56use crate::{
57    manage::{Detach, Manage},
58    slot::SlotMeta,
59};
60
61/// What an [`Exchange`] did with a connection offered to [`Exchange::park`].
62pub enum Parked<M: Manage> {
63    /// Taken; the connection now belongs to the exchange, not to the shard.
64    Accepted,
65    /// Refused — the exchange is full, or does not share at all. Ownership is
66    /// handed back, and the shard keeps the connection locally.
67    Refused(M::Connection, SlotMeta),
68    /// Taken, but it could not be made portable and has been destroyed.
69    /// See [`Detach::detach`].
70    Destroyed,
71}
72
73/// What an [`Exchange`] produced for [`Exchange::unpark`].
74pub enum Unparked<M: Manage> {
75    /// Claimed from another thread and re-wrapped in this thread's runtime.
76    Claimed(M::Connection, SlotMeta),
77    /// Nothing was available; the caller should dial.
78    Empty,
79    /// One was taken, but [`Detach::attach`] failed and it has been destroyed.
80    /// The caller should dial. Reported separately so the pool can keep its
81    /// `created`/`closed` counters balanced.
82    Lost,
83}
84
85/// Strategy for moving idle connections between per-thread shards.
86pub trait Exchange<M: Manage>: Send + Sync + 'static {
87    /// Offers an idle connection to other threads. Must not block.
88    fn park(&self, conn: M::Connection, meta: SlotMeta) -> Parked<M>;
89
90    /// Tries to claim a connection parked by another thread and reattach it
91    /// to this one.
92    fn unpark(&self) -> impl Future<Output = Unparked<M>>;
93
94    /// Connections currently parked and claimable.
95    fn parked(&self) -> u64;
96
97    /// Drops every parked connection. Called by [`Pool::close`](crate::Pool::close).
98    fn clear(&self) {}
99}
100
101/// The default: shards never share. Zero-sized, zero-cost.
102#[derive(Debug, Default, Clone, Copy)]
103pub struct NoExchange;
104
105impl<M: Manage> Exchange<M> for NoExchange {
106    fn park(&self, conn: M::Connection, meta: SlotMeta) -> Parked<M> {
107        Parked::Refused(conn, meta)
108    }
109
110    async fn unpark(&self) -> Unparked<M> {
111        Unparked::Empty
112    }
113
114    fn parked(&self) -> u64 {
115        0
116    }
117}
118
119struct Entry<P> {
120    parked: P,
121    meta: SlotMeta,
122}
123
124/// A bounded, lock-free pool of detached connections shared by every thread.
125///
126/// Idle connections beyond a shard's [`min_idle`](crate::Config::min_idle) are
127/// detached into this queue, where any thread may claim them. Both ends are
128/// wait-free in the common case: [`ArrayQueue`] is a fixed-size ring, so
129/// parking and stealing never allocate and never take a lock. The only async
130/// step, [`Detach::attach`], runs after the entry is already out of the queue.
131///
132/// # Platform support
133///
134/// Only construct this where [`Detach`] is sound: io_uring and poll drivers
135/// treat `attach` as a no-op, so fds move freely. Under IOCP a handle is bound
136/// to one completion port for life. See [`Detach`] for the details.
137pub struct Reservoir<M: Detach> {
138    queue: ArrayQueue<Entry<M::Parked>>,
139    /// Slots claimed: entries in the queue, plus those in flight between the
140    /// admission check and the push. Capped at the queue's capacity, which is
141    /// what makes the push below infallible — without it, a queue that filled
142    /// up between `detach` and `push` would leave us holding a detached
143    /// connection with no way to rebuild it synchronously.
144    admitted: AtomicUsize,
145}
146
147impl<M: Detach> Reservoir<M> {
148    /// Creates a reservoir holding at most `capacity` connections.
149    pub fn new(capacity: usize) -> Self {
150        assert!(capacity > 0, "reservoir capacity must be greater than zero");
151        Self {
152            queue: ArrayQueue::new(capacity),
153            admitted: AtomicUsize::new(0),
154        }
155    }
156
157    /// The most connections this reservoir will hold.
158    pub fn capacity(&self) -> usize {
159        self.queue.capacity()
160    }
161
162    /// Connections currently parked and claimable.
163    pub fn len(&self) -> usize {
164        self.queue.len()
165    }
166
167    /// True when nothing is parked.
168    pub fn is_empty(&self) -> bool {
169        self.queue.is_empty()
170    }
171
172    /// Claims one slot, unless the reservoir is already full.
173    fn admit(&self) -> bool {
174        self.admitted
175            .fetch_update(AcqRel, Acquire, |n| {
176                (n < self.queue.capacity()).then_some(n + 1)
177            })
178            .is_ok()
179    }
180
181    /// Gives a claimed slot back.
182    fn readmit(&self) {
183        self.admitted.fetch_sub(1, AcqRel);
184    }
185}
186
187impl<M: Detach> Default for Reservoir<M> {
188    fn default() -> Self {
189        Self::new(64)
190    }
191}
192
193impl<M: Detach> std::fmt::Debug for Reservoir<M> {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.debug_struct("Reservoir")
196            .field("capacity", &self.queue.capacity())
197            .field("len", &self.queue.len())
198            .finish()
199    }
200}
201
202impl<M: Detach> Exchange<M> for Reservoir<M> {
203    fn park(&self, conn: M::Connection, meta: SlotMeta) -> Parked<M> {
204        // Reserve before detaching, so the connection is still whole if the
205        // reservoir turns out to be full.
206        if !self.admit() {
207            return Parked::Refused(conn, meta);
208        }
209
210        let Some(parked) = M::detach(conn) else {
211            // An operation was still in flight, so `detach` consumed and
212            // dropped it. Nothing to push.
213            self.readmit();
214            return Parked::Destroyed;
215        };
216
217        if self.queue.push(Entry { parked, meta }).is_err() {
218            // Unreachable: `admitted` never exceeds capacity, and every
219            // admitted slot is either pushed or handed back. Handled rather
220            // than asserted, because the alternative is losing a live socket
221            // without telling the pool about it.
222            self.readmit();
223            return Parked::Destroyed;
224        }
225        Parked::Accepted
226    }
227
228    async fn unpark(&self) -> Unparked<M> {
229        let Some(entry) = self.queue.pop() else {
230            return Unparked::Empty;
231        };
232        self.readmit();
233
234        // Nothing is held across this await — the queue is lock-free and the
235        // entry is already ours. `attach` re-wraps the socket in the calling
236        // thread's runtime, which is why it must run here and not at push time.
237        match M::attach(entry.parked).await {
238            Ok(conn) => Unparked::Claimed(conn, entry.meta),
239            Err(_) => Unparked::Lost,
240        }
241    }
242
243    fn parked(&self) -> u64 {
244        self.queue.len() as u64
245    }
246
247    fn clear(&self) {
248        // A `park` caught mid-flight (admitted, not yet pushed) leaves its slot
249        // claimed. That is harmless: `clear` runs from `Pool::close`, after
250        // which nothing parks again.
251        while self.queue.pop().is_some() {
252            self.readmit();
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use std::{
260        pin::pin,
261        sync::atomic::Ordering::SeqCst,
262        task::{Context, Poll, Waker},
263    };
264
265    use super::*;
266    use crate::test_support::{CAN_ATTACH, CAN_DETACH, MovableConn, MovableManager, detach_lock};
267
268    /// Both `unpark` implementations, and the test `attach`, complete without
269    /// ever yielding — the queue is lock-free and the entry is already ours. One
270    /// poll is therefore enough, and asserting that keeps the claim honest.
271    fn drive<F: Future>(fut: F) -> F::Output {
272        let mut fut = pin!(fut);
273        match fut.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
274            Poll::Ready(v) => v,
275            Poll::Pending => panic!("an exchange future must not yield"),
276        }
277    }
278
279    fn meta(generation: u64, uses: u64) -> SlotMeta {
280        let mut m = SlotMeta::new(generation);
281        m.uses = uses;
282        m
283    }
284
285    fn reservoir(capacity: usize) -> Reservoir<MovableManager> {
286        Reservoir::new(capacity)
287    }
288
289    // ---- NoExchange: the zero-cost default ----------------------------------
290
291    #[test]
292    fn no_exchange_refuses_and_hands_the_connection_straight_back() {
293        let x = NoExchange;
294        match Exchange::<MovableManager>::park(&x, MovableConn::new(7), meta(3, 5)) {
295            Parked::Refused(conn, meta) => {
296                assert_eq!(conn.id, 7, "the caller keeps its connection");
297                assert_eq!(meta.generation, 3);
298                assert_eq!(meta.uses, 5);
299            }
300            _ => panic!("NoExchange must never take ownership"),
301        }
302    }
303
304    #[test]
305    fn no_exchange_never_produces_a_connection() {
306        let x = NoExchange;
307        assert!(matches!(
308            drive(Exchange::<MovableManager>::unpark(&x)),
309            Unparked::Empty
310        ));
311        assert_eq!(Exchange::<MovableManager>::parked(&x), 0);
312    }
313
314    #[test]
315    fn no_exchange_clear_is_the_default_no_op() {
316        // `clear` has a default body; calling it on the default exchange is the
317        // path `Pool::close` takes when no reservoir is installed.
318        Exchange::<MovableManager>::clear(&NoExchange);
319    }
320
321    #[test]
322    fn no_exchange_is_zero_sized_and_derives_the_usual_traits() {
323        fn assert_traits<T: Copy + Clone + Default + std::fmt::Debug>() {}
324        assert_traits::<NoExchange>();
325
326        // Zero-sized is the whole point: the default exchange must cost a pool
327        // that never shares absolutely nothing.
328        assert_eq!(std::mem::size_of::<NoExchange>(), 0);
329        assert_eq!(format!("{NoExchange:?}"), "NoExchange");
330    }
331
332    // ---- Reservoir: construction and inspection -----------------------------
333
334    #[test]
335    fn a_new_reservoir_is_empty() {
336        let r = reservoir(4);
337        assert_eq!(r.capacity(), 4);
338        assert_eq!(r.len(), 0);
339        assert!(r.is_empty());
340        assert_eq!(Exchange::<MovableManager>::parked(&r), 0);
341    }
342
343    #[test]
344    #[should_panic(expected = "reservoir capacity must be greater than zero")]
345    fn a_zero_capacity_reservoir_panics() {
346        // A reservoir that can hold nothing would detach connections and then
347        // have nowhere to put them.
348        let _ = reservoir(0);
349    }
350
351    #[test]
352    fn the_default_reservoir_holds_sixty_four() {
353        assert_eq!(Reservoir::<MovableManager>::default().capacity(), 64);
354    }
355
356    #[test]
357    fn debug_reports_capacity_and_occupancy() {
358        let _lock = detach_lock();
359        let r = reservoir(4);
360        r.park(MovableConn::new(0), meta(0, 0));
361        let s = format!("{r:?}");
362        assert!(s.contains("capacity: 4"), "got {s}");
363        assert!(s.contains("len: 1"), "got {s}");
364    }
365
366    // ---- Reservoir: parking ------------------------------------------------
367
368    #[test]
369    fn parking_takes_ownership() {
370        let _lock = detach_lock();
371        let r = reservoir(2);
372
373        assert!(matches!(
374            r.park(MovableConn::new(1), meta(0, 0)),
375            Parked::Accepted
376        ));
377        assert_eq!(r.len(), 1);
378        assert!(!r.is_empty());
379        assert_eq!(Exchange::<MovableManager>::parked(&r), 1);
380    }
381
382    #[test]
383    fn a_full_reservoir_refuses_without_detaching() {
384        let _lock = detach_lock();
385        let r = reservoir(2);
386        r.park(MovableConn::new(0), meta(0, 0));
387        r.park(MovableConn::new(1), meta(0, 0));
388
389        // The third offer must come back whole: admission is checked *before*
390        // `detach`, so a full reservoir cannot cost the shard a connection.
391        match r.park(MovableConn::new(2), meta(9, 4)) {
392            Parked::Refused(conn, meta) => {
393                assert_eq!(conn.id, 2);
394                assert_eq!((meta.generation, meta.uses), (9, 4));
395            }
396            _ => panic!("a full reservoir must refuse"),
397        }
398        assert_eq!(r.len(), 2, "and must not grow past capacity");
399    }
400
401    /// `detach` consumes the connection either way. When it declines, the
402    /// connection is already gone, so the reservoir reports `Destroyed` rather
403    /// than pretending it can be handed back.
404    #[test]
405    fn a_connection_that_cannot_detach_is_reported_destroyed() {
406        let _lock = detach_lock();
407        let r = reservoir(2);
408        CAN_DETACH.store(false, SeqCst);
409
410        assert!(matches!(
411            r.park(MovableConn::new(0), meta(0, 0)),
412            Parked::Destroyed
413        ));
414        assert_eq!(r.len(), 0, "nothing was queued");
415
416        // The admission slot must have been handed back, or the reservoir would
417        // leak capacity on every failed detach.
418        CAN_DETACH.store(true, SeqCst);
419        r.park(MovableConn::new(1), meta(0, 0));
420        r.park(MovableConn::new(2), meta(0, 0));
421        assert_eq!(r.len(), 2, "failed detaches must not consume capacity");
422    }
423
424    // ---- Reservoir: claiming -----------------------------------------------
425
426    #[test]
427    fn claiming_an_empty_reservoir_tells_the_caller_to_dial() {
428        let r = reservoir(2);
429        assert!(matches!(
430            drive(Exchange::<MovableManager>::unpark(&r)),
431            Unparked::Empty
432        ));
433    }
434
435    #[test]
436    fn a_claimed_connection_keeps_its_identity_and_metadata() {
437        let _lock = detach_lock();
438        let r = reservoir(2);
439        r.park(MovableConn::new(42), meta(3, 8));
440
441        match drive(Exchange::<MovableManager>::unpark(&r)) {
442            Unparked::Claimed(conn, meta) => {
443                assert_eq!(conn.id, 42, "the same socket, re-wrapped");
444                assert_eq!(
445                    (meta.generation, meta.uses),
446                    (3, 8),
447                    "lifecycle bookkeeping survives the trip"
448                );
449            }
450            _ => panic!("the parked connection should have been claimed"),
451        }
452        assert!(r.is_empty());
453    }
454
455    /// FIFO by design: the connection parked longest is claimed first, so
456    /// nothing goes stale at the bottom of a stack.
457    #[test]
458    fn claims_are_first_in_first_out() {
459        let _lock = detach_lock();
460        let r = reservoir(4);
461        for id in 0..3 {
462            r.park(MovableConn::new(id), meta(0, 0));
463        }
464
465        let mut order = Vec::new();
466        while let Unparked::Claimed(conn, _) = drive(Exchange::<MovableManager>::unpark(&r)) {
467            order.push(conn.id);
468        }
469        assert_eq!(order, vec![0, 1, 2]);
470    }
471
472    #[test]
473    fn a_connection_that_cannot_reattach_is_reported_lost() {
474        let _lock = detach_lock();
475        let r = reservoir(2);
476        r.park(MovableConn::new(0), meta(0, 0));
477        CAN_ATTACH.store(false, SeqCst);
478
479        assert!(matches!(
480            drive(Exchange::<MovableManager>::unpark(&r)),
481            Unparked::Lost
482        ));
483        assert!(r.is_empty(), "it was popped, not left behind");
484    }
485
486    /// Claiming frees the admission slot as well as the queue slot; otherwise a
487    /// reservoir would accept only `capacity` parks over its whole lifetime.
488    #[test]
489    fn claiming_frees_capacity_for_a_later_park() {
490        let _lock = detach_lock();
491        let r = reservoir(1);
492        assert!(matches!(
493            r.park(MovableConn::new(0), meta(0, 0)),
494            Parked::Accepted
495        ));
496        assert!(matches!(
497            r.park(MovableConn::new(1), meta(0, 0)),
498            Parked::Refused(..)
499        ));
500
501        drive(Exchange::<MovableManager>::unpark(&r));
502        assert!(
503            matches!(r.park(MovableConn::new(2), meta(0, 0)), Parked::Accepted),
504            "the slot freed by the claim must be reusable"
505        );
506    }
507
508    #[test]
509    fn a_lost_connection_also_frees_its_capacity() {
510        let _lock = detach_lock();
511        let r = reservoir(1);
512        r.park(MovableConn::new(0), meta(0, 0));
513        CAN_ATTACH.store(false, SeqCst);
514        assert!(matches!(
515            drive(Exchange::<MovableManager>::unpark(&r)),
516            Unparked::Lost
517        ));
518
519        CAN_ATTACH.store(true, SeqCst);
520        assert!(matches!(
521            r.park(MovableConn::new(1), meta(0, 0)),
522            Parked::Accepted
523        ));
524    }
525
526    // ---- Reservoir: clearing ----------------------------------------------
527
528    #[test]
529    fn clear_drops_everything_and_frees_all_capacity() {
530        let _lock = detach_lock();
531        let r = reservoir(2);
532        r.park(MovableConn::new(0), meta(0, 0));
533        r.park(MovableConn::new(1), meta(0, 0));
534
535        Exchange::<MovableManager>::clear(&r);
536
537        assert_eq!(r.len(), 0);
538        assert!(r.is_empty());
539        assert_eq!(Exchange::<MovableManager>::parked(&r), 0);
540        // Capacity is back, which is what lets a pool keep working after
541        // `invalidate` rather than only after `close`.
542        assert!(matches!(
543            r.park(MovableConn::new(2), meta(0, 0)),
544            Parked::Accepted
545        ));
546        assert!(matches!(
547            r.park(MovableConn::new(3), meta(0, 0)),
548            Parked::Accepted
549        ));
550    }
551
552    #[test]
553    fn clearing_an_empty_reservoir_is_harmless() {
554        let r = reservoir(2);
555        Exchange::<MovableManager>::clear(&r);
556        assert!(r.is_empty());
557    }
558
559    /// Admission is the invariant that makes the push in `park` infallible, so
560    /// exercise it right at the boundary.
561    #[test]
562    fn admission_never_exceeds_capacity() {
563        let _lock = detach_lock();
564        let r = reservoir(3);
565        for id in 0..3 {
566            assert!(matches!(
567                r.park(MovableConn::new(id), meta(0, 0)),
568                Parked::Accepted
569            ));
570        }
571        for id in 3..6 {
572            assert!(matches!(
573                r.park(MovableConn::new(id), meta(0, 0)),
574                Parked::Refused(..)
575            ));
576        }
577        assert_eq!(r.len(), 3);
578        assert!(r.len() <= r.capacity());
579    }
580}