Skip to content
rusthacks.dev
Concurrency

Build a Connection Pool in Rust and Learn What Pools Do

A pool hands out reusable connections, caps how many exist and takes them back on drop. Building a small one explains the knobs on SQLx and deadpool.

21 min read
Summarize in AI
Flat illustration for the post “Build a Connection Pool in Rust and Learn What Pools Do”, composed around database.

Point 400 queries at a database that allows 20 connections, open a fresh connection for every query, and 285 of them fail before they run. Give the same workload a pool of four connections and all 400 succeed with a 99th percentile of 173 ms. This post builds that pool out of a semaphore, a Mutex<Vec<_>> and a Drop impl, and then uses it to measure the one setting every pool exposes that people get wrong: its size.

Everything below compiles and runs on Rust 1.99 with edition 2024, and the only dependency is Tokio, which Cargo resolved to 1.54.1. The database is fake: a struct with a connection limit, a number of cores and two sleeps. That keeps the experiment repeatable on a laptop, and it also means the numbers describe the pool's behavior and not any real server's. You should be comfortable with async functions and Arc. If Semaphore is new, limiting concurrency with a semaphore is the five-minute version.

Four parts and one invariant

A pool has four parts, and each one answers a single question.

  1. A semaphore with max permits. Holding a permit means you may have a connection checked out. It answers "may I?".
  2. An idle list of connections that are open and unused. It answers "is there one ready?".
  3. A factory that opens a new connection when the idle list is empty. It answers "what if not?".
  4. A guard that dereferences to the connection and hands it back in Drop. It answers "who gives it back?".

The invariant that ties them together is short: the number of permits in circulation equals the number of connections that are allowed to exist. A task that holds a permit is therefore guaranteed room for one connection, either an idle one it can pop or a new one it may open. The timeout, the lifetime check and the discard method below are all bookkeeping around that sentence.

The pool in about a hundred lines

The crate is a library plus a few binaries. The manifest is as small as it gets:

Cargo.tomlTOML
[package]
name = "pool"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }

And the pool itself:

src/lib.rsrust
pub mod fake;

use std::fmt;
use std::future::Future;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};

#[derive(Debug)]
pub enum PoolError {
    Timeout,
    Connect(String),
}

impl fmt::Display for PoolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PoolError::Timeout => write!(f, "timed out waiting for a connection"),
            PoolError::Connect(why) => write!(f, "could not connect: {why}"),
        }
    }
}

impl std::error::Error for PoolError {}

pub trait Factory: Send + Sync + 'static {
    type Conn: Send + 'static;
    fn connect(&self) -> impl Future<Output = Result<Self::Conn, String>> + Send;
    fn is_valid(&self, conn: &Self::Conn) -> bool;
}

struct Idle<C> {
    conn: C,
    born: Instant,
}

struct Inner<F: Factory> {
    factory: F,
    idle: Mutex<Vec<Idle<F::Conn>>>,
    permits: Arc<Semaphore>,
    max_lifetime: Duration,
    opened: AtomicUsize,
}

pub struct Pool<F: Factory>(Arc<Inner<F>>);

impl<F: Factory> Clone for Pool<F> {
    fn clone(&self) -> Self {
        Pool(Arc::clone(&self.0))
    }
}

pub struct Pooled<F: Factory> {
    conn: Option<Idle<F::Conn>>,
    pool: Pool<F>,
    // Dropped after Drop::drop has pushed the connection back, so a woken waiter finds it.
    _permit: OwnedSemaphorePermit,
}

impl<F: Factory> Pool<F> {
    pub fn new(factory: F, max: usize, max_lifetime: Duration) -> Self {
        Pool(Arc::new(Inner {
            factory,
            idle: Mutex::new(Vec::new()),
            permits: Arc::new(Semaphore::new(max)),
            max_lifetime,
            opened: AtomicUsize::new(0),
        }))
    }

    pub async fn get(&self, wait: Duration) -> Result<Pooled<F>, PoolError> {
        let permit = tokio::time::timeout(wait, Arc::clone(&self.0.permits).acquire_owned())
            .await
            .map_err(|_| PoolError::Timeout)?
            .expect("the semaphore is never closed");

        let reused = loop {
            let Some(idle) = self.0.idle.lock().unwrap().pop() else {
                break None;
            };
            if idle.born.elapsed() < self.0.max_lifetime && self.0.factory.is_valid(&idle.conn) {
                break Some(idle);
            }
        };

        let idle = match reused {
            Some(idle) => idle,
            None => {
                let conn = self.0.factory.connect().await.map_err(PoolError::Connect)?;
                self.0.opened.fetch_add(1, Ordering::Relaxed);
                Idle {
                    conn,
                    born: Instant::now(),
                }
            }
        };
        Ok(Pooled {
            conn: Some(idle),
            pool: self.clone(),
            _permit: permit,
        })
    }

