packages feed

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 #-}