packages feed

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

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

module Main (main) where

import           Control.Concurrent                  (forkIO, threadDelay)
import           Control.Concurrent.MVar             (newEmptyMVar, putMVar,
                                                      takeMVar)
import           Control.Exception                   (SomeException, try)
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.Internal.Multiplexer
import           Database.Redis.Resp                 (Encodable (..),
                                                      RespData (..))
import           Test.Hspec

-- ---------------------------------------------------------------------------
-- Mock client for testing without a real Redis connection
-- ---------------------------------------------------------------------------

-- | A mock client that uses IORef-based queues for send/receive.
-- Sent data is accumulated in sendBuf; recv reads from recvBuf.
data MockClient (a :: ConnectionStatus) where
  MockConnected :: !(IORef ByteString)  -- sendBuf (accumulates sent data)
                -> !(IORef [ByteString]) -- recvQueue (list of chunks to return)
                -> 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

-- | Polling recv loop — retries until data is available.
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

-- | Create a mock client and return (client, addRecvData).
-- addRecvData pushes response bytes that the reader thread will consume.
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)

-- | Encode a RespData to strict ByteString (for feeding to mock recv).
encodeResp :: RespData -> ByteString
encodeResp = LBS.toStrict . Builder.toLazyByteString . encode

-- | Encode a RESP command as a Builder (for submitting to multiplexer).
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))

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

main :: IO ()
main = hspec spec

spec :: Spec
spec = do
  slotPoolSpec
  responseSlotSpec
  commandQueueBatchingSpec
  multiplexerLifecycleSpec
  isMultiplexerAliveSpec

slotPoolSpec :: Spec
slotPoolSpec = describe "SlotPool" $ do
  it "allocation returns a valid slot" $ do
    pool <- createSlotPool 64
    -- acquireSlot is not exported, but submitCommandPooled uses it internally.
    -- We test via the public API: create multiplexer, submit, verify response.
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    addRecv (encodeResp (RespSimpleString "OK"))
    resp <- submitCommandPooled pool mux (encodeCmd ["PING"])
    resp `shouldBe` RespSimpleString "OK"
    destroyMultiplexer mux

  it "slots are reusable (return-and-reuse)" $ do
    pool <- createSlotPool 4
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    -- Submit multiple commands sequentially — slots should be reused from pool
    let n = 20
    mapM_ (\i -> do
      addRecv (encodeResp (RespInteger i))
      resp <- submitCommandPooled pool mux (encodeCmd ["GET", "key"])
      resp `shouldBe` RespInteger i
      ) [1..n]
    destroyMultiplexer mux

  it "striped distribution across cores does not crash" $ do
    -- Verify that pool creation with various sizes works
    pool1 <- createSlotPool 1
    pool2 <- createSlotPool 16
    pool3 <- createSlotPool 1024
    -- Use each pool to ensure stripes are functional
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    addRecv (encodeResp (RespSimpleString "OK"))
    _ <- submitCommandPooled pool1 mux (encodeCmd ["PING"])
    addRecv (encodeResp (RespSimpleString "OK"))
    _ <- submitCommandPooled pool2 mux (encodeCmd ["PING"])
    addRecv (encodeResp (RespSimpleString "OK"))
    _ <- submitCommandPooled pool3 mux (encodeCmd ["PING"])
    destroyMultiplexer mux

responseSlotSpec :: Spec
responseSlotSpec = describe "ResponseSlot" $ do
  it "write-then-read returns correct value" $ do
    pool <- createSlotPool 16
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    -- Submit a command; the reader thread writes the response to the slot
    addRecv (encodeResp (RespBulkString "hello"))
    resp <- submitCommandPooled pool mux (encodeCmd ["GET", "key"])
    resp `shouldBe` RespBulkString "hello"
    destroyMultiplexer mux

  it "waitSlot blocks until filled (async submit)" $ do
    pool <- createSlotPool 16
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    -- Submit async — slot should not be filled yet
    slot <- submitCommandAsync pool mux (encodeCmd ["GET", "key"])
    -- Feed response after a short delay
    _ <- forkIO $ do
      threadDelay 50000  -- 50ms delay
      addRecv (encodeResp (RespBulkString "delayed"))
    resp <- waitSlot pool slot
    resp `shouldBe` RespBulkString "delayed"
    destroyMultiplexer mux

