hask-redis-mux-0.1.0.0: lib/cluster/Database/Redis/Cluster.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Redis cluster topology model and slot routing.
--
-- Provides types for representing cluster nodes, slot ranges, and the full topology,
-- plus functions to compute hash slots, parse @CLUSTER SLOTS@ responses, and look up
-- which node owns a given slot.
--
-- @since 0.1.0.0
module Database.Redis.Cluster
( ClusterNode (..),
SlotRange (..),
ClusterTopology (..),
NodeRole (..),
NodeAddress (..),
calculateSlot,
extractHashTag,
parseClusterSlots,
findNodeForSlot,
findNodeAddressForSlot,
)
where
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Time (UTCTime)
import Data.Vector (Vector)
import qualified Data.Vector as V
import Data.Word (Word16)
import Database.Redis.Crc16 (crc16)
import Database.Redis.Resp (RespData (..))
-- | Node role in the cluster
data NodeRole = Master | Replica
deriving (Show, Eq)
-- | Network address for connecting to a cluster node.
data NodeAddress = NodeAddress
{ nodeHost :: String,
nodePort :: Int
}
deriving (Show, Eq, Ord)
-- | A cluster node with its identity, address, role, and slot assignments.
data ClusterNode = ClusterNode
{ nodeId :: ByteString,
nodeAddress :: NodeAddress,
nodeRole :: NodeRole,
nodeSlotsServed :: [SlotRange],
nodeReplicas :: [ByteString] -- Node IDs of replicas
}
deriving (Show, Eq)
-- | A contiguous range of hash slots and the nodes responsible for them.
data SlotRange = SlotRange
{ slotStart :: Word16, -- 0-16383
slotEnd :: Word16,
slotMaster :: ByteString, -- Node ID reference
slotReplicas :: [ByteString] -- Node ID references
}
deriving (Show, Eq)
-- | Full snapshot of the cluster topology: a fast O(1) slot-to-node vector,
-- a map of all known nodes, and the time the snapshot was taken.
data ClusterTopology = ClusterTopology
{ topologySlots :: Vector ByteString, -- 16384 slots, each mapped to node ID
topologyAddresses :: Vector NodeAddress, -- 16384 slots, each mapped directly to NodeAddress (hot path)
topologyNodes :: Map ByteString ClusterNode, -- Node ID -> full node details
topologyUpdateTime :: UTCTime
}
deriving (Show)
-- | Calculate the hash slot (0–16383) for a Redis key.
-- Respects hash tags: if the key starts with @{tag}@, only @tag@ is hashed.
calculateSlot :: ByteString -> Word16
calculateSlot key =
let !hashKey = extractHashTag key
in crc16 hashKey
{-# INLINE calculateSlot #-}
-- | Extract hash tag from a key if present
-- Pattern: {tag} - returns the content within the first valid {} pair
-- Examples:
-- "{user}:profile" -> "user"
-- "key" -> "key"
-- "{}" -> "{}"
-- "{user" -> "{user"
-- "key{tag}" -> "key{tag}" (no tag at end)
extractHashTag :: ByteString -> ByteString
extractHashTag key =
case BS.breakSubstring "{" key of
(before, rest)
| not (BS.null rest) && not (BS.null before) ->
-- Found { but there's content before it - no valid tag
key
| not (BS.null rest) ->
-- Found { at start, check for closing }
case BS.breakSubstring "}" (BS.tail rest) of
(tag, after)
| not (BS.null after) && not (BS.null tag) -> tag
| otherwise -> key
| otherwise -> key
-- | Parse the RESP response from @CLUSTER SLOTS@ into a 'ClusterTopology'.
-- Returns 'Left' with an error message if the response format is unexpected.
parseClusterSlots :: RespData -> UTCTime -> Either String ClusterTopology
parseClusterSlots (RespArray slots) currentTime = do
ranges <- mapM parseSlotRange slots
let (slotMap, nodeMap) = buildTopology ranges
addrMap = buildAddressVector slotMap nodeMap
return $ ClusterTopology slotMap addrMap nodeMap currentTime
where
parseSlotRange :: RespData -> Either String (SlotRange, [(ByteString, NodeAddress)])
parseSlotRange (RespArray (RespInteger start : RespInteger end : masterInfo : replicaInfos)) = do
master <- parseNodeInfo masterInfo
replicas <- mapM parseNodeInfo replicaInfos
let range = SlotRange
{ slotStart = fromIntegral start,
slotEnd = fromIntegral end,
slotMaster = fst master,
slotReplicas = map fst replicas
}
return (range, master : replicas)
parseSlotRange other = Left $ "Invalid slot range format: " ++ show other
parseNodeInfo :: RespData -> Either String (ByteString, NodeAddress)
parseNodeInfo (RespArray (RespBulkString host : RespInteger port : RespBulkString nodeIdBS : _)) =
Right (nodeIdBS, NodeAddress (BS8.unpack host) (fromIntegral port))
parseNodeInfo other = Left $ "Invalid node info format: " ++ show other
buildTopology :: [(SlotRange, [(ByteString, NodeAddress)])] -> (Vector ByteString, Map ByteString ClusterNode)
buildTopology rangesWithNodes =
let slotVector = V.replicate 16384 ""
slotMap = foldl (\v (range, _) -> assignSlots v range) slotVector rangesWithNodes
nodeMap = foldl buildNodeMap Map.empty rangesWithNodes
in (slotMap, nodeMap)
assignSlots :: Vector ByteString -> SlotRange -> Vector ByteString
assignSlots vec range =
foldl
(\v slot -> v V.// [(fromIntegral slot, slotMaster range)])
vec
[slotStart range .. slotEnd range]
buildNodeMap :: Map ByteString ClusterNode -> (SlotRange, [(ByteString, NodeAddress)]) -> Map ByteString ClusterNode
buildNodeMap nodeMap (range, nodeInfos) =
case nodeInfos of
[] -> nodeMap -- No nodes in this range (shouldn't happen)
(masterId, masterAddr) : replicaInfos ->
-- Insert master node
let nodeMapWithMaster = insertNode nodeMap (masterId, masterAddr) Master
-- Insert replica nodes
nodeMapWithReplicas = foldl (\nm (replicaId, replicaAddr) ->
insertNode nm (replicaId, replicaAddr) Replica)
nodeMapWithMaster replicaInfos
in nodeMapWithReplicas
where
insertNode :: Map ByteString ClusterNode -> (ByteString, NodeAddress) -> NodeRole -> Map ByteString ClusterNode
insertNode nm (nodeId, addr) role =
if Map.member nodeId nm
then nm -- Node already exists, don't overwrite
else Map.insert nodeId (ClusterNode nodeId addr role [range] []) nm
parseClusterSlots other _ = Left $ "Expected array of slot ranges, got: " ++ show other
-- | Build a slot-to-NodeAddress vector from the slot-to-nodeId vector and node map.
-- Used for O(1) hot path lookups that skip the Map entirely.
buildAddressVector :: Vector ByteString -> Map ByteString ClusterNode -> Vector NodeAddress
buildAddressVector slotVec nodeMap =
V.map (\nid -> case Map.lookup nid nodeMap of
Just node -> nodeAddress node
Nothing -> NodeAddress "" 0 -- placeholder for unassigned slots
) slotVec
-- | Look up the master node ID responsible for a given slot.
-- Returns 'Nothing' if the slot is out of range (≥ 16384).
findNodeForSlot :: ClusterTopology -> Word16 -> Maybe ByteString
findNodeForSlot topology slot
| slot < 16384 = Just $ topologySlots topology V.! fromIntegral slot
| otherwise = Nothing
-- | Look up the 'NodeAddress' responsible for a given slot directly (O(1), no Map lookup).
-- Returns 'Nothing' if the slot is out of range (≥ 16384).
findNodeAddressForSlot :: ClusterTopology -> Word16 -> Maybe NodeAddress
findNodeAddressForSlot topology slot
| slot < 16384 = Just $! topologyAddresses topology V.! fromIntegral slot
| otherwise = Nothing
{-# INLINE findNodeAddressForSlot #-}