    pub fn opened(&self) -> usize {
        self.0.opened.load(Ordering::Relaxed)
    }

    pub fn idle(&self) -> usize {
        self.0.idle.lock().unwrap().len()
    }
}

impl<F: Factory> Pooled<F> {
    /// Close the connection instead of returning it, after an error that left it unusable.
    pub fn discard(mut self) {
        self.conn = None;
    }
}

impl<F: Factory> Deref for Pooled<F> {
    type Target = F::Conn;
    fn deref(&self) -> &Self::Target {
        &self.conn.as_ref().expect("present until drop").conn
    }
}

impl<F: Factory> DerefMut for Pooled<F> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.conn.as_mut().expect("present until drop").conn
    }
}

impl<F: Factory> Drop for Pooled<F> {
    fn drop(&mut self) {
        if let Some(idle) = self.conn.take() {
            self.pool.0.idle.lock().unwrap().push(idle);
        }
    }
}

Start with Factory. Its connect returns impl Future<Output = ...> + Send instead of being declared async fn. Both forms work in a trait since Rust 1.75, but the explicit form lets us demand Send, and every future that ends up inside tokio::spawn has to be Send. Without that bound, a caller who spawns a task that calls pool.get would get an error about a future they never wrote. is_valid is synchronous on purpose: the cheap check is "is the socket still open and young", and a check that needs a round trip belongs in a different method.

Pool is an Arc<Inner>, so cloning it into a hundred tasks copies one pointer. Inner holds the factory, the idle list behind a standard-library Mutex, the semaphore wrapped in its own Arc, the maximum lifetime and a counter of connections opened. Every Idle remembers when its connection was born, which is what max_lifetime compares against.

get does three things in order. First it waits for a permit, and only that wait is under the timeout. acquire_owned is the important call here, and we come back to it in the failing example below. Second it looks for a reusable connection: pop takes the most recently returned one, and a connection that is older than max_lifetime or fails is_valid is dropped and the loop pops the next. Taking the newest first keeps a small set of connections hot and lets the rest age out, which is what you want after a traffic spike. Third, if nothing was reusable, it calls the factory. The result goes into a Pooled guard that carries the connection, a clone of the pool and the permit.

Pooled is where the safety comes from. It implements Deref and DerefMut so a caller writes conn.query() as if it owned the connection, and Drop pushes the connection back. The connection sits in an Option because Drop::drop only gets &mut self, and take() is how we move the value out of it. discard does the opposite: it sets the option to None, so the connection is dropped (and closed) instead of being returned, while the permit is still released. A caller that cannot forget to release is the reason to build it this way. With a manual release(conn) function, every early return and every ? is a leak waiting to happen.

A fake database to test against

To see what a pool does, it needs something to protect. This module models the two limits that matter on a real database server: a cap on open connections, and a fixed number of cores that execute queries.

src/fake.rsrust
use crate::Factory;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::time::sleep;

/// A database with a connection limit and a fixed number of cores.
pub struct Server {
    limit: usize,
    open: AtomicUsize,
    cores: Semaphore,
    connect_cost: Duration,
    query_cost: Duration,
}

pub struct Conn {
    server: Arc<Server>,
}

impl Server {
    pub fn new(limit: usize, cores: usize, connect_ms: u64, query_ms: u64) -> Arc<Server> {
        Arc::new(Server {
            limit,
            open: AtomicUsize::new(0),
            cores: Semaphore::new(cores),
            connect_cost: Duration::from_millis(connect_ms),
            query_cost: Duration::from_millis(query_ms),
        })
    }

    pub async fn connect(self: &Arc<Self>) -> Result<Conn, String> {
        sleep(self.connect_cost).await;
        if self.open.fetch_add(1, Ordering::SeqCst) >= self.limit {
            self.open.fetch_sub(1, Ordering::SeqCst);
            return Err("FATAL: sorry, too many clients already".to_string());
        }
        Ok(Conn {
            server: Arc::clone(self),
        })
    }