commandQueueBatchingSpec :: Spec
commandQueueBatchingSpec = describe "Command queue batching" $ do
  it "multiple enqueued commands are drained together" $ do
    pool <- createSlotPool 64
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)

    -- Submit 5 commands concurrently, then feed 5 responses
    let n = 5
    results <- newIORef ([] :: [RespData])
    barriers <- mapM (\_ -> newEmptyMVar) [1..n]

    -- Submit commands from separate threads
    mapM_ (\(i, barrier) -> forkIO $ do
      resp <- submitCommandPooled pool mux (encodeCmd ["GET", LBS.toStrict $ Builder.toLazyByteString $ Builder.intDec i])
      atomicModifyIORef' results $ \rs -> (rs ++ [resp], ())
      putMVar barrier ()
      ) (zip [1..n] barriers)

    -- Give commands time to be enqueued
    threadDelay 10000

    -- Feed all responses at once (they should be batched)
    let allResponses = mconcat $ map (\i -> encodeResp (RespInteger (fromIntegral (i :: Int)))) [1..n]
    addRecv allResponses

    -- Wait for all to complete
    mapM_ takeMVar barriers

    -- Verify all responses received
    rs <- readIORef results
    length rs `shouldBe` n
    destroyMultiplexer mux

multiplexerLifecycleSpec :: Spec
multiplexerLifecycleSpec = describe "Multiplexer lifecycle" $ do
  it "create and submit returns correct response" $ do
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    addRecv (encodeResp (RespSimpleString "PONG"))
    resp <- submitCommand mux (encodeCmd ["PING"])
    resp `shouldBe` RespSimpleString "PONG"
    destroyMultiplexer mux

  it "destroy then submit throws MultiplexerDead" $ do
    (client, _) <- createMockClient
    mux <- createMultiplexer client (receive client)
    destroyMultiplexer mux
    -- Small delay for destroy to take effect
    threadDelay 10000
    result <- try $ submitCommand mux (encodeCmd ["PING"])
    case result of
      Left (e :: SomeException) -> show e `shouldContain` "MultiplexerDead"
      Right _ -> expectationFailure "Expected MultiplexerDead exception"

  it "submit-after-destroy with pooled also throws MultiplexerDead" $ do
    pool <- createSlotPool 16
    (client, _) <- createMockClient
    mux <- createMultiplexer client (receive client)
    destroyMultiplexer mux
    threadDelay 10000
    result <- try $ submitCommandPooled pool mux (encodeCmd ["GET", "key"])
    case result of
      Left (e :: SomeException) -> show e `shouldContain` "MultiplexerDead"
      Right _ -> expectationFailure "Expected MultiplexerDead exception"

  it "handles multiple sequential commands correctly" $ do
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    -- Send multiple commands and verify ordering
    addRecv (encodeResp (RespSimpleString "OK"))
    r1 <- submitCommand mux (encodeCmd ["SET", "k1", "v1"])
    r1 `shouldBe` RespSimpleString "OK"

    addRecv (encodeResp (RespBulkString "v1"))
    r2 <- submitCommand mux (encodeCmd ["GET", "k1"])
    r2 `shouldBe` RespBulkString "v1"

    addRecv (encodeResp (RespInteger 1))
    r3 <- submitCommand mux (encodeCmd ["DEL", "k1"])
    r3 `shouldBe` RespInteger 1

    addRecv (encodeResp RespNullBulkString)
    r4 <- submitCommand mux (encodeCmd ["GET", "k1"])
    r4 `shouldBe` RespNullBulkString

    destroyMultiplexer mux

  it "handles RespArray responses" $ do
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    let arrResp = RespArray [RespBulkString "v1", RespBulkString "v2", RespNullBulkString]
    addRecv (encodeResp arrResp)
    resp <- submitCommand mux (encodeCmd ["MGET", "k1", "k2", "k3"])
    resp `shouldBe` arrResp
    destroyMultiplexer mux

  it "handles RespError responses without crashing" $ do
    (client, addRecv) <- createMockClient
    mux <- createMultiplexer client (receive client)
    addRecv (encodeResp (RespError "ERR wrong number of arguments"))
    resp <- submitCommand mux (encodeCmd ["GET"])
    resp `shouldBe` RespError "ERR wrong number of arguments"
    destroyMultiplexer mux

isMultiplexerAliveSpec :: Spec
isMultiplexerAliveSpec = describe "isMultiplexerAlive" $ do
  it "returns True for a live multiplexer" $ do
    (client, _) <- createMockClient
    mux <- createMultiplexer client (receive client)
    alive <- isMultiplexerAlive mux
    alive `shouldBe` True
    destroyMultiplexer mux

  it "returns False after destroy" $ do
    (client, _) <- createMockClient
    mux <- createMultiplexer client (receive client)
    destroyMultiplexer mux
    threadDelay 10000
    alive <- isMultiplexerAlive mux
    alive `shouldBe` False