packages feed

hask-redis-mux-0.1.0.0: test/MultiplexPoolSpec.hs

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE GADTs             #-}
{-# LANGUAGE OverloadedStrings #-}

module Main (main) where

import           Control.Concurrent                    (forkIO, threadDelay)
import           Control.Concurrent.MVar               (newEmptyMVar, putMVar,
                                                        takeMVar)
import           Control.Monad.IO.Class                (liftIO)
import           Data.ByteString                       (ByteString)
import qualified Data.ByteString                       as BS
import qualified Data.ByteString.Builder               as Builder
import qualified Data.ByteString.Lazy                  as LBS
import           Data.IORef                            (IORef,
                                                        atomicModifyIORef',
                                                        newIORef, readIORef)
import           Database.Redis.Client                 (Client (..),
                                                        ConnectionStatus (..))
import           Database.Redis.Cluster                (NodeAddress (..))
import           Database.Redis.Internal.MultiplexPool
import           Database.Redis.Resp                   (Encodable (..),
                                                        RespData (..))
import           Test.Hspec

-- ---------------------------------------------------------------------------
-- Mock client for testing without a real Redis connection
-- (Same pattern as MultiplexerSpec)
-- ---------------------------------------------------------------------------

data MockClient (a :: ConnectionStatus) where
  MockConnected :: !(IORef ByteString)   -- sendBuf
                -> !(IORef [ByteString]) -- recvQueue
                -> MockClient 'Connected

instance Client MockClient where
  connect = error "MockClient: connect not supported"
  close _ = return ()
  send (MockConnected sendBuf _) lbs = liftIO $ do
    let !bs = LBS.toStrict lbs
    atomicModifyIORef' sendBuf $ \old -> (old <> bs, ())
  receive (MockConnected sRef recvQueue) = liftIO $ recvLoop sRef recvQueue

recvLoop :: IORef ByteString -> IORef [ByteString] -> IO ByteString
recvLoop sRef recvQueue = do
  mChunk <- atomicModifyIORef' recvQueue $ \xs ->
    case xs of
      []     -> ([], Nothing)
      (y:ys) -> (ys, Just y)
  case mChunk of
    Just chunk -> return chunk
    Nothing -> do
      threadDelay 1000
      recvLoop sRef recvQueue

createMockClient :: IO (MockClient 'Connected, ByteString -> IO ())
createMockClient = do
  sendBuf <- newIORef BS.empty
  recvQueue <- newIORef []
  let client = MockConnected sendBuf recvQueue
      addRecv bs = atomicModifyIORef' recvQueue $ \xs -> (xs ++ [bs], ())
  return (client, addRecv)

-- | Total version of 'head' that avoids the -Wx-partial warning in tests.
firstOf :: [a] -> a
firstOf (x:_) = x
firstOf []    = error "firstOf: empty list"

encodeResp :: RespData -> ByteString
encodeResp = LBS.toStrict . Builder.toLazyByteString . encode

encodeCmd :: [ByteString] -> Builder.Builder
encodeCmd args =
  Builder.byteString ("*" <> bshow (length args) <> "\r\n")
  <> foldMap (\a -> Builder.byteString ("$" <> bshow (BS.length a) <> "\r\n" <> a <> "\r\n")) args
  where bshow x = LBS.toStrict (Builder.toLazyByteString (Builder.intDec x))

-- | A mock connector that creates MockClients and tracks per-node addRecv functions.
-- Returns (connector, getAddRecv) where getAddRecv retrieves the addRecv function
-- for a specific node after it has been connected.
type AddRecvMap = IORef [(NodeAddress, ByteString -> IO ())]

createMockConnector :: IO (NodeAddress -> IO (MockClient 'Connected), AddRecvMap)
createMockConnector = do
  mapRef <- newIORef []
  let connector addr = do
        (client, addRecv) <- createMockClient
        atomicModifyIORef' mapRef $ \xs -> (xs ++ [(addr, addRecv)], ())
        return client
  return (connector, mapRef)

-- | Get addRecv functions for a given node address.
getAddRecvs :: AddRecvMap -> NodeAddress -> IO [ByteString -> IO ()]
getAddRecvs mapRef addr = do
  xs <- readIORef mapRef
  return [f | (a, f) <- xs, a == addr]


-- ---------------------------------------------------------------------------
-- Test nodes
-- ---------------------------------------------------------------------------

node1, node2, node3 :: NodeAddress
node1 = NodeAddress "127.0.0.1" 6379
node2 = NodeAddress "127.0.0.2" 6380
node3 = NodeAddress "127.0.0.3" 6381

-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------

main :: IO ()
main = hspec spec

spec :: Spec
spec = do
  roundRobinSpec
  lazyCreationSpec
  multiNodeRoutingSpec
  poolClosureSpec
  askingSpec

roundRobinSpec :: Spec
roundRobinSpec = describe "Round-robin routing" $ do
  it "sequential submits to same node rotate across multiplexers" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 3  -- 3 muxes per node

    -- First submit triggers lazy creation of 3 multiplexers for node1
    addRecvFns <- do
      -- We need to feed responses for the first command to trigger creation,
      -- but the connector creates clients lazily. Submit from a thread and
      -- then feed responses once the connections are established.
      resultMVar <- newEmptyMVar
      _ <- forkIO $ do
        r <- submitToNode pool node1 (encodeCmd ["PING"])
        putMVar resultMVar r

      -- Wait for connections to be created
      threadDelay 50000
      fns <- getAddRecvs addRecvMap node1
      -- Should have 3 connections (one per mux)
      length fns `shouldBe` 3

      -- Feed response to first mux (round-robin starts at 0)
      (firstOf fns) (encodeResp (RespSimpleString "PONG1"))
      r <- takeMVar resultMVar
      r `shouldBe` RespSimpleString "PONG1"
      return fns

    -- Second submit should go to mux index 1
    do
      resultMVar <- newEmptyMVar
      _ <- forkIO $ do
        r <- submitToNode pool node1 (encodeCmd ["PING"])
        putMVar resultMVar r
      threadDelay 10000
      (addRecvFns !! 1) (encodeResp (RespSimpleString "PONG2"))
      r <- takeMVar resultMVar
      r `shouldBe` RespSimpleString "PONG2"

    -- Third submit should go to mux index 2
    do
      resultMVar <- newEmptyMVar
      _ <- forkIO $ do
        r <- submitToNode pool node1 (encodeCmd ["PING"])
        putMVar resultMVar r
      threadDelay 10000
      (addRecvFns !! 2) (encodeResp (RespSimpleString "PONG3"))
      r <- takeMVar resultMVar
      r `shouldBe` RespSimpleString "PONG3"

    -- Fourth submit should wrap around to mux index 0
    do
      resultMVar <- newEmptyMVar
      _ <- forkIO $ do
        r <- submitToNode pool node1 (encodeCmd ["PING"])
        putMVar resultMVar r
      threadDelay 10000
      (firstOf addRecvFns) (encodeResp (RespSimpleString "PONG4"))
      r <- takeMVar resultMVar
      r `shouldBe` RespSimpleString "PONG4"

    closeMultiplexPool pool

  it "single multiplexer skips round-robin counter" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 1  -- single mux per node

    -- Submit multiple commands — all go to the same (only) mux
    resultMVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar resultMVar r
    threadDelay 50000
    fns <- getAddRecvs addRecvMap node1
    length fns `shouldBe` 1
    (firstOf fns) (encodeResp (RespSimpleString "OK"))
    r1 <- takeMVar resultMVar
    r1 `shouldBe` RespSimpleString "OK"

    -- Second command still goes to same mux
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar resultMVar r
    threadDelay 10000
    (firstOf fns) (encodeResp (RespSimpleString "OK2"))
    r2 <- takeMVar resultMVar
    r2 `shouldBe` RespSimpleString "OK2"

    closeMultiplexPool pool

lazyCreationSpec :: Spec
lazyCreationSpec = describe "Lazy creation" $ do
  it "node muxes created on first access, not at pool creation" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 2

    -- At creation, no connections should exist
    fns <- getAddRecvs addRecvMap node1
    length fns `shouldBe` 0

    -- Access node1 — should trigger creation
    resultMVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar resultMVar r
    threadDelay 50000

    -- Now node1 should have 2 connections
    fns1 <- getAddRecvs addRecvMap node1
    length fns1 `shouldBe` 2

    -- node2 should still have 0
    fns2 <- getAddRecvs addRecvMap node2
    length fns2 `shouldBe` 0

    -- Feed response and clean up
    (firstOf fns1) (encodeResp (RespSimpleString "OK"))
    _ <- takeMVar resultMVar

    closeMultiplexPool pool

  it "second access to same node reuses existing muxes" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 2

    -- First access creates muxes
    resultMVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar resultMVar r
    threadDelay 50000
    fns1 <- getAddRecvs addRecvMap node1
    (firstOf fns1) (encodeResp (RespSimpleString "OK"))
    _ <- takeMVar resultMVar

    -- Second access should NOT create new connections
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar resultMVar r
    threadDelay 10000
    fns2 <- getAddRecvs addRecvMap node1
    -- Still only 2 connections (not 4)
    length fns2 `shouldBe` 2

    -- Feed response via the second mux (round-robin moved to index 1)
    (fns2 !! 1) (encodeResp (RespSimpleString "OK2"))
    _ <- takeMVar resultMVar

    closeMultiplexPool pool

multiNodeRoutingSpec :: Spec
multiNodeRoutingSpec = describe "Multi-node routing" $ do
  it "commands to different nodes use different multiplexers" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 1

    -- Submit to node1
    r1MVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["SET", "k1", "v1"])
      putMVar r1MVar r
    threadDelay 50000

    -- Submit to node2
    r2MVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node2 (encodeCmd ["SET", "k2", "v2"])
      putMVar r2MVar r
    threadDelay 50000

    -- Verify both nodes got separate connections
    fns1 <- getAddRecvs addRecvMap node1
    fns2 <- getAddRecvs addRecvMap node2
    length fns1 `shouldBe` 1
    length fns2 `shouldBe` 1

    -- Feed different responses to confirm independent routing
    (firstOf fns1) (encodeResp (RespSimpleString "OK1"))
    (firstOf fns2) (encodeResp (RespSimpleString "OK2"))

    r1 <- takeMVar r1MVar
    r2 <- takeMVar r2MVar
    r1 `shouldBe` RespSimpleString "OK1"
    r2 `shouldBe` RespSimpleString "OK2"

    closeMultiplexPool pool

  it "three nodes each get independent multiplexer groups" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 2

    -- Access all three nodes
    mvars <- mapM (\node -> do
      mv <- newEmptyMVar
      _ <- forkIO $ do
        r <- submitToNode pool node (encodeCmd ["PING"])
        putMVar mv r
      return mv
      ) [node1, node2, node3]
    threadDelay 50000

    -- Each node should have exactly 2 connections
    mapM_ (\node -> do
      fns <- getAddRecvs addRecvMap node
      length fns `shouldBe` 2
      ) [node1, node2, node3]

    -- Feed responses and verify
    mapM_ (\(node, mv) -> do
      fns <- getAddRecvs addRecvMap node
      (firstOf fns) (encodeResp (RespSimpleString "PONG"))
      r <- takeMVar mv
      r `shouldBe` RespSimpleString "PONG"
      ) (zip [node1, node2, node3] mvars)

    closeMultiplexPool pool

poolClosureSpec :: Spec
poolClosureSpec = describe "Pool closure" $ do
  it "closeMultiplexPool destroys all multiplexers" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 1

    -- Create muxes for two nodes
    r1MVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar r1MVar r
    threadDelay 50000
    fns1 <- getAddRecvs addRecvMap node1
    (firstOf fns1) (encodeResp (RespSimpleString "OK"))
    _ <- takeMVar r1MVar

    r2MVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node2 (encodeCmd ["PING"])
      putMVar r2MVar r
    threadDelay 50000
    fns2 <- getAddRecvs addRecvMap node2
    (firstOf fns2) (encodeResp (RespSimpleString "OK"))
    _ <- takeMVar r2MVar

    -- Close the pool
    closeMultiplexPool pool
    threadDelay 20000

    -- After close, submitting should create new multiplexers (pool is empty)
    -- The old ones should be destroyed — verify by submitting again and seeing new connections
    r3MVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["PING"])
      putMVar r3MVar r
    threadDelay 50000
    fns3 <- getAddRecvs addRecvMap node1
    -- Should now have 2 connections for node1 (1 old + 1 new)
    length fns3 `shouldBe` 2
    -- Feed to the newest one (last in list)
    (last fns3) (encodeResp (RespSimpleString "REOPENED"))
    r3 <- takeMVar r3MVar
    r3 `shouldBe` RespSimpleString "REOPENED"

    closeMultiplexPool pool

  it "closeMultiplexPool on empty pool does not crash" $ do
    (connector, _) <- createMockConnector
    pool <- createMultiplexPool connector 2
    -- Close without ever using it
    closeMultiplexPool pool
    -- Close again — should be idempotent
    closeMultiplexPool pool

askingSpec :: Spec
askingSpec = describe "ASKING support (submitToNodeWithAsking)" $ do
  it "consumes the ASKING +OK and returns only the command response" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 1

    resultMVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNodeWithAsking pool node1
             (encodeCmd ["ASKING"])
             (encodeCmd ["GET", "mykey"])
      putMVar resultMVar r
    threadDelay 50000

    -- Feed two responses: +OK for ASKING, then the real GET response
    fns <- getAddRecvs addRecvMap node1
    length fns `shouldBe` 1
    (firstOf fns) (encodeResp (RespSimpleString "OK"))       -- ASKING response
    (firstOf fns) (encodeResp (RespBulkString "myvalue"))    -- GET response

    r <- takeMVar resultMVar
    -- Should return the GET response, not the ASKING +OK
    r `shouldBe` RespBulkString "myvalue"

    closeMultiplexPool pool

  it "sends ASKING to the target node, not the original node" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 1

    -- Submit ASKING+SET to node2 (the ASK target)
    resultMVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNodeWithAsking pool node2
             (encodeCmd ["ASKING"])
             (encodeCmd ["SET", "migratingkey", "val"])
      putMVar resultMVar r
    threadDelay 50000

    -- node1 should have no connections
    fns1 <- getAddRecvs addRecvMap node1
    length fns1 `shouldBe` 0

    -- node2 should have the connection and receive both responses
    fns2 <- getAddRecvs addRecvMap node2
    length fns2 `shouldBe` 1
    (firstOf fns2) (encodeResp (RespSimpleString "OK"))  -- ASKING response
    (firstOf fns2) (encodeResp (RespSimpleString "OK"))  -- SET response

    r <- takeMVar resultMVar
    r `shouldBe` RespSimpleString "OK"

    closeMultiplexPool pool

  it "regular submitToNode only consumes one response" $ do
    (connector, addRecvMap) <- createMockConnector
    pool <- createMultiplexPool connector 1

    resultMVar <- newEmptyMVar
    _ <- forkIO $ do
      r <- submitToNode pool node1 (encodeCmd ["GET", "normalkey"])
      putMVar resultMVar r
    threadDelay 50000

    fns <- getAddRecvs addRecvMap node1
    -- Only feed one response — no ASKING involved
    (firstOf fns) (encodeResp (RespBulkString "normalvalue"))

    r <- takeMVar resultMVar
    r `shouldBe` RespBulkString "normalvalue"

    closeMultiplexPool pool