bolty-0.1.0.2: src/Database/Bolty/Pool.hs
-- | Connection pooling with health-check validation and retry configuration.
module Database.Bolty.Pool
( BoltPool(..)
, PoolConfig(..)
, defaultPoolConfig
, ValidationStrategy(..)
, RetryConfig(..)
, defaultRetryConfig
, PoolCounters(..)
, createPool
, destroyPool
, withConnection
, CheckedOutConnection(..)
, acquireConnection
, releaseConnection
, releaseConnectionOnError
, poolCounters
) where
import Control.Exception (SomeException, mask, try, fromException, throwIO)
import Data.IORef (IORef, newIORef, readIORef, atomicModifyIORef')
import Data.Pool (Pool)
import qualified Data.Pool as Pool
import Data.Word (Word64)
import GHC.Clock (getMonotonicTimeNSec)
import GHC.Stack (HasCallStack)
import Data.Kind (Type)
import Database.Bolty.Connection.Type (Connection, ValidatedConfig, Error(..))
import qualified Database.Bolty.Connection.Pipe as P
import Database.Bolty.Connection.Pipe (touchConnection, connectionLastActivity,
connectionServerIdleTimeout)
-- | Configuration for retry logic on transient failures.
type RetryConfig :: Type
data RetryConfig = RetryConfig
{ maxRetries :: !Int
-- ^ Maximum number of retry attempts. Default: 5.
, initialDelay :: !Int
-- ^ Initial delay in microseconds before first retry. Default: 200000 (200ms).
, maxDelay :: !Int
-- ^ Maximum delay in microseconds between retries. Default: 5000000 (5s).
}
deriving stock (Show, Eq)
-- | Default retry configuration: 5 retries, 200ms initial delay, 5s max delay.
defaultRetryConfig :: RetryConfig
defaultRetryConfig = RetryConfig
{ maxRetries = 5
, initialDelay = 200_000
, maxDelay = 5_000_000
}
-- | Strategy for validating connections when they are checked out of the pool.
type ValidationStrategy :: Type
data ValidationStrategy
= AlwaysPing
-- ^ Always send RESET before use (current default, safest).
| PingIfIdle !Int
-- ^ Only ping if the connection has been idle for more than N seconds.
-- Connections used within the last N seconds are assumed healthy.
| NeverPing
-- ^ Skip health check entirely (fastest, use only in trusted environments).
deriving stock (Show, Eq)
-- | Configuration for the connection pool.
type PoolConfig :: Type
data PoolConfig = PoolConfig
{ maxConnections :: Int
-- ^ Maximum number of open connections. Default: 10.
, idleTimeout :: Double
-- ^ Seconds before an idle connection is closed. Default: 60.
, maxPingRetries :: Int
-- ^ Number of additional attempts after a ping failure. Default: 1.
-- Total attempts = 1 + maxPingRetries.
, retryConfig :: RetryConfig
-- ^ Retry configuration for transient failures. Default: 'defaultRetryConfig'.
, validationStrategy :: ValidationStrategy
-- ^ How to validate connections on checkout. Default: 'AlwaysPing'.
}
-- | Default pool configuration: 10 max connections, 60 second idle timeout, 1 ping retry.
defaultPoolConfig :: PoolConfig
defaultPoolConfig = PoolConfig
{ maxConnections = 10
, idleTimeout = 60
, maxPingRetries = 1
, retryConfig = defaultRetryConfig
, validationStrategy = PingIfIdle 30
}
-- | A pool of Neo4j connections with health-check and retry configuration.
type BoltPool :: Type
data BoltPool = BoltPool
{ bpPool :: !(Pool Connection)
, bpMaxRetries :: !Int
, bpRetryConfig :: !RetryConfig
, bpValidation :: !ValidationStrategy
, bpActiveConns :: !(IORef Int)
, bpTotalAcqs :: !(IORef Int)
, bpTotalWaitNs :: !(IORef Word64)
}
-- | Snapshot of pool-level counters.
type PoolCounters :: Type
data PoolCounters = PoolCounters
{ pcActive :: !Int
-- ^ Number of connections currently checked out.
, pcTotalAcqs :: !Int
-- ^ Lifetime count of connection acquisitions.
, pcTotalWaitNs :: !Word64
-- ^ Cumulative nanoseconds spent waiting for connections.
} deriving stock (Show, Eq)
-- | Create a new connection pool.
-- If the server advertises @connection.recv_timeout_seconds@, the pool's
-- idle timeout is capped to @min(configured, hint - 5)@ so connections
-- are recycled before the server closes them.
createPool :: HasCallStack => ValidatedConfig -> PoolConfig -> IO BoltPool
createPool cfg PoolConfig{..} = do
-- Probe a connection to discover the server's idle timeout hint.
probe <- P.connect cfg
let effectiveIdle = case connectionServerIdleTimeout probe of
Just serverSecs | serverSecs > 5 ->
min idleTimeout (fromIntegral (serverSecs - 5))
_ -> idleTimeout
P.close probe
let poolCfg = Pool.setNumStripes (Just 1)
$ Pool.defaultPoolConfig
(P.connect cfg) -- create
P.close -- destroy
effectiveIdle -- idle timeout (seconds)
maxConnections -- max resources
pool <- Pool.newPool poolCfg
activeRef <- newIORef 0
acqRef <- newIORef 0
waitRef <- newIORef 0
pure BoltPool { bpPool = pool, bpMaxRetries = maxPingRetries
, bpRetryConfig = retryConfig, bpValidation = validationStrategy
, bpActiveConns = activeRef, bpTotalAcqs = acqRef, bpTotalWaitNs = waitRef }
-- | Destroy the pool, closing all connections.
destroyPool :: BoltPool -> IO ()
destroyPool BoltPool{bpPool} = Pool.destroyAllResources bpPool
-- | Acquire a healthy connection from the pool, run an action, then release it.
-- Validates the connection using the configured 'ValidationStrategy'.
-- On connection failure during the action, discards the dead connection and
-- retries once with a fresh one.
withConnection :: HasCallStack => BoltPool -> (Connection -> IO a) -> IO a
withConnection BoltPool{bpPool, bpMaxRetries, bpValidation, bpActiveConns, bpTotalAcqs, bpTotalWaitNs} action =
go (bpMaxRetries + 1) True
where
go 0 _ = fail "withConnection: no healthy connection available"
go n canRetryDead = mask $ \restore -> do
t0 <- getMonotonicTimeNSec
(conn, localPool) <- Pool.takeResource bpPool
t1 <- getMonotonicTimeNSec
atomicModifyIORef' bpActiveConns $ \x -> (x + 1, ())
atomicModifyIORef' bpTotalAcqs $ \x -> (x + 1, ())
atomicModifyIORef' bpTotalWaitNs $ \x -> (x + (t1 - t0), ())
let decActive = atomicModifyIORef' bpActiveConns $ \x -> (x - 1, ())
shouldPing <- needsPing bpValidation conn
healthy <- if shouldPing then P.ping conn else pure True
if healthy
then do
result <- try $ restore (action conn)
case result of
Right x -> do
touchConnection conn
Pool.putResource localPool conn
decActive
pure x
Left (e :: SomeException)
| canRetryDead, isConnectionError e -> do
Pool.destroyResource bpPool localPool conn
decActive
restore $ go n False -- retry with fresh connection, no more retries
| otherwise -> do
Pool.destroyResource bpPool localPool conn
decActive
restore $ throwIOSome e
else do
Pool.destroyResource bpPool localPool conn
decActive
restore $ go (n - 1) canRetryDead
throwIOSome :: SomeException -> IO a
throwIOSome = throwIO' where
throwIO' :: SomeException -> IO a
throwIO' = Control.Exception.throwIO
isConnectionError :: SomeException -> Bool
isConnectionError e = case fromException e :: Maybe Error of
Just (NonboltyError _) -> True
_ -> False
-- | Check if a connection needs a ping based on the validation strategy.
needsPing :: ValidationStrategy -> Connection -> IO Bool
needsPing AlwaysPing _ = pure True
needsPing NeverPing _ = pure False
needsPing (PingIfIdle secs) conn = do
now <- getMonotonicTimeNSec
lastAct <- connectionLastActivity conn
let idleNs = now - lastAct
let thresholdNs = fromIntegral secs * 1_000_000_000
pure (idleNs >= thresholdNs)
-- | A checked-out connection handle, bundling the connection with its
-- local pool reference for proper release.
type CheckedOutConnection :: Type
data CheckedOutConnection = CheckedOutConnection
{ cocConnection :: !Connection
, cocLocalPool :: !(Pool.LocalPool Connection)
, cocBoltPool :: !BoltPool
}
-- | Acquire a validated connection from the pool. The caller is responsible
-- for releasing it with 'releaseConnection' or 'releaseConnectionOnError'.
--
-- This is the low-level primitive behind 'withConnection'. Prefer
-- 'withConnection' for simple request-response patterns.
-- Use 'acquireConnection' when you need the connection to outlive a callback
-- (e.g. for streaming with @bracketIO@).
acquireConnection :: BoltPool -> IO CheckedOutConnection
acquireConnection bp@BoltPool{bpPool, bpMaxRetries, bpValidation, bpActiveConns, bpTotalAcqs, bpTotalWaitNs} =
go (bpMaxRetries + 1)
where
go 0 = fail "acquireConnection: no healthy connection available"
go n = do
t0 <- getMonotonicTimeNSec
(conn, localPool) <- Pool.takeResource bpPool
t1 <- getMonotonicTimeNSec
atomicModifyIORef' bpActiveConns $ \x -> (x + 1, ())
atomicModifyIORef' bpTotalAcqs $ \x -> (x + 1, ())
atomicModifyIORef' bpTotalWaitNs $ \x -> (x + (t1 - t0), ())
shouldPing <- needsPing bpValidation conn
healthy <- if shouldPing then P.ping conn else pure True
if healthy
then pure CheckedOutConnection
{ cocConnection = conn, cocLocalPool = localPool, cocBoltPool = bp }
else do
Pool.destroyResource bpPool localPool conn
atomicModifyIORef' bpActiveConns $ \x -> (x - 1, ())
go (n - 1)
-- | Release a connection back to the pool after successful use.
releaseConnection :: CheckedOutConnection -> IO ()
releaseConnection CheckedOutConnection{cocConnection, cocLocalPool, cocBoltPool} = do
touchConnection cocConnection
Pool.putResource cocLocalPool cocConnection
atomicModifyIORef' (bpActiveConns cocBoltPool) $ \x -> (x - 1, ())
-- | Release a connection after an error, destroying it instead of returning
-- it to the pool (since it may be in a bad state).
releaseConnectionOnError :: CheckedOutConnection -> IO ()
releaseConnectionOnError CheckedOutConnection{cocConnection, cocLocalPool, cocBoltPool} = do
Pool.destroyResource (bpPool cocBoltPool) cocLocalPool cocConnection
atomicModifyIORef' (bpActiveConns cocBoltPool) $ \x -> (x - 1, ())
-- | Read a snapshot of the pool's lifetime counters.
poolCounters :: BoltPool -> IO PoolCounters
poolCounters BoltPool{bpActiveConns, bpTotalAcqs, bpTotalWaitNs} = do
active <- readIORef bpActiveConns
acqs <- readIORef bpTotalAcqs
waitNs <- readIORef bpTotalWaitNs
pure PoolCounters{pcActive = active, pcTotalAcqs = acqs, pcTotalWaitNs = waitNs}