pg-wire-0.2.0.0: src/PgWire/Pool.hs
{-# LANGUAGE DataKinds #-}
-- | Thread-safe connection pool for PostgreSQL.
--
-- Manages a set of reusable 'Connection's with configurable pool size,
-- idle timeout, max lifetime, health checking, and lifecycle hooks.
-- Inspired by deadpool-postgres (Rust).
--
-- @
-- pool <- 'newPool' 'PgWire.Pool.Config.defaultPoolConfig'
-- { poolConnString = \"postgres:\/\/...\"
-- , poolSize = 10
-- }
-- 'withResource' pool $ \\conn -> ...
-- 'closePool' pool
-- @
module PgWire.Pool
( Pool (..)
, PoolStats (..)
, newPool
, closePool
, drainPool
, withResource
, poolStats
, poolIsAlive
, resize
, retain
, setPostCreateHook
, setOnAcquireHook
, setPreReleaseHook
, withResourceTimeout
) where
import GHC.Generics (Generic)
import NoThunks.Class (NoThunks (..), OnlyCheckWhnfNamed (..), allNoThunks)
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (Async, async, cancel, race)
import Control.Concurrent.STM
import Control.Exception (AsyncException, SomeException, catch, fromException, mask, onException, throwIO, try)
import Data.ByteString.Char8 qualified as BS8
import Data.IORef
import Data.Int (Int32)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Sequence (Seq (..))
import Data.Sequence qualified as Seq
import Data.Time (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import PgWire.Async (AsyncWireConn (..))
import PgWire.Cache.Sieve qualified as Sieve
import PgWire.Connection (Connection (..), close, connectString, simpleQuery)
import PgWire.Error (PgWireError (..), isFatal, throwPgWire)
import PgWire.Pool.Config (PoolConfig (..), QueueMode (..), RecyclingMethod (..))
import PgWire.Pool.Observation (PoolEvent (ConnectionAcquired, ConnectionCreated, ConnectionDestroyed, ConnectionRecycled, ConnectionReleased, HealthCheckFailed, AcquireTimeout, ReaperSwept, WarmerCreated, PoolResized, PoolShutdown))
import PgWire.TypeCache (TypeCache, newTypeCache)
import System.Random (randomRIO)
-- | A thread-safe connection pool for PostgreSQL.
--
-- Manages a bounded set of reusable 'Connection's with idle timeout, max lifetime
-- (with jitter to avoid thundering-herd reconnects), health checking, and lifecycle hooks.
-- The pool is safe to share across threads. Use 'newPool' to create and 'closePool' to shut down.
data Pool = Pool
{ pConfig :: PoolConfig
, pIdle :: TVar (Seq PoolEntry)
, pActive :: TVar Int
, pWaiters :: TVar (Seq (TMVar (Either PgWireError Connection)))
, pClosed :: TVar Bool
, pReaper :: IORef (Async ())
, pWarmer :: IORef (Maybe (Async ()))
, pEffectiveSize :: TVar Int
-- ^ Mutable pool size for runtime 'resize'.
, pTotalCreated :: TVar Int
, pTotalDestroyed :: TVar Int
, pTotalTimeouts :: TVar Int
, pConnMeta :: TVar (Map Int32 ConnMeta)
-- ^ Per-connection metadata keyed by backend PID.
-- Tracks creation time and jittered max-life for each connection.
, pOnCreate :: IORef (Connection -> IO ())
, pOnAcquire :: IORef (Connection -> IO ())
, pOnRelease :: IORef (Connection -> IO ())
, pTypeCache :: TypeCache
-- ^ Pool-level cache for resolved PG type metadata (OID → TypeInfo).
-- Avoids redundant @pg_type@ round-trips across connections.
}
-- | Per-connection metadata, stored by backend PID.
data ConnMeta = ConnMeta
{ cmCreatedAt :: !UTCTime
, cmDeadline :: !UTCTime
-- ^ Creation time + jittered max-life. Connection is expired after this.
}
deriving stock (Generic)
-- | 'UTCTime' is treated as opaque (its internal 'Day'/'DiffTime' are
-- lazy in the @time@ package). Bounded size, not a leak target.
instance NoThunks ConnMeta where
showTypeOf _ = "ConnMeta"
wNoThunks ctx (ConnMeta createdAt deadline) = allNoThunks
[ noThunks ctx (OnlyCheckWhnfNamed @"UTCTime" createdAt)
, noThunks ctx (OnlyCheckWhnfNamed @"UTCTime" deadline)
]
data PoolEntry = PoolEntry
{ peConn :: Connection
, peCreatedAt :: UTCTime
, peLastUsed :: IORef UTCTime
}
-- | Treats 'Connection', the @IORef UTCTime@, and 'UTCTime' itself as
-- opaque references (their internal state is not pool-bookkeeping and
-- 'UTCTime' has lazy fields in the @time@ package).
instance NoThunks PoolEntry where
showTypeOf _ = "PoolEntry"
wNoThunks ctx (PoolEntry conn createdAt lastUsed) = allNoThunks
[ noThunks ctx (OnlyCheckWhnfNamed @"Connection" conn)
, noThunks ctx (OnlyCheckWhnfNamed @"UTCTime" createdAt)
, noThunks ctx (OnlyCheckWhnfNamed @"IORef UTCTime" lastUsed)
]
-- | Snapshot of pool statistics at a point in time, obtained via 'poolStats'.
data PoolStats = PoolStats
{ psIdle :: !Int
-- ^ Number of connections currently idle in the pool.
, psInUse :: !Int
-- ^ Number of connections currently checked out by callers.
, psWaiters :: !Int
-- ^ Number of threads blocked waiting to acquire a connection.
, psMaxSize :: !Int
-- ^ Current maximum pool size (may differ from initial config after 'resize').
, psTotalCreated :: !Int
-- ^ Cumulative count of connections created since pool creation.
, psTotalDestroyed :: !Int
-- ^ Cumulative count of connections destroyed since pool creation.
, psTotalTimeouts :: !Int
-- ^ Cumulative count of acquire attempts that timed out.
}
deriving stock (Show, Eq, Generic)
instance NoThunks PoolStats
-- | Create a new connection pool from the given configuration.
--
-- Starts background threads for reaping idle\/expired connections and (if
-- @poolMinIdle > 0@) warming the pool to maintain a minimum number of idle
-- connections. No connections are created eagerly; the first 'withResource'
-- call triggers the initial connection.
--
-- Call 'closePool' when the pool is no longer needed to release all resources.
newPool :: PoolConfig -> IO Pool
newPool cfg = do
idle <- newTVarIO Seq.empty
active <- newTVarIO 0
waiters <- newTVarIO Seq.empty
closed <- newTVarIO False
reaperRef <- newIORef (error "reaper not started")
warmerRef <- newIORef Nothing
effectiveSize <- newTVarIO (poolSize cfg)
totalCreated <- newTVarIO 0
totalDestroyed <- newTVarIO 0
totalTimeouts <- newTVarIO 0
connMeta <- newTVarIO Map.empty
onCreate <- newIORef (\_ -> pure ())
onAcquire <- newIORef (\_ -> pure ())
onRelease <- newIORef (\_ -> pure ())
typeCache <- newTypeCache
let pool =
Pool
{ pConfig = cfg
, pIdle = idle
, pActive = active
, pWaiters = waiters
, pClosed = closed
, pReaper = reaperRef
, pWarmer = warmerRef
, pEffectiveSize = effectiveSize
, pTotalCreated = totalCreated
, pTotalDestroyed = totalDestroyed
, pTotalTimeouts = totalTimeouts
, pConnMeta = connMeta
, pOnCreate = onCreate
, pOnAcquire = onAcquire
, pOnRelease = onRelease
, pTypeCache = typeCache
}
reaper <- async (reaperThread pool)
writeIORef reaperRef reaper
-- Spawn warmer if poolMinIdle > 0
when (poolMinIdle cfg > 0) $ do
w <- async (warmerThread pool)
writeIORef warmerRef (Just w)
pure pool
-- | Close the pool, releasing all resources.
--
-- Closes every idle connection, cancels background threads (reaper and warmer),
-- and wakes all blocked waiters with a 'PoolClosed' error. After this call,
-- any subsequent 'withResource' will throw 'PoolClosed'. Idempotent: calling
-- 'closePool' on an already-closed pool is a no-op for the connection teardown.
closePool :: Pool -> IO ()
closePool pool = do
-- Atomically: mark closed, drain idle, drain waiters
(entries, blockedWaiters) <- atomically $ do
writeTVar (pClosed pool) True
idle <- readTVar (pIdle pool)
writeTVar (pIdle pool) Seq.empty
ws <- readTVar (pWaiters pool)
writeTVar (pWaiters pool) Seq.empty
pure (idle, ws)
-- Cancel background threads
reaper <- readIORef (pReaper pool)
cancel reaper
mWarmer <- readIORef (pWarmer pool)
mapM_ cancel mWarmer
-- Wake all blocked waiters with PoolClosed
mapM_ (\w -> atomically $ tryPutTMVar w (Left PoolClosed)) blockedWaiters
-- Close all idle connections (properly tracked)
mapM_ (destroyEntry pool "pool closed") entries
observe pool PoolShutdown
-- | Gracefully drain the pool for rolling deployments.
--
-- Marks the pool as closed (no new acquires), then polls until all
-- in-use connections are returned, then closes everything. Blocks
-- until the drain is complete or the timeout expires.
--
-- @
-- -- During rolling deployment:
-- drainPool pool 30 -- wait up to 30 seconds for in-flight queries
-- @
drainPool :: Pool -> NominalDiffTime -> IO ()
drainPool pool timeout = do
-- Mark as closed — new withResource calls will get PoolClosed
atomically $ writeTVar (pClosed pool) True
-- Wake blocked waiters immediately
ws <- atomically $ do
waiters <- readTVar (pWaiters pool)
writeTVar (pWaiters pool) Seq.empty
pure waiters
mapM_ (\w -> atomically $ tryPutTMVar w (Left PoolClosed)) ws
-- Poll until all active connections are returned or timeout
let timeoutMicros = round (timeout * 1000000) :: Int
pollInterval = 100000 -- 100ms
pollLoop !remaining
| remaining <= 0 = pure () -- timeout, force close
| otherwise = do
active <- readTVarIO (pActive pool)
idle <- Seq.length <$> readTVarIO (pIdle pool)
if active <= idle
then pure () -- all connections returned to idle
else do
threadDelay pollInterval
pollLoop (remaining - pollInterval)
pollLoop timeoutMicros
-- Now close everything (reuse closePool's cleanup logic)
entries <- atomically $ do
idle <- readTVar (pIdle pool)
writeTVar (pIdle pool) Seq.empty
pure idle
reaper <- readIORef (pReaper pool)
cancel reaper
mWarmer <- readIORef (pWarmer pool)
mapM_ cancel mWarmer
mapM_ (destroyEntry pool "drain") entries
observe pool PoolShutdown
-- | Acquire a connection from the pool, run an action, and return the connection.
--
-- The connection is guaranteed to be returned to the pool (or destroyed) even
-- if the action throws an exception. Uses 'mask' internally so the acquire and
-- release cannot be interrupted by async exceptions.
--
-- @
-- withResource pool $ \\conn ->
-- simpleQuery conn \"SELECT 1\"
-- @
withResource :: Pool -> (Connection -> IO a) -> IO a
withResource pool action = mask $ \restore -> do
t0 <- getCurrentTime
conn <- acquire pool
t1 <- getCurrentTime
observe pool (ConnectionAcquired (diffUTCTime t1 t0))
result <- try (restore (action conn))
case result of
Right val -> do
release pool conn
observe pool ConnectionReleased
pure val
Left ex -> do
-- Recoverable PgWire errors (constraint violations, syntax errors,
-- serialization failures) leave the connection healthy: return it
-- to the pool. Anything else (fatal PgWireError, async exceptions,
-- user IOExceptions) destroys the connection.
case fromException ex of
Just pgErr | not (isFatal pgErr) -> do
release pool conn
observe pool ConnectionReleased
_ -> destroyConn pool conn "exception"
throwIO (ex :: SomeException)
-- | Like 'withResource' but with a custom acquire timeout.
--
-- Overrides 'poolAcquireTimeout' for this single acquisition. Useful when
-- certain operations can tolerate longer (or shorter) waits than the pool
-- default.
--
-- @
-- -- Wait up to 30 seconds for this particular query
-- withResourceTimeout pool 30 $ \\conn ->
-- simpleQuery conn \"SELECT expensive_function()\"
-- @
withResourceTimeout :: Pool -> NominalDiffTime -> (Connection -> IO a) -> IO a
withResourceTimeout pool timeout action = mask $ \restore -> do
t0 <- getCurrentTime
conn <- acquireWithTimeout pool timeout
t1 <- getCurrentTime
observe pool (ConnectionAcquired (diffUTCTime t1 t0))
result <- try (restore (action conn))
case result of
Right val -> do
release pool conn
observe pool ConnectionReleased
pure val
Left ex -> do
case fromException ex of
Just pgErr | not (isFatal pgErr) -> do
release pool conn
observe pool ConnectionReleased
_ -> destroyConn pool conn "exception"
throwIO (ex :: SomeException)
-- | Get an atomic snapshot of the pool's current statistics.
--
-- The returned 'PoolStats' is consistent (all fields read in a single STM
-- transaction) but represents a point-in-time view that may be stale by
-- the time you inspect it.
poolStats :: Pool -> IO PoolStats
poolStats pool = atomically $ do
idle <- Seq.length <$> readTVar (pIdle pool)
active <- readTVar (pActive pool)
waiters <- Seq.length <$> readTVar (pWaiters pool)
maxSz <- readTVar (pEffectiveSize pool)
created <- readTVar (pTotalCreated pool)
destroyed <- readTVar (pTotalDestroyed pool)
timeouts <- readTVar (pTotalTimeouts pool)
pure PoolStats
{ psIdle = idle
, psInUse = active - idle
, psWaiters = waiters
, psMaxSize = maxSz
, psTotalCreated = created
, psTotalDestroyed = destroyed
, psTotalTimeouts = timeouts
}
-- | Check if the pool is still open (not closed).
--
-- Returns 'True' if the pool is alive and accepting connections,
-- 'False' if 'closePool' has been called.
poolIsAlive :: Pool -> IO Bool
poolIsAlive pool = not <$> readTVarIO (pClosed pool)
-- | Resize the pool at runtime to a new maximum connection count.
--
-- If shrinking, excess idle connections are closed immediately. In-use
-- connections are not interrupted and will finish naturally; they are simply
-- not returned to the pool once the count exceeds the new limit. If growing,
-- new connections are created on demand by subsequent 'withResource' calls.
resize :: Pool -> Int -> IO ()
resize pool newSize = do
(oldSize, excess) <- atomically $ do
old <- readTVar (pEffectiveSize pool)
writeTVar (pEffectiveSize pool) newSize
idle <- readTVar (pIdle pool)
let idleCount = Seq.length idle
excessCount = max 0 (idleCount - newSize)
if excessCount > 0
then do
let (keep, toDrop) = Seq.splitAt (idleCount - excessCount) idle
writeTVar (pIdle pool) keep
pure (old, seqToList toDrop)
else pure (old, [])
logPool pool "info" ("resized from " <> BS8.pack (show oldSize) <> " to " <> BS8.pack (show newSize))
observe pool (PoolResized oldSize newSize)
mapM_ (destroyEntry pool "resize") excess
-- | Filter idle connections, keeping only those that satisfy the predicate.
--
-- Atomically drains the idle queue, applies the predicate to each connection
-- in IO, returns matching ones to the pool, and destroys the rest. Useful for
-- invalidating connections after a credential rotation or schema migration.
--
-- @
-- -- Drop all connections that were created before the migration
-- retain pool (\\conn -> isPostMigration conn)
-- @
retain :: Pool -> (Connection -> IO Bool) -> IO ()
retain pool predicate = do
entries <- atomically $ do
idle <- readTVar (pIdle pool)
writeTVar (pIdle pool) Seq.empty
pure (seqToList idle)
(kept, dropped) <- partitionM (\e -> predicate (peConn e)) entries
atomically $ modifyTVar' (pIdle pool) (Seq.fromList kept Seq.><)
mapM_ (destroyEntry pool "retain") dropped
-- | Set a hook called immediately after a new connection is created.
--
-- Useful for running session-level setup such as @SET search_path@ or
-- @SET statement_timeout@. If the hook throws, the exception is silently
-- caught and the connection is still placed in the pool.
setPostCreateHook :: Pool -> (Connection -> IO ()) -> IO ()
setPostCreateHook pool hook = writeIORef (pOnCreate pool) $! hook
-- | Set a hook called each time a connection is handed out to a caller via 'withResource'.
--
-- Runs after any recycling\/health checks have passed. If the hook throws, the
-- exception is silently caught and the connection is still provided.
setOnAcquireHook :: Pool -> (Connection -> IO ()) -> IO ()
setOnAcquireHook pool hook = writeIORef (pOnAcquire pool) $! hook
-- | Set a hook called before a connection is returned to the idle pool.
--
-- Runs after the caller's action completes and before the connection is placed
-- back in the idle queue or delivered to a waiting thread. Useful for cleanup
-- such as @RESET ALL@ or @DISCARD TEMP@. If the hook throws, the exception is
-- silently caught.
setPreReleaseHook :: Pool -> (Connection -> IO ()) -> IO ()
setPreReleaseHook pool hook = writeIORef (pOnRelease pool) $! hook
-- Internal ----------------------------------------------------------------
-- | What the single STM transaction decided we should do.
data AcquireAction
= GotIdle !PoolEntry
| CreateNew
| MustWait !(TMVar (Either PgWireError Connection))
| PoolIsClosed
-- | Atomically decide what to do: take idle, create new, or enqueue waiter.
-- This is the core fix for the acquire race condition — a single STM
-- transaction replaces the old two-step check.
acquireAction :: Pool -> STM AcquireAction
acquireAction pool = do
closed <- readTVar (pClosed pool)
if closed
then pure PoolIsClosed
else do
idle <- readTVar (pIdle pool)
case takeIdle (poolQueueMode (pConfig pool)) idle of
Just (entry, rest) -> do
writeTVar (pIdle pool) rest
pure (GotIdle entry)
Nothing -> do
active <- readTVar (pActive pool)
maxSz <- readTVar (pEffectiveSize pool)
if active < maxSz
then do
writeTVar (pActive pool) (active + 1)
pure CreateNew
else do
waiter <- newEmptyTMVar
modifyTVar' (pWaiters pool) (Seq.|> waiter)
pure (MustWait waiter)
-- | Take from the appropriate end based on QueueMode.
takeIdle :: QueueMode -> Seq PoolEntry -> Maybe (PoolEntry, Seq PoolEntry)
takeIdle QueueLIFO s = case Seq.viewr s of
Seq.EmptyR -> Nothing
rest Seq.:> e -> Just (e, rest)
takeIdle QueueFIFO s = case Seq.viewl s of
Seq.EmptyL -> Nothing
e Seq.:< rest -> Just (e, rest)
acquire :: Pool -> IO Connection
acquire pool = acquireWithTimeout pool (poolAcquireTimeout (pConfig pool))
acquireWithTimeout :: Pool -> NominalDiffTime -> IO Connection
acquireWithTimeout pool timeout = do
act <- atomically (acquireAction pool)
case act of
PoolIsClosed -> throwPgWire PoolClosed
GotIdle entry -> tryRecycle pool entry
CreateNew -> createConnection pool
MustWait waiter -> waitForConnectionWith pool waiter timeout
-- | Try to recycle an idle connection. If it's expired or unhealthy,
-- destroy it and retry.
tryRecycle :: Pool -> PoolEntry -> IO Connection
tryRecycle pool entry = do
now <- getCurrentTime
expired <- isExpiredIO pool entry now
if expired
then do
destroyEntry pool "expired" entry
acquire pool
else do
ok <- recycleCheck pool entry now
if ok
then do
logPool pool "debug" "recycled connection"
observe pool ConnectionRecycled
writeIORef (peLastUsed entry) now
runHook (pOnAcquire pool) (peConn entry)
pure (peConn entry)
else do
observe pool HealthCheckFailed
destroyEntry pool "unhealthy" entry
acquire pool
-- | Recycling check based on the configured method.
recycleCheck :: Pool -> PoolEntry -> UTCTime -> IO Bool
recycleCheck pool entry now = case poolRecyclingMethod (pConfig pool) of
RecycleFast -> do
readTVarIO (awcAlive (connAsync (peConn entry)))
RecycleVerified -> do
alive <- readTVarIO (awcAlive (connAsync (peConn entry)))
if not alive
then pure False
else do
lastUsed <- readIORef (peLastUsed entry)
if diffUTCTime now lastUsed <= poolHealthCheckAge (pConfig pool)
then pure True
else checkHealthWithTimeout (poolAcquireTimeout (pConfig pool)) (peConn entry)
RecycleClean -> do
alive <- readTVarIO (awcAlive (connAsync (peConn entry)))
if not alive
then pure False
else do
let timeoutSecs = poolAcquireTimeout (pConfig pool)
timeoutMicros = round (timeoutSecs * 1000000) :: Int
result <- race (threadDelay timeoutMicros)
(try @SomeException (simpleQuery (peConn entry) "DISCARD ALL"))
case result of
Right (Right _) -> do
-- DISCARD ALL deallocates all prepared statements on the server,
-- so the client-side cache must be cleared to stay in sync.
Sieve.clear (connStmtCache (peConn entry))
pure True
_ -> pure False
createConnection :: Pool -> IO Connection
createConnection pool = do
conn <- connectWithRetry (poolConnectionRetries (pConfig pool)) 100000
`onException` atomically (modifyTVar' (pActive pool) (subtract 1))
now <- getCurrentTime
deadline <- jitteredDeadline (pConfig pool) now
atomically $ do
modifyTVar' (pTotalCreated pool) (+ 1)
modifyTVar' (pConnMeta pool) (Map.insert (connBackendPid conn) (ConnMeta now deadline))
logPool pool "debug" "created connection"
observe pool ConnectionCreated
runHook (pOnCreate pool) conn
runHook (pOnAcquire pool) conn
pure conn
where
-- Exponential backoff: 100ms, 200ms, 400ms, ...
connectWithRetry :: Int -> Int -> IO Connection
connectWithRetry 0 _ = connectString (poolConnString (pConfig pool))
connectWithRetry !retriesLeft !delayMicros = do
result <- try @SomeException (connectString (poolConnString (pConfig pool)))
case result of
Right c -> pure c
Left _ -> do
logPool pool "warn" ("connection failed, retrying in " <> BS8.pack (show (delayMicros `div` 1000)) <> "ms")
threadDelay delayMicros
connectWithRetry (retriesLeft - 1) (delayMicros * 2)
-- | Compute a jittered deadline for a connection.
-- deadline = now + maxLife + uniform(-jitter, +jitter)
jitteredDeadline :: PoolConfig -> UTCTime -> IO UTCTime
jitteredDeadline cfg now = do
let jitter = poolMaxLifeJitter cfg
offset <- if jitter > 0
then do
let jitterMicros = round (jitter * 1000000) :: Int
r <- randomRIO (negate jitterMicros, jitterMicros)
pure (fromIntegral r / 1000000 :: NominalDiffTime)
else pure 0
pure (addUTCTime (poolMaxLife cfg + offset) now)
waitForConnectionWith :: Pool -> TMVar (Either PgWireError Connection) -> NominalDiffTime -> IO Connection
waitForConnectionWith pool waiter timeout = do
let timeoutMicros = round (timeout * 1000000) :: Int
result <- race
(threadDelay timeoutMicros)
(atomically $ takeTMVar waiter)
case result of
Left () -> do
atomically $ modifyTVar' (pTotalTimeouts pool) (+ 1)
logPool pool "warn" "acquire timeout"
observe pool AcquireTimeout
throwPgWire PoolTimeout
Right (Left err) -> throwPgWire err
Right (Right conn) -> do
runHook (pOnAcquire pool) conn
pure conn
release :: Pool -> Connection -> IO ()
release pool conn = do
runHook (pOnRelease pool) conn
now <- getCurrentTime
-- Try to deliver to a waiter first. Use tryPutTMVar and loop
-- to handle dead waiters (waiter died between enqueue and delivery).
delivered <- deliverToWaiter pool conn
if delivered
then pure ()
else do
-- Look up creation time from the tracking map (fixes maxLife bug)
meta <- Map.lookup (connBackendPid conn) <$> readTVarIO (pConnMeta pool)
let createdAt = maybe now cmCreatedAt meta
lastUsed <- newIORef now
let !entry = PoolEntry conn createdAt lastUsed
atomically $ modifyTVar' (pIdle pool) (Seq.|> entry)
-- | Try to deliver a connection to a waiting thread. Loops through waiters,
-- skipping any that have died (timed out). Returns True if delivered.
deliverToWaiter :: Pool -> Connection -> IO Bool
deliverToWaiter pool conn = atomically $ do
ws <- readTVar (pWaiters pool)
case Seq.viewl ws of
Seq.EmptyL -> pure False
w Seq.:< rest -> do
delivered <- tryPutTMVar w (Right conn)
if delivered
then do
writeTVar (pWaiters pool) rest
pure True
else do
-- Waiter died (timeout). Skip it and try next.
writeTVar (pWaiters pool) rest
-- Retry with remaining waiters (re-enter STM)
deliverLoop pool rest conn
-- | STM loop to deliver to next available waiter.
deliverLoop :: Pool -> Seq (TMVar (Either PgWireError Connection)) -> Connection -> STM Bool
deliverLoop _ Empty _ = pure False
deliverLoop pool (w :<| rest) conn = do
delivered <- tryPutTMVar w (Right conn)
if delivered
then do
writeTVar (pWaiters pool) rest
pure True
else do
writeTVar (pWaiters pool) rest
deliverLoop pool rest conn
destroyConn :: Pool -> Connection -> BS8.ByteString -> IO ()
destroyConn pool conn reason = do
safeClose conn
atomically $ do
modifyTVar' (pActive pool) (subtract 1)
modifyTVar' (pTotalDestroyed pool) (+ 1)
modifyTVar' (pConnMeta pool) (Map.delete (connBackendPid conn))
logPool pool "debug" ("destroyed connection: " <> reason)
observe pool (ConnectionDestroyed reason)
destroyEntry :: Pool -> BS8.ByteString -> PoolEntry -> IO ()
destroyEntry pool reason entry = destroyConn pool (peConn entry) reason
-- | Check if a connection has exceeded its (jittered) max-life.
isExpiredIO :: Pool -> PoolEntry -> UTCTime -> IO Bool
isExpiredIO pool entry now = do
meta <- Map.lookup (connBackendPid (peConn entry)) <$> readTVarIO (pConnMeta pool)
pure $ case meta of
Just cm -> now >= cmDeadline cm
Nothing -> diffUTCTime now (peCreatedAt entry) > poolMaxLife (pConfig pool)
safeClose :: Connection -> IO ()
safeClose conn = close conn `catch` \(_ :: SomeException) -> pure ()
-- | Lightweight health check: send an empty query and see if we get a response.
-- Times out after the given duration to prevent a hung backend from blocking acquire.
checkHealthWithTimeout :: NominalDiffTime -> Connection -> IO Bool
checkHealthWithTimeout timeout conn = do
let timeoutMicros = round (timeout * 1000000) :: Int
result <- race (threadDelay timeoutMicros) (try @SomeException (simpleQuery conn ""))
pure $ case result of
Left () -> False -- timed out
Right (Right _) -> True
Right (Left _) -> False
-- | Run a lifecycle hook, catching and ignoring exceptions so a hook
-- failure doesn't break the pool.
runHook :: IORef (Connection -> IO ()) -> Connection -> IO ()
runHook ref conn = do
hook <- readIORef ref
hook conn `catch` \(_ :: SomeException) -> pure ()
-- | Log a pool event using the configured logger.
logPool :: Pool -> BS8.ByteString -> BS8.ByteString -> IO ()
logPool pool = poolLogger (pConfig pool)
-- | Emit a structured observation event, catching and ignoring exceptions.
observe :: Pool -> PoolEvent -> IO ()
observe pool event =
poolObserver (pConfig pool) event `catch` \(_ :: SomeException) -> pure ()
-- | Background thread that periodically reaps idle/expired connections.
--
-- Atomically drains the idle queue, filters in IO (reading IORefs),
-- then atomically merges kept entries back. This avoids the race where
-- acquire/release modify the idle queue between read and write.
reaperThread :: Pool -> IO ()
reaperThread pool = go
where
intervalMicros = round (poolReaperInterval (pConfig pool) * 1000000) :: Int
go = do
threadDelay intervalMicros
reap
`catch` \(e :: AsyncException) -> throwIO e
`catch` \(_ :: SomeException) ->
logPool pool "warn" "reaper: exception in sweep cycle (continuing)"
go
reap = do
now <- getCurrentTime
-- Atomically drain the idle queue
entries <- atomically $ do
idle <- readTVar (pIdle pool)
writeTVar (pIdle pool) Seq.empty
pure (seqToList idle)
(keep, toReap) <- partitionM (shouldKeep now) entries
-- Merge kept entries back (prepend; entries added by release
-- during filtering are already at the tail)
atomically $ modifyTVar' (pIdle pool) (Seq.fromList keep Seq.><)
let reapCount = length toReap
mapM_ (destroyEntry pool "reaped") toReap
when (reapCount > 0) $ do
logPool pool "info" ("reaper swept " <> BS8.pack (show reapCount) <> " connections")
observe pool (ReaperSwept reapCount)
shouldKeep now entry = do
expired <- isExpiredIO pool entry now
if expired
then pure False
else do
lastUsed <- readIORef (peLastUsed entry)
pure (diffUTCTime now lastUsed <= poolIdleTime (pConfig pool))
-- | Background thread that maintains 'poolMinIdle' connections.
warmerThread :: Pool -> IO ()
warmerThread pool = go
where
intervalMicros = round (poolReaperInterval (pConfig pool) * 1000000) :: Int
go = do
threadDelay intervalMicros
warm
`catch` \(e :: AsyncException) -> throwIO e
`catch` \(_ :: SomeException) ->
logPool pool "warn" "warmer: exception in warm cycle (continuing)"
go
warm = do
deficit <- atomically $ do
closed <- readTVar (pClosed pool)
if closed
then pure 0
else do
idle <- Seq.length <$> readTVar (pIdle pool)
active <- readTVar (pActive pool)
maxSz <- readTVar (pEffectiveSize pool)
let minIdle = poolMinIdle (pConfig pool)
canCreate = maxSz - active
need = max 0 (minIdle - idle)
pure (min need canCreate)
created <- warmN 0 deficit
when (created > 0) $ do
logPool pool "info" ("warmer created " <> BS8.pack (show created) <> " connections")
observe pool (WarmerCreated created)
warmN :: Int -> Int -> IO Int
warmN !acc 0 = pure acc
warmN !acc n = do
result <- try @SomeException $ do
-- Reserve a slot
reserved <- atomically $ do
active <- readTVar (pActive pool)
maxSz <- readTVar (pEffectiveSize pool)
if active < maxSz
then do
writeTVar (pActive pool) (active + 1)
pure True
else pure False
if reserved
then do
conn <- connectString (poolConnString (pConfig pool))
`onException` atomically (modifyTVar' (pActive pool) (subtract 1))
now <- getCurrentTime
deadline <- jitteredDeadline (pConfig pool) now
atomically $ do
modifyTVar' (pTotalCreated pool) (+ 1)
modifyTVar' (pConnMeta pool) (Map.insert (connBackendPid conn) (ConnMeta now deadline))
runHook (pOnCreate pool) conn
lastUsed <- newIORef now
let !entry = PoolEntry conn now lastUsed
atomically $ modifyTVar' (pIdle pool) (Seq.|> entry)
pure True
else pure False
case result of
Left _ -> pure acc -- Connection failed, stop warming this round
Right True -> warmN (acc + 1) (n - 1)
Right False -> pure acc
when :: Bool -> IO () -> IO ()
when True f = f
when False _ = pure ()
seqToList :: Seq a -> [a]
seqToList = foldr (:) []
partitionM :: (a -> IO Bool) -> [a] -> IO ([a], [a])
partitionM _ [] = pure ([], [])
partitionM p (x : xs) = do
b <- p x
(!ys, !zs) <- partitionM p xs
pure (if b then (x : ys, zs) else (ys, x : zs))