    pub fn open(&self) -> usize {
        self.open.load(Ordering::SeqCst)
    }
}

impl Conn {
    pub async fn query(&self) {
        let _core = self.server.cores.acquire().await.unwrap();
        sleep(self.server.query_cost).await;
    }
}

impl Drop for Conn {
    fn drop(&mut self) {
        self.server.open.fetch_sub(1, Ordering::SeqCst);
    }
}

pub struct ServerFactory(pub Arc<Server>);

impl Factory for ServerFactory {
    type Conn = Conn;

    async fn connect(&self) -> Result<Conn, String> {
        self.0.connect().await
    }

    fn is_valid(&self, _conn: &Conn) -> bool {
        true
    }
}

connect sleeps for the connection cost first and then checks the limit, which is how a real server behaves: the handshake is paid for, and then the server says "too many clients". The error string is the one PostgreSQL uses. query takes a permit from the cores semaphore before it sleeps, so at most cores queries make progress at once and the rest queue, exactly as a saturated CPU would queue them. Conn decrements the open count in its own Drop, so a discarded connection really does free a slot on the server. ServerFactory is the adapter that lets the pool open connections against it.

Reuse, exhaustion and discard, run once

Before measuring anything, a single program that shows each behavior in turn:

src/main.rsrust
use pool::fake::{Server, ServerFactory};
use pool::{Pool, PoolError};
use std::time::{Duration, Instant};

#[tokio::main]
async fn main() {
    let server = Server::new(20, 4, 20, 5);
    let pool = Pool::new(ServerFactory(server.clone()), 4, Duration::from_secs(1800));

    // Reuse: 12 sequential queries open one connection, not twelve.
    for _ in 0..12 {
        let conn = pool
            .get(Duration::from_secs(1))
            .await
            .expect("pool has room");
        conn.query().await;
    }
    println!(
        "after 12 sequential queries: opened={} idle={}",
        pool.opened(),
        pool.idle()
    );

    // Exhaustion: hold all four, ask for a fifth with a 50 ms timeout.
    let mut held = Vec::new();
    for _ in 0..4 {
        held.push(
            pool.get(Duration::from_secs(1))
                .await
                .expect("pool has room"),
        );
    }
    let started = Instant::now();
    match pool.get(Duration::from_millis(50)).await {
        Err(PoolError::Timeout) => println!(
            "fifth get failed after {} ms: {}",
            started.elapsed().as_millis(),
            PoolError::Timeout
        ),
        Err(other) => println!("unexpected: {other}"),
        Ok(_) => println!("unexpected: the pool had room"),
    }
    println!("held={} server_open={}", held.len(), server.open());

    // Return one, and the next caller gets it without a new connection.
    held.pop();
    let before = pool.opened();
    let again = pool
        .get(Duration::from_millis(50))
        .await
        .expect("one slot was returned");
    println!(
        "reused after drop: new connections={}",
        pool.opened() - before
    );

    // Discard: a broken connection leaves the pool instead of coming back.
    let idle_before = pool.idle();
    again.discard();
    println!(
        "idle before={} after discard={} server_open={}",
        idle_before,
        pool.idle(),
        server.open()
    );
}
Text
after 12 sequential queries: opened=1 idle=1
fifth get failed after 52 ms: timed out waiting for a connection
held=4 server_open=4
reused after drop: new connections=0
idle before=0 after discard=0 server_open=3

Read the output against the code. Twelve sequential queries opened exactly one connection, so opened=1 idle=1: each guard went back to the idle list before the next get. Then the program holds all four permits and asks for a fifth with a 50 ms timeout. The error arrives at 52 ms, not at 50, because a timeout is a floor and the task still has to be scheduled once the timer fires. Note server_open=4: the pool never asked the server for a fifth connection, because the permit check comes before the factory is called. After one guard is dropped, the next get finds it in the idle list and opens nothing new. Finally discard removes the connection from circulation: idle stays at 0 and server_open falls from 4 to 3, because dropping the Conn told the server.

Two ways the guard refuses to compile

The pool's shape is not arbitrary. Two natural first attempts fail, and each failure teaches something about why the code looks the way it does.

The first is using the borrowed permit. Semaphore::acquire is the method most examples show, and it returns a SemaphorePermit that borrows the semaphore. That is fine inside one function. It breaks the moment the guard has to travel into a spawned task:

