hask-redis-mux-0.1.0.0: lib/cluster/Database/Redis/Internal/MultiplexPool.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
-- | Pool of 'Multiplexer's for cluster-mode usage.
--
-- Manages one or more multiplexed connections per node, matching the
-- StackExchange.Redis architecture. Automatically reconnects dead multiplexers.
--
-- @
-- pool <- createMultiplexPool connector 1
-- resp <- submitToNode pool nodeAddr cmdBytes
-- closeMultiplexPool pool
-- @
--
-- @since 0.1.0.0
module Database.Redis.Internal.MultiplexPool
( MultiplexPool
, createMultiplexPool
, submitToNode
, submitToNodeWithAsking
, submitToNodeAsync
, waitSlotResult
, closeMultiplexPool
) where
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)
import Control.Exception (SomeException, catch,
throwIO)
import qualified Data.ByteString.Builder as Builder
import Data.IORef (IORef, atomicModifyIORef',
atomicWriteIORef,
newIORef, readIORef)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Vector (Vector)
import qualified Data.Vector as V
import Database.Redis.Client (Client (..))
import Database.Redis.Cluster (NodeAddress (..))
import Database.Redis.Connector (Connector)
import Database.Redis.Internal.Multiplexer (Multiplexer, ResponseSlot,
SlotPool,
createMultiplexer,
createSlotPool,
destroyMultiplexer,
isMultiplexerAlive,
submitCommandAsync,
submitCommandPairPooled,
submitCommandPooled,
waitSlot)
import Database.Redis.Resp (RespData)
-- | Per-node multiplexer group with its own round-robin counter.
-- Keeping the counter per-node eliminates cross-node CAS contention
-- on the shared counter that existed before.
data NodeMuxes = NodeMuxes
{ nmMuxes :: !(Vector Multiplexer)
, nmCounter :: !(IORef Int)
}
-- | A pool of multiplexers, N per node address (round-robin selected).
-- Uses IORef for fast lock-free reads on the hot path,
-- with MVar protecting creation/replacement (exclusive writes).
-- Includes a per-pool SlotPool for ResponseSlot reuse.
data MultiplexPool client = MultiplexPool
{ poolNodesRef :: !(IORef (Map NodeAddress NodeMuxes)) -- fast reads
, poolNodesLock :: !(MVar ()) -- protects writes
, poolConnector :: !(Connector client)
, poolSlotPool :: !SlotPool -- reusable ResponseSlots
, poolMuxCount :: !Int -- multiplexers per node
}
-- | Create a new empty multiplexer pool.
-- Multiplexers are created lazily when a node is first accessed.
-- @muxCount@ controls how many multiplexers are created per node.
createMultiplexPool
:: (Client client)
=> Connector client
-> Int
-> IO (MultiplexPool client)
createMultiplexPool connector muxCnt = do
nodesRef <- newIORef Map.empty
nodesLock <- newMVar ()
slotPool <- createSlotPool 256
return $ MultiplexPool nodesRef nodesLock connector slotPool (max 1 muxCnt)
-- | Submit a pre-encoded RESP command (as a Builder) to the multiplexer for a given node.
-- Creates the multiplexer on demand if the node hasn't been seen before.
-- On submission failure (dead multiplexer), replaces it and retries once.
submitToNode
:: (Client client)
=> MultiplexPool client
-> NodeAddress
-> Builder.Builder
-> IO RespData
submitToNode pool addr cmdBuilder = do
mux <- getMultiplexer pool addr
submitCommandPooled (poolSlotPool pool) mux cmdBuilder
`catch` \(e :: SomeException) -> do
-- Multiplexer may be dead; try to replace and retry once
alive <- isMultiplexerAlive mux
if alive
then throwIO e -- mux is alive, error is something else
else do
newMux <- replaceMux pool addr mux
submitCommandPooled (poolSlotPool pool) newMux cmdBuilder
{-# INLINE submitToNode #-}
-- | Submit an ASKING command followed by a real command atomically to a node.
-- Both commands are enqueued in a single atomic operation so no other command
-- can be interleaved between them on the same connection. The ASKING response
-- is discarded; only the real command's response is returned.
submitToNodeWithAsking
:: (Client client)
=> MultiplexPool client
-> NodeAddress
-> Builder.Builder -- ^ ASKING command builder
-> Builder.Builder -- ^ The actual command builder
-> IO RespData
submitToNodeWithAsking pool addr askingBuilder cmdBuilder = do
mux <- getMultiplexer pool addr
submitCommandPairPooled (poolSlotPool pool) mux askingBuilder cmdBuilder
`catch` \(e :: SomeException) -> do
alive <- isMultiplexerAlive mux
if alive
then throwIO e
else do
newMux <- replaceMux pool addr mux
submitCommandPairPooled (poolSlotPool pool) newMux askingBuilder cmdBuilder
{-# INLINE submitToNodeWithAsking #-}
-- | Async version of submitToNode: enqueue the command and return a ResponseSlot.
-- Caller must later call 'waitSlotResult' to get the response.
submitToNodeAsync
:: (Client client)
=> MultiplexPool client
-> NodeAddress
-> Builder.Builder
-> IO ResponseSlot
submitToNodeAsync pool addr cmdBuilder = do
mux <- getMultiplexer pool addr
submitCommandAsync (poolSlotPool pool) mux cmdBuilder
{-# INLINE submitToNodeAsync #-}
-- | Wait for an async submission's result and release the slot.
waitSlotResult :: MultiplexPool client -> ResponseSlot -> IO RespData
waitSlotResult pool slot = waitSlot (poolSlotPool pool) slot
{-# INLINE waitSlotResult #-}
-- | Get or create a multiplexer for a node, round-robin among N muxes.
-- Uses readIORef for the common path (lock-free, no MVar overhead).
-- Per-node counter eliminates cross-node CAS contention.
-- When only 1 mux per node, skips the counter entirely.
getMultiplexer
:: (Client client)
=> MultiplexPool client
-> NodeAddress
-> IO Multiplexer
getMultiplexer pool addr = do
m <- readIORef (poolNodesRef pool)
case Map.lookup addr m of
Just nm -> pickMux nm
Nothing -> modifyMVar (poolNodesLock pool) $ \() -> do
-- Double-check after acquiring lock
m' <- readIORef (poolNodesRef pool)
case Map.lookup addr m' of
Just nm -> do
mux <- pickMux nm
return ((), mux)
Nothing -> do
nm <- createNodeMuxes (poolConnector pool) addr (poolMuxCount pool)
atomicWriteIORef (poolNodesRef pool) (Map.insert addr nm m')
return ((), V.head (nmMuxes nm))
{-# INLINE getMultiplexer #-}
-- | Pick a multiplexer from a NodeMuxes using round-robin.
-- Fast path: single mux skips atomic counter entirely.
pickMux :: NodeMuxes -> IO Multiplexer
pickMux nm
| V.length (nmMuxes nm) == 1 = return $! V.unsafeHead (nmMuxes nm)
| otherwise = do
idx <- atomicModifyIORef' (nmCounter nm) (\n -> (n + 1, n))
return $! nmMuxes nm `V.unsafeIndex` (idx `mod` V.length (nmMuxes nm))
{-# INLINE pickMux #-}
-- | Create N multiplexers for a node address, bundled with a per-node counter.
createNodeMuxes
:: (Client client)
=> Connector client
-> NodeAddress
-> Int
-> IO NodeMuxes
createNodeMuxes connector addr count = do
muxes <- V.generateM count $ \_ -> do
conn <- connector addr
createMultiplexer conn (receive conn)
counter <- newIORef 1
return $ NodeMuxes muxes counter
-- | Replace a dead multiplexer for a node.
replaceMux
:: (Client client)
=> MultiplexPool client
-> NodeAddress
-> Multiplexer
-> IO Multiplexer
replaceMux pool addr oldMux = do
destroyMultiplexer oldMux `catch` \(_ :: SomeException) -> return ()
modifyMVar (poolNodesLock pool) $ \() -> do
m <- readIORef (poolNodesRef pool)
case Map.lookup addr m of
Just nm -> do
-- Find and replace the dead mux in the vector
newMuxes <- V.mapM (\mux -> do
alive <- isMultiplexerAlive mux
if alive
then return mux
else do
conn <- (poolConnector pool) addr
createMultiplexer conn (receive conn)
) (nmMuxes nm)
let nm' = nm { nmMuxes = newMuxes }
atomicWriteIORef (poolNodesRef pool) (Map.insert addr nm' m)
-- Return the first alive one
mux <- pickMux nm'
return ((), mux)
Nothing -> do
nm <- createNodeMuxes (poolConnector pool) addr (poolMuxCount pool)
atomicWriteIORef (poolNodesRef pool) (Map.insert addr nm m)
return ((), V.head (nmMuxes nm))
-- | Tear down all multiplexers across all nodes.
closeMultiplexPool
:: MultiplexPool client
-> IO ()
closeMultiplexPool pool = do
modifyMVar (poolNodesLock pool) $ \() -> do
m <- readIORef (poolNodesRef pool)
mapM_ (\nm -> V.mapM_ (\mux -> destroyMultiplexer mux `catch` \(_ :: SomeException) -> return ()) (nmMuxes nm))
(Map.elems m)
atomicWriteIORef (poolNodesRef pool) Map.empty
return ((), ())