compio-pool 0.0.1

A thread-per-core connection pool for the compio runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
//! Behavioural tests for the sharded pool.

mod common;

use std::{
    sync::{Arc, Barrier, atomic::Ordering::SeqCst},
    time::Duration,
};

use common::{LocalManager, MovableManager};
use compio_pool::{Config, Error, Pool, Reservoir};

fn cfg() -> Config {
    // Disable the reaper's own timers so tests observe only what they trigger.
    Config::new()
        .max_lifetime(None)
        .idle_timeout(None)
        .acquire_timeout(Duration::from_millis(200))
}

#[compio::test]
async fn returns_and_reuses_the_same_connection() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(4));

    let first = pool.acquire().await.unwrap().id;
    // Guard dropped here: the connection goes back to this thread's free list.
    let second = pool.acquire().await.unwrap().id;

    assert_eq!(
        first, second,
        "the warm connection should be handed back out"
    );
    assert_eq!(counts.connected.load(SeqCst), 1, "no second dial");
    assert_eq!(
        counts.recycled.load(SeqCst),
        1,
        "recycle runs on reuse, not first use"
    );
}

#[compio::test]
async fn max_size_is_enforced_per_shard() {
    let pool = Pool::new(LocalManager::new(), cfg().max_size(2));

    let _a = pool.acquire().await.unwrap();
    let _b = pool.acquire().await.unwrap();
    assert_eq!(pool.local_size(), 2);

    // Third checkout has nowhere to go and must time out rather than grow.
    match pool.acquire().await {
        Err(Error::Timeout) => {}
        other => panic!("expected Timeout, got {:?}", other.map(|c| c.id)),
    }
    assert_eq!(pool.metrics().timeouts, 1);
}

#[compio::test]
async fn a_waiter_is_woken_when_a_connection_comes_back() {
    let pool = Pool::new(LocalManager::new(), cfg().max_size(1));

    let held = pool.acquire().await.unwrap();

    let waiting = compio::runtime::spawn({
        let pool = pool.clone();
        async move { pool.acquire().await.map(|c| c.id) }
    });

    // Let the spawned task reach the wait queue, then hand the connection back.
    compio::time::sleep(Duration::from_millis(20)).await;
    drop(held);

    let id = waiting
        .await
        .unwrap()
        .expect("waiter should be handed the returned conn");
    assert_eq!(id, 0);
    assert_eq!(pool.metrics().waits, 1);
}

/// A waiter that is notified and then cancelled before it can be polled must
/// hand the wakeup on, or the connection sits idle while the next waiter sleeps.
/// Driven by hand rather than by timers, so the interleaving is exact.
#[compio::test]
async fn a_cancelled_waiter_hands_its_wakeup_to_the_next() {
    use std::{
        future::Future,
        task::{Context, Poll, Waker},
    };

    let pool = Pool::new(LocalManager::new(), cfg().max_size(1).acquire_timeout(None));
    let held = pool.acquire().await.unwrap();

    let mut cx = Context::from_waker(Waker::noop());
    let mut first = Box::pin(pool.acquire());
    let mut second = Box::pin(pool.acquire());

    // Both futures reach the wait queue, in order.
    assert!(matches!(first.as_mut().poll(&mut cx), Poll::Pending));
    assert!(matches!(second.as_mut().poll(&mut cx), Poll::Pending));

    // Returning the connection notifies `first`; `first` is then cancelled
    // before it is ever polled again. This is the exact interleaving that
    // loses a wakeup if `WaitForSlot::drop` does not pass it along.
    drop(held);
    drop(first);

    match second.as_mut().poll(&mut cx) {
        Poll::Ready(Ok(conn)) => assert_eq!(conn.id, 0),
        Poll::Ready(Err(e)) => panic!("unexpected error: {e:?}"),
        Poll::Pending => panic!("the cancelled waiter swallowed its wakeup"),
    }
}