examples/fail_borrow.rsrust
use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let permits = Arc::new(Semaphore::new(4));
    let permit = permits.acquire().await.unwrap();

    tokio::spawn(async move {
        let _held = permit;
    })
    .await
    .unwrap();
}
Text
error[E0597]: `permits` does not live long enough
   --> examples/fail_borrow.rs:7:18
    |
  6 |       let permits = Arc::new(Semaphore::new(4));
    |           ------- binding `permits` declared here
  7 |       let permit = permits.acquire().await.unwrap();
    |                    ^^^^^^^ borrowed value does not live long enough
  8 |
  9 | /     tokio::spawn(async move {
 10 | |         let _held = permit;
 11 | |     })
    | |______- argument requires that `permits` is borrowed for `'static`
...
 14 |   }
    |   - `permits` dropped here while still borrowed
    |
note: requirement that the value outlives `'static` introduced here
   --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.54.1/src/task/spawn.rs:176:28
    |
176 |         F: Future + Send + 'static,
    |                            ^^^^^^^

For more information about this error, try `rustc --explain E0597`.
error: could not compile `pool` (example "fail_borrow") due to 1 previous error

The compiler is telling you that the permit holds a reference to permits, tokio::spawn needs a future that is 'static, and permits is a local that dies at the end of main. For a pool the guard is handed to arbitrary callers who may spawn, so it cannot borrow from anything on someone else's stack. The fix is the one get uses: Arc::clone(&self.0.permits).acquire_owned(). It consumes an Arc<Semaphore> and returns an OwnedSemaphorePermit with no lifetime parameter, so the guard is 'static and can go anywhere. It is also why the semaphore sits inside its own Arc in Inner.

The second mistake is holding the idle-list lock while connecting. It looks harmless in a first draft, because the lock is right there when you find the list empty:

examples/fail_guard.rsrust
use std::sync::Mutex;
use std::time::Duration;

struct Pool {
    idle: Mutex<Vec<u32>>,
}

impl Pool {
    async fn get(&self) -> u32 {
        let mut idle = self.idle.lock().unwrap();
        match idle.pop() {
            Some(conn) => conn,
            None => {
                tokio::time::sleep(Duration::from_millis(20)).await;
                idle.len() as u32
            }
        }
    }
}

#[tokio::main]
async fn main() {
    let pool = std::sync::Arc::new(Pool {
        idle: Mutex::new(vec![]),
    });
    let p = pool.clone();
    let id = tokio::spawn(async move { p.get().await }).await.unwrap();
    println!("{id}");
}
Text
error: future cannot be sent between threads safely
   --> examples/fail_guard.rs:25:14
    |
 25 |     let id = tokio::spawn(async move { p.get().await }).await.unwrap();
    |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ future created by async block is not `Send`
    |
    = help: within `{async block@examples/fail_guard.rs:25:27: 25:37}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, Vec<u32>>`
note: future is not `Send` as this value is used across an await
   --> examples/fail_guard.rs:14:63
    |
 10 |         let mut idle = self.idle.lock().unwrap();
    |             -------- has type `std::sync::MutexGuard<'_, Vec<u32>>` which is not `Send`
...
 14 |                 tokio::time::sleep(Duration::from_millis(20)).await;
    |                                                               ^^^^^ await occurs here, with `mut idle` maybe used later
note: required by a bound in `tokio::spawn`
   --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.54.1/src/task/spawn.rs:176:21
    |
174 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
175 |     where
176 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

error: could not compile `pool` (example "fail_guard") due to 1 previous error

A std::sync::MutexGuard is not Send, so a future that keeps one alive across an .await cannot be moved between Tokio's worker threads. The message points at the exact .await and the binding that is still alive. There is a second reason to avoid it that the compiler cannot flag: even if it compiled, every other task would block on the list for the full 20 ms of a connect. In the real get, the lock is taken in the statement let Some(idle) = self.0.idle.lock().unwrap().pop() else, and the guard is a temporary that ends at the semicolon, well before the connect().await. The rule of thumb is that a standard mutex is fine when the critical section is a pop or a push and never contains an await. Tokio's mutex versus the standard one covers when that stops being true.

Size the pool to the server's cores, not to your task count

Now the measurement. The benchmark spawns 100 tasks that each run 4 queries, 400 in all, against a fake server that allows 20 connections, runs queries on 4 cores, spends 5 ms of core time per query and takes 20 ms to connect. The first row opens a connection for every query. The others use a pool of each size, with a 30 second acquire timeout so that nothing times out and we see queueing instead. The binary was built with --release and the machine was an Apple M1 Max.

