hask-redis-mux-0.1.0.0: lib/cluster/Database/Redis/Cluster/ConnectionPool.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE RankNTypes #-}
-- | Thread-safe connection pool for managing Redis connections.
--
-- Connections are created lazily and managed per-node. Each call to
-- 'withConnection' checks out an exclusive connection for the caller,
-- preventing RESP protocol interleaving between threads. Connections
-- are returned to the pool after use, or discarded if an error occurred.
--
-- When the pool is at capacity, callers block until a connection becomes
-- available rather than creating unbounded overflow connections.
--
-- @since 0.1.0.0
module Database.Redis.Cluster.ConnectionPool
( ConnectionPool (..),
PoolConfig (..),
createPool,
withConnection,
closePool,
)
where
import Control.Concurrent.MVar (MVar, modifyMVar, newEmptyMVar,
newMVar, putMVar, takeMVar)
import Control.Exception (SomeException, catch, throwIO,
toException, try)
import Control.Monad (forM_)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Database.Redis.Client (Client (..), ConnectionStatus (..))
import Database.Redis.Cluster (NodeAddress (..))
import Database.Redis.Connector (Connector)
-- | Configuration for the connection pool.
data PoolConfig = PoolConfig
{ maxConnectionsPerNode :: Int -- ^ Maximum number of connections kept per node. Callers block when all connections are in use.
, connectionTimeout :: Int -- ^ Connection timeout in seconds (reserved for future use).
, maxRetries :: Int -- ^ Maximum retry attempts for cluster operations.
, useTLS :: Bool -- ^ Whether to use TLS connections.
}
deriving (Show)
-- | Per-node connection state: available connections, total count, and waiters
data NodePool client = NodePool
{ availableConns :: [client 'Connected] -- ^ Idle connections ready for checkout
, totalConns :: !Int -- ^ Total connections created (available + in-use)
, waitQueue :: [MVar (Either SomeException (client 'Connected))]
-- ^ Threads waiting for a connection. Right = success, Left = pool error (retry).
}
-- | Thread-safe connection pool using MVar for atomic access.
-- Each node has a pool of connections; callers check out exclusive
-- connections and return them after use. When no connections are
-- available and the pool is at capacity, callers block until one
-- is returned.
data ConnectionPool client = ConnectionPool
{ poolConnections :: MVar (Map NodeAddress (NodePool client))
, poolConfig :: PoolConfig
}
-- | Create a new empty connection pool.
-- Connections are created lazily when first requested.
createPool :: PoolConfig -> IO (ConnectionPool client)
createPool config = do
connections <- newMVar Map.empty
return $ ConnectionPool connections config
-- | What to do after acquiring the MVar lock
data CheckoutResult client
= UseExisting (client 'Connected) -- ^ Reuse an idle connection
| CreateNew -- ^ Create a new connection (slot reserved)
| Wait (MVar (Either SomeException (client 'Connected))) -- ^ Block until a connection is returned
-- | Check out a connection, run an action, and return the connection to the pool.
-- If the action throws an exception, the connection is discarded (not returned)
-- since its RESP parse state may be corrupted. A fresh connection will be created
-- on the next checkout for that node.
withConnection ::
(Client client) =>
ConnectionPool client ->
NodeAddress ->
Connector client ->
(client 'Connected -> IO a) ->
IO a
withConnection pool addr connector action = do
conn <- checkoutConnection pool addr connector
result <- try (action conn)
case result of
Right val -> do
returnConnection pool addr conn
return val
Left (e :: SomeException) -> do
discardConnection pool addr conn connector
throwIO e
-- | Check out a connection from the pool. Creates a new one if none available
-- and the max hasn't been reached. Blocks if pool is at capacity.
checkoutConnection ::
(Client client) =>
ConnectionPool client ->
NodeAddress ->
Connector client ->
IO (client 'Connected)
checkoutConnection pool addr connector = do
result <- modifyMVar (poolConnections pool) $ \m -> do
let nodePool = Map.findWithDefault (NodePool [] 0 []) addr m
case availableConns nodePool of
(conn : rest) -> do
let updated = nodePool { availableConns = rest }
return (Map.insert addr updated m, UseExisting conn)
[] ->
if totalConns nodePool < maxConnectionsPerNode (poolConfig pool)
then do
-- Reserve a slot, create connection outside the lock
let updated = nodePool { totalConns = totalConns nodePool + 1 }
return (Map.insert addr updated m, CreateNew)
else do
-- At capacity — enqueue a waiter
waiter <- newEmptyMVar
let updated = nodePool { waitQueue = waitQueue nodePool ++ [waiter] }
return (Map.insert addr updated m, Wait waiter)
case result of
UseExisting conn -> return conn
CreateNew -> do
-- Create connection outside the MVar lock
connResult <- try (connector addr)
case connResult of
Right conn -> return conn
Left (e :: SomeException) -> do
-- Creation failed — release the reserved slot
modifyMVar (poolConnections pool) $ \m -> do
let m' = Map.adjust (\np -> np { totalConns = totalConns np - 1 }) addr m
return (m', ())
throwIO e
Wait waiter -> takeMVar waiter >>= either throwIO return
-- | Return a connection to the pool for reuse.
-- If threads are waiting, hand the connection directly to the next waiter.
returnConnection ::
(Client client) =>
ConnectionPool client ->
NodeAddress ->
client 'Connected ->
IO ()
returnConnection pool addr conn =
modifyMVar (poolConnections pool) $ \m -> do
let nodePool = Map.findWithDefault (NodePool [] 0 []) addr m
case waitQueue nodePool of
(waiter : rest) -> do
-- Hand connection directly to a waiting thread
putMVar waiter (Right conn)
let updated = nodePool { waitQueue = rest }
return (Map.insert addr updated m, ())
[] ->
if length (availableConns nodePool) < maxConnectionsPerNode (poolConfig pool)
then do
let updated = nodePool { availableConns = conn : availableConns nodePool }
return (Map.insert addr updated m, ())
else do
-- Shouldn't happen, but close just in case
close conn `catch` \(_ :: SomeException) -> return ()
let updated = nodePool { totalConns = totalConns nodePool - 1 }
return (Map.insert addr updated m, ())
-- | Discard a connection (on error) and wake a waiter or release the slot.
-- If threads are waiting, attempts to create a replacement connection.
-- If replacement creation fails, the waiter receives the error.
discardConnection ::
(Client client) =>
ConnectionPool client ->
NodeAddress ->
client 'Connected ->
Connector client ->
IO ()
discardConnection pool addr conn connector = do
close conn `catch` \(_ :: SomeException) -> return ()
maybeWaiter <- modifyMVar (poolConnections pool) $ \m -> do
let nodePool = Map.findWithDefault (NodePool [] 0 []) addr m
case waitQueue nodePool of
(waiter : rest) -> do
-- Keep slot reserved for the waiter (don't decrement totalConns)
let updated = nodePool { waitQueue = rest }
return (Map.insert addr updated m, Just waiter)
[] -> do
-- No waiters, just release the slot
let updated = nodePool { totalConns = totalConns nodePool - 1 }
return (Map.insert addr updated m, Nothing)
case maybeWaiter of
Nothing -> return ()
Just waiter -> do
-- Try to create a replacement connection for the waiter
connResult <- try (connector addr)
case connResult of
Right newConn -> putMVar waiter (Right newConn)
Left (e :: SomeException) -> do
-- Failed — release the reserved slot and notify waiter of the error
modifyMVar (poolConnections pool) $ \m -> do
let m' = Map.adjust (\np -> np { totalConns = totalConns np - 1 }) addr m
return (m', ())
putMVar waiter (Left e)
-- | Close all connections in the pool and wake any blocked waiters.
-- Exceptions during close are caught and ignored.
closePool :: (Client client) => ConnectionPool client -> IO ()
closePool pool =
modifyMVar (poolConnections pool) $ \m -> do
let poolClosed = toException (userError "Connection pool closed")
forM_ (Map.elems m) $ \nodePool -> do
forM_ (availableConns nodePool) $ \conn ->
close conn `catch` \(_ :: SomeException) -> return ()
-- Wake all blocked waiters with an error
forM_ (waitQueue nodePool) $ \waiter ->
putMVar waiter (Left poolClosed)
return (Map.empty, ())