#[compio::test]
async fn dropping_an_op_guard_poisons_the_connection() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(4));

    {
        let conn = pool.acquire().await.unwrap();
        let _op = conn.begin_op(); // never completed: models a cancelled await
        assert!(!conn.is_poisoned());
        drop(_op);
        assert!(conn.is_poisoned(), "a dropped OpGuard must poison");
    }

    assert_eq!(
        counts.disconnected.load(SeqCst),
        1,
        "poisoned conn must be closed"
    );
    assert_eq!(pool.metrics().poisoned, 1);
    assert_eq!(pool.local_idle(), 0, "a poisoned conn must not be pooled");

    // Completing the op leaves the connection reusable.
    {
        let conn = pool.acquire().await.unwrap();
        conn.begin_op().complete_op();
    }
    assert_eq!(pool.local_idle(), 1);
    assert_eq!(counts.disconnected.load(SeqCst), 1);
}

#[compio::test]
async fn invalidate_retires_existing_connections() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(4));

    let old_id = pool.acquire().await.unwrap().id;
    assert_eq!(pool.local_idle(), 1);

    pool.invalidate();

    let new_id = pool.acquire().await.unwrap().id;
    assert_ne!(old_id, new_id, "stale-generation conn must not be reused");
    assert_eq!(counts.connected.load(SeqCst), 2);
    assert_eq!(counts.disconnected.load(SeqCst), 1);
}

#[compio::test]
async fn max_uses_retires_a_connection() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(2).max_uses(2u64));

    for _ in 0..2 {
        let _c = pool.acquire().await.unwrap();
    }
    // Two checkouts done. `release` stamps `uses` before testing expiry, so the
    // second return is what retires it; this third call dials a replacement.
    let _c = pool.acquire().await.unwrap();
    assert_eq!(counts.connected.load(SeqCst), 2);
    assert_eq!(counts.disconnected.load(SeqCst), 1);
}

#[compio::test]
async fn a_failed_recycle_discards_and_redials() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(4));

    let first = pool.acquire().await.unwrap().id;
    pool.manager().set_fail_recycle(true);

    let second = pool.acquire().await.unwrap().id;
    assert_ne!(first, second);
    assert_eq!(counts.disconnected.load(SeqCst), 1);
    assert_eq!(pool.metrics().recycle_failures, 1);
}

#[compio::test]
async fn close_rejects_further_checkouts() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(4));

    let conn = pool.acquire().await.unwrap();
    pool.close();
    assert!(pool.is_closed());

    drop(conn);
    assert_eq!(
        counts.disconnected.load(SeqCst),
        1,
        "returned conn closes, not pools"
    );
    assert!(matches!(pool.acquire().await, Err(Error::Closed)));
}

#[compio::test]
async fn warm_preopens_min_idle_on_this_thread() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(8).min_idle(3));

    pool.warm().await.unwrap();
    assert_eq!(counts.connected.load(SeqCst), 3);
    assert_eq!(pool.local_idle(), 3);
}

/// Each compio thread gets its own shard, so `max_size` is per thread and a
/// connection opened on one thread is never handed to another.
#[test]
fn shards_are_independent_per_thread() {
    let manager = LocalManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(1));

    let barrier = Arc::new(Barrier::new(2));
    let threads: Vec<_> = (0..2)
        .map(|_| {
            let pool = pool.clone();
            let barrier = barrier.clone();
            std::thread::spawn(move || {
                compio::runtime::Runtime::new()
                    .unwrap()
                    .block_on(async move {
                        let conn = pool.acquire().await.unwrap();
                        let id = conn.id;
                        // Hold it while the other thread acquires: if the shards
                        // were shared, one of the two would time out at max_size 1.
                        barrier.wait();
                        drop(conn);
                        id
                    })
            })
        })
        .collect();

    let mut ids: Vec<u64> = threads.into_iter().map(|t| t.join().unwrap()).collect();
    ids.sort();
    assert_eq!(ids, vec![0, 1], "each thread dialled its own connection");
    assert_eq!(counts.connected.load(SeqCst), 2);
    // Both threads have exited, so their thread-locals dropped and each shard
    // closed the connection it owned - on the thread whose driver owned it.
    assert_eq!(pool.metrics().live, 0);
    assert_eq!(pool.metrics().closed, 2);
    assert_eq!(
        counts.disconnected.load(SeqCst),
        2,
        "thread exit must close connections through the manager"
    );
}