StrategyTotalSucceededFailedp50p99
Connect per query208 ms11528550 ms54 ms
Pool of 21,310 ms4000320 ms331 ms
Pool of 4667 ms4000160 ms173 ms
Pool of 8679 ms4000163 ms183 ms
Pool of 16722 ms4000164 ms204 ms
Pool of 20721 ms4000172 ms192 ms
Pool of 40299 ms15324771 ms96 ms

Three things stand out. The first row looks fastest (208 ms), and it is the most misleading: 285 of the 400 queries failed with FATAL: sorry, too many clients already, and the 50 ms median describes only the 115 lucky ones. A pool of 40 does the same thing for the same reason, because its cap is above the server's limit of 20. A pool does not repair a bad configuration; it moves the failure from "every request" to "the requests that arrive after the server is full".

The second is the flat region. Pools of 4, 8, 16 and 20 finish in about the same time. The floor for this workload is 400 queries times 5 ms divided by 4 cores, which is 500 ms. We measure 667 ms because a 5 ms sleep takes closer to 6.6 ms in practice (400 times 6.6 divided by 4 is 660), and that timer overhead is the same in every row. Once the pool is as large as the server's parallelism, extra connections add nothing but memory on the server. Their only effect is to move waiting from the pool, where you control the timeout and can see the queue, into the database's run queue, where you cannot.

The third is the cliff on the left. A pool of 2 uses half the cores and takes twice as long, 1,310 ms. That asymmetry is why the usual advice is "smaller than you think" with an asterisk: too small costs you throughput linearly, too large costs you nothing visible until the day it costs you everything.

To check that the knee follows the server and not the code, the same benchmark against an 8 core server:

StrategyTotalSucceededFailedp50p99
Connect per query150 ms13326735 ms47 ms
Pool of 21,391 ms4000337 ms361 ms
Pool of 4720 ms4000172 ms189 ms
Pool of 8385 ms400087 ms113 ms
Pool of 16373 ms400085 ms110 ms
Pool of 20369 ms400084 ms106 ms
Pool of 40214 ms21218848 ms62 ms

Now the pool of 4 is the one at half speed, and 8 is where the curve flattens. The formula for this lab is pool size = server cores, and the way to find your own input is to count what actually executes queries: the core count on the database host, or the vCPU count of the instance, not the number of application tasks. Then multiply by the number of application instances, because every process has its own pool. Twelve instances with a pool of 10 each is 120 connections, and against a server limited to 100 the eleventh instance is the one that cannot connect, usually during a scale-out at 3 a.m. with no code change. The arithmetic belongs in the deploy checklist.

This lab says nothing about queries that wait on disk or on a lock. Those leave their core idle, so a real workload usually runs best a little above the core count, and the right number comes from watching latency while you raise it. The SQLx pool sizing post shows where to read those signals.

Release the connection before you wait on something else

A pool is only as good as its checkout discipline. The most common way to starve one is to keep the guard alive while doing unrelated work, such as calling another service. Here is the experiment, with two variants of one handler:

examples/hold.rsrust
async fn handle(pool: Pool<ServerFactory>, hold_during_call: bool) {
    let conn = pool.get(Duration::from_secs(30)).await.expect("pool has room");
    conn.query().await;
    if hold_during_call {
        tokio::time::sleep(OUTBOUND_CALL).await;
        drop(conn);
    } else {
        drop(conn);
        tokio::time::sleep(OUTBOUND_CALL).await;
    }
}
Text
60 requests, pool of 8, each does a 5 ms query and a 50 ms outbound call
connection held during the call: 483 ms
connection released first:       168 ms

Sixty requests, a pool of 8, a 5 ms query and a 50 ms outbound call. When the guard lives through the call, each request occupies a connection for about 55 ms, so the pool serves 8 at a time and the 60 requests need about 7.5 rounds: 483 ms. When the guard is dropped before the call, the connection is busy for 5 ms and the 50 ms call overlaps freely: 168 ms. Same pool, same server, a one line move of drop(conn), and a request that is nearly three times faster. Nothing in the code looks wrong in the first version, which is why this shows up as "the database is slow" in a dashboard while the database is idle.

The permit goes last

One detail in the Pooled struct deserves a comment, and it has one. The permit is a field, so it is dropped after Drop::drop has run, and Drop::drop is what pushes the connection back into the idle list. The order matters. If the permit were released first, a waiter could wake, find the idle list empty and open a new connection while the old one was still on its way back, which would put the pool over its own cap.

We tested that by flipping it. The experimental copy stored the permit in an Option and released it at the top of drop:

Diff
 impl<F: Factory> Drop for Pooled<F> {
     fn drop(&mut self) {
+        drop(self.permit.take());
         if let Some(idle) = self.conn.take() {
             self.pool.0.idle.lock().unwrap().push(idle);
         }
     }
 }

Run through the benchmark 30 times at pool size 4, it opened exactly 4 connections every time. We could not reproduce the failure, and we are not going to pretend otherwise. The window between the release and the push is a few nanoseconds, and a test suite will never land in it. What the ordering buys is that the question never comes up: with the permit released last, the invariant holds by construction, and nobody has to find the race in production under load. When a bug cannot be provoked on demand, make it unrepresentable.

Broken connections, old connections, stale sockets

Three situations make an idle connection unusable, and a pool has to handle each one at a different moment.

A connection that failed mid-query should not go back. The caller knows, because it saw the error, so discard is a method on the guard: it closes the connection and frees the slot, as the discard line of the output above shows. A pool that returns a broken connection to the idle list hands the same failure to the next caller.

A connection that has lived too long should be replaced. max_lifetime exists for failovers and DNS changes: a connection opened a week ago may point at a replica that was demoted. The pool above checks the age at checkout. A real pool also checks in the background, so an idle connection does not sit past its lifetime for hours.

A connection that the other side closed is the sneaky one. Firewalls and managed databases drop idle sockets after a few minutes, and the pool finds out only when it uses one. is_valid is the hook. For a plain TCP socket it can be a non-blocking peek; for a database driver it may be a SELECT 1, which costs a round trip on every checkout. Many pools therefore validate only connections that have been idle longer than some threshold. We left is_valid returning true in the fake because the fake cannot go stale, but it is worth a thought in the first week of a real deployment.

Three timeouts, and the one this pool is missing

The timeout in get covers the wait for a permit. It converts "the pool is exhausted" from a hang into a PoolError::Timeout, which a web handler can turn into a 503 in 100 ms instead of keeping the client waiting for 30 seconds. Without it, pool exhaustion has no symptom except requests that never finish.

There are two more, and the code above has neither. A connect timeout bounds factory.connect(): a database that accepts the TCP connection and then never answers the handshake would otherwise hold both a permit and a task forever. It is one more tokio::time::timeout around the call, and a real pool needs it. A statement timeout is enforced by the server, so a runaway query is cancelled instead of keeping a connection busy long after the caller gave up. All three are covered with retry policy in database timeouts and retries, and the general mechanics of timeout are in Tokio timeouts.

What production pools add

The settings of any serious pool map straight onto the pieces above:

FeatureWhy it existsIn this pool
Maximum sizecap connections, protect the serverthe semaphore
Acquire timeoutturn exhaustion into an errorget(wait)
Max lifetimerotate connections across failoversmax_lifetime
Idle timeoutshrink after a traffic spikenot implemented
Minimum idleavoid a connection storm after a quiet periodnot implemented
Test on acquiredetect dead socketsis_valid
Connect timeoutbound the handshakenot implemented
Metricspool size, wait time, timeoutsopened() and idle() only

SQLx's pool and deadpool expose these under names you now recognize, and the real SQLx sizing advice is in the SQLx pool post.

Where writing your own pool stops being a good idea

Do not ship this one. It is a model for reading other pools, and it leaves out the unglamorous parts that cost real time: background reaping, a minimum of idle connections, graceful close, connect timeouts, cancellation tests and metrics. Use sqlx::Pool, deadpool or bb8, depending on the driver, and keep this file as the thing you open when their settings page is unclear.

Some systems do not need a pool at all. HTTP/2 and most Redis clients multiplex many requests over one connection, and adding a pool in front of a multiplexed client mostly adds a place to deadlock. SQLite has one writer, so a pool of write connections only creates lock contention. If PgBouncer or a managed proxy already sits in front of the database, the application pool should be small and its job is only to bound concurrency per process. And the sizing rule above is for CPU-bound database work: if your queries mostly wait on remote storage, measure upward from the core count instead of stopping there.

To take this further, replace ServerFactory with a real driver such as tokio-postgres, point the benchmark at a local database, and watch whether your own knee lands where the core count predicts. If it does not, the gap is how much of your workload is waiting rather than computing, which is the number worth knowing.

Taggedpool
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.