/// With a `Reservoir`, an idle connection from one thread is picked up by
/// another instead of sitting unused.
#[test]
fn reservoir_moves_a_connection_between_threads() {
    // Without the lock, a sibling test flipping `CAN_ATTACH` can make thread B's
    // reattach fail, and B then dials instead of claiming.
    let _lock = common::detach_lock();
    let manager = MovableManager::new();
    let counts = manager.counts();
    let pool = Pool::builder(manager)
        .config(cfg().max_size(4).min_idle(0))
        .exchange(Reservoir::new(8))
        .build();

    // Thread A opens one connection and returns it; min_idle is 0, so the
    // surplus is parked in the shared reservoir rather than kept locally.
    let a = std::thread::spawn({
        let pool = pool.clone();
        move || {
            compio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move {
                    let conn = pool.acquire().await.unwrap();
                    conn.id
                })
        }
    });
    let parked_id = a.join().unwrap();
    assert_eq!(
        pool.metrics().parked,
        1,
        "surplus should be parked, not held"
    );
    assert_eq!(pool.metrics().live, 0, "a parked conn belongs to no shard");

    // Thread B claims it rather than dialling again.
    let b = std::thread::spawn({
        let pool = pool.clone();
        move || {
            compio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move {
                    let conn = pool.acquire().await.unwrap();
                    conn.id
                })
        }
    });
    let claimed_id = b.join().unwrap();

    assert_eq!(
        claimed_id, parked_id,
        "thread B should reuse thread A's connection"
    );
    assert_eq!(
        counts.connected.load(SeqCst),
        1,
        "exactly one dial across both threads"
    );
    assert_eq!(pool.metrics().unparked, 1);
}

/// Without an exchange, the same workload dials once per thread.
#[test]
fn without_an_exchange_threads_do_not_share() {
    let manager = MovableManager::new();
    let counts = manager.counts();
    let pool = Pool::new(manager, cfg().max_size(4));

    for _ in 0..2 {
        let pool = pool.clone();
        std::thread::spawn(move || {
            compio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move { pool.acquire().await.unwrap().id })
        })
        .join()
        .unwrap();
    }

    assert_eq!(counts.connected.load(SeqCst), 2);
    assert_eq!(pool.metrics().parked, 0);
}

/// `acquire` claims shard budget *before* awaiting `connect`, and `acquire` is
/// itself cancellable via the acquire timeout. If a cancelled attempt did not
/// give the budget back, a shard would silently lose capacity on every hung
/// dial until it deadlocked at `max_size` with zero real connections.
#[compio::test]
async fn a_timed_out_dial_gives_its_shard_budget_back() {
    let pool = Pool::new(
        LocalManager::new(),
        cfg().max_size(1).acquire_timeout(Duration::from_millis(30)),
    );
    pool.manager().set_hang_connect(true);

    for _ in 0..3 {
        assert!(matches!(pool.acquire().await, Err(Error::Timeout)));
        assert_eq!(pool.local_size(), 0, "cancelled dial leaked shard capacity");
    }

    // The shard must still be able to serve a connection afterwards.
    pool.manager().set_hang_connect(false);
    assert!(
        pool.acquire().await.is_ok(),
        "shard deadlocked after hung dials"
    );
}

/// The reservoir is bounded: surplus beyond its capacity is handed back and
/// stays on the shard rather than being silently dropped.
#[compio::test]
async fn reservoir_capacity_is_respected() {
    let _lock = common::detach_lock();
    let manager = MovableManager::new();
    let pool = Pool::builder(manager)
        .config(cfg().max_size(4).min_idle(0))
        .exchange(Reservoir::new(2))
        .build();

    // Hold all four at once so each one is a separate connection, then let
    // them all go back at the same time.
    let held: Vec<_> = {
        let mut v = Vec::new();
        for _ in 0..4 {
            v.push(pool.acquire().await.unwrap());
        }
        v
    };
    drop(held);

    let m = pool.metrics();
    assert_eq!(
        m.parked, 2,
        "the reservoir should fill to capacity and stop"
    );
    assert_eq!(pool.local_idle(), 2, "the refused two stay on this shard");
    assert_eq!(m.live, 2, "a parked connection belongs to no shard");
    assert_eq!(m.closed, 0, "refusing an offer must not close anything");
}

/// A connection that cannot be detached is consumed by `detach`, so the pool
/// has to account for it as closed rather than leak it out of the counters.
#[compio::test]
async fn a_connection_that_cannot_detach_is_closed() {
    let _lock = common::detach_lock();
    let manager = MovableManager::new();
    let counts = manager.counts();
    let pool = Pool::builder(manager)
        .config(cfg().max_size(2).min_idle(0))
        .exchange(Reservoir::new(8))
        .build();

    common::CAN_DETACH.store(false, SeqCst);

    let held: Vec<_> = {
        let mut v = Vec::new();
        for _ in 0..2 {
            v.push(pool.acquire().await.unwrap());
        }
        v
    };
    drop(held);

    let m = pool.metrics();
    assert_eq!(m.parked, 0, "nothing could be made portable");
    assert_eq!(m.live, 0, "and nothing was kept");
    assert_eq!(
        m.closed, 2,
        "a connection consumed by a failed detach must still be counted closed"
    );
    assert_eq!(m.created, m.closed, "created and closed must balance");
    assert_eq!(
        counts.disconnected.load(SeqCst),
        0,
        "detach consumed them, so `disconnect` never saw them"
    );
}

/// A parked connection that cannot be reattached is counted closed, and the
/// claiming thread falls back to dialling.
#[test]
fn a_failed_attach_is_counted_closed_and_redials() {
    let _lock = common::detach_lock();
    let manager = MovableManager::new();
    let counts = manager.counts();
    let pool = Pool::builder(manager)
        .config(cfg().max_size(2).min_idle(0))
        .exchange(Reservoir::new(8))
        .build();

    // Thread A parks one.
    std::thread::spawn({
        let pool = pool.clone();
        move || {
            compio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move {
                    pool.acquire().await.unwrap();
                })
        }
    })
    .join()
    .unwrap();
    assert_eq!(pool.metrics().parked, 1);
    assert_eq!(counts.connected.load(SeqCst), 1);

    // Thread B pops it, fails to reattach, and dials instead.
    common::CAN_ATTACH.store(false, SeqCst);
    std::thread::spawn({
        let pool = pool.clone();
        move || {
            compio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move {
                    pool.acquire().await.unwrap();
                })
        }
    })
    .join()
    .unwrap();

    let m = pool.metrics();
    assert_eq!(counts.connected.load(SeqCst), 2, "B had to dial its own");
    assert!(
        m.closed >= 1,
        "the connection lost to a failed attach must be counted closed, got {}",
        m.closed
    );
}

/// Many threads parking and stealing at once must not lose or duplicate a
/// connection: everything ever created is live, parked, or closed.
#[test]
fn concurrent_parking_and_stealing_conserves_connections() {
    let _lock = common::detach_lock();
    let manager = MovableManager::new();
    let pool = Pool::builder(manager)
        .config(cfg().max_size(4).min_idle(0))
        .exchange(Reservoir::new(16))
        .build();

    let threads: Vec<_> = (0..8)
        .map(|_| {
            let pool = pool.clone();
            std::thread::spawn(move || {
                compio::runtime::Runtime::new()
                    .unwrap()
                    .block_on(async move {
                        for _ in 0..200 {
                            let a = pool.acquire().await.unwrap();
                            let b = pool.acquire().await.unwrap();
                            drop(a);
                            drop(b);
                        }
                    })
            })
        })
        .collect();
    for t in threads {
        t.join().unwrap();
    }

    let m = pool.metrics();
    assert_eq!(m.live, 0, "every shard is gone, so nothing is held locally");
    assert_eq!(
        m.created,
        m.closed + m.parked,
        "created must equal closed + parked: {m:?}"
    );
    assert!(
        m.parked <= 16,
        "the reservoir must never exceed its capacity, got {}",
        m.parked
    );
    assert!(m.unparked > 0, "threads should have stolen from each other");
}