libraft-0.1.0.0: test/TestRaft.hs
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ScopedTypeVariables #-}
module TestRaft where
import Protolude
import qualified Data.Sequence as Seq
import Data.Sequence (Seq(..), (|>))
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.Serialize as S
import Numeric.Natural
import Control.Monad.Conc.Class (throw)
import qualified Test.Tasty.HUnit as HUnit
import TestUtils
import Raft hiding (sendClient)
import Raft.Logging (logMsgToText, logMsgData, logMsgNodeId, LogMsg)
import Raft.Action
import Raft.Handle
import Raft.Log
import Raft.Monad
import Raft.Types
import Raft.RPC
------------------------------
-- State Machine & Commands --
------------------------------
type Var = ByteString
data StoreCmd
= Set Var Natural
| Incr Var
deriving (Show, Generic)
instance S.Serialize StoreCmd
type Store = Map Var Natural
instance RSMP Store StoreCmd where
data RSMPError Store StoreCmd = StoreError Text deriving (Show)
type RSMPCtx Store StoreCmd = ()
applyCmdRSMP _ store cmd =
Right $ case cmd of
Set x n -> Map.insert x n store
Incr x -> Map.adjust succ x store
testVar :: Var
testVar = "test"
testInitVal :: Natural
testInitVal = 1
testSetCmd :: StoreCmd
testSetCmd = Set testVar testInitVal
testIncrCmd :: StoreCmd
testIncrCmd = Incr testVar
--------------------
-- Scenario Monad --
--------------------
type ClientResps = Map ClientId (Seq (ClientResponse Store))
data TestState = TestState
{ testNodeIds :: NodeIds
, testNodeLogs :: Map NodeId (Entries StoreCmd)
, testNodeSMs :: Map NodeId Store
, testNodeRaftStates :: Map NodeId RaftNodeState
, testNodePersistentStates :: Map NodeId PersistentState
, testNodeConfigs :: Map NodeId NodeConfig
, testClientResps :: ClientResps
} deriving (Show)
type Scenario v = StateT TestState IO v
-- | Run scenario monad with initial state
runScenario :: Scenario () -> IO ()
runScenario scenario = do
let initPersistentState = PersistentState term0 Nothing
let initTestState = TestState
{ testNodeIds = nodeIds
, testNodeLogs = Map.fromList $ (, mempty) <$> Set.toList nodeIds
, testNodeSMs = Map.fromList $ (, mempty) <$> Set.toList nodeIds
, testNodeRaftStates = Map.fromList $ (, initRaftNodeState) <$> Set.toList nodeIds
, testNodePersistentStates = Map.fromList $ (, initPersistentState) <$> Set.toList nodeIds
, testNodeConfigs = Map.fromList $ zip (Set.toList nodeIds) testConfigs
, testClientResps = Map.fromList [(client0, mempty)]
}
evalStateT scenario initTestState
updateStateMachine :: NodeId -> Store -> Scenario ()
updateStateMachine nodeId sm
= modify $ \testState@TestState{..}
-> testState
{ testNodeSMs = Map.insert nodeId sm testNodeSMs
}
updatePersistentState :: NodeId -> PersistentState -> Scenario ()
updatePersistentState nodeId persistentState
= modify $ \testState@TestState{..}
-> testState
{ testNodePersistentStates = Map.insert nodeId persistentState testNodePersistentStates
}
updateRaftNodeState :: NodeId -> RaftNodeState -> Scenario ()
updateRaftNodeState nodeId raftState
= modify $ \testState@TestState{..}
-> testState
{ testNodeRaftStates = Map.insert nodeId raftState testNodeRaftStates
}
getNodeInfo :: NodeId -> Scenario (NodeConfig, Store, RaftNodeState, PersistentState)
getNodeInfo nId = do
nodeConfigs <- gets testNodeConfigs
nodeSMs <- gets testNodeSMs
nodeRaftStates <- gets testNodeRaftStates
nodePersistentStates <- gets testNodePersistentStates
let Just nodeInfo = Map.lookup nId nodeConfigs >>= \config ->
Map.lookup nId nodeSMs >>= \store ->
Map.lookup nId nodeRaftStates >>= \raftState ->
Map.lookup nId nodePersistentStates >>= \persistentState ->
pure (config, store, raftState, persistentState)
pure nodeInfo
lookupClientResps :: ClientId -> ClientResps -> Seq (ClientResponse Store)
lookupClientResps clientId cResps =
case Map.lookup clientId cResps of
Nothing -> panic "Client id not found"
Just resps -> resps
lookupLastClientResp :: ClientId -> ClientResps -> ClientResponse Store
lookupLastClientResp clientId cResps = r
where
(_ :|> r) = lookupClientResps clientId cResps
sendClient :: ClientId -> ClientResponse Store -> Scenario ()
sendClient clientId resp = do
cResps <- gets testClientResps
let resps = lookupClientResps clientId cResps
modify (\st -> st { testClientResps = Map.insert clientId (resps |> resp) (testClientResps st) })
-------------------
-- Log instances --
-------------------
newtype NodeEnvError = NodeEnvError Text
deriving (Show)
instance Exception NodeEnvError
type RTLog = ReaderT NodeId (StateT TestState IO)
instance RaftWriteLog RTLog StoreCmd where
type RaftWriteLogError RTLog = NodeEnvError
writeLogEntries newEntries = do
nid <- ask
Just log <- Map.lookup nid <$> gets testNodeLogs
fmap Right $ modify $ \testState@TestState{..} ->
testState { testNodeLogs = Map.insert nid (log Seq.>< newEntries) testNodeLogs }
instance RaftReadLog RTLog StoreCmd where
type RaftReadLogError RTLog = NodeEnvError
readLogEntry (Index idx) = do
nid <- ask
Just log <- Map.lookup nid <$> gets testNodeLogs
case log Seq.!? fromIntegral (if idx == 0 then 0 else idx - 1) of
Nothing -> pure (Right Nothing)
Just e -> pure (Right (Just e))
readLastLogEntry = do
nid <- ask
Just log <- Map.lookup nid <$> gets testNodeLogs
case log of
Seq.Empty -> pure (Right Nothing)
(_ Seq.:|> e) -> pure (Right (Just e))
instance RaftDeleteLog RTLog StoreCmd where
type RaftDeleteLogError RTLog = NodeEnvError
deleteLogEntriesFrom idx = do
nid <- ask
Just log <- Map.lookup nid <$> gets testNodeLogs
fmap (const (Right DeleteSuccess)) $ modify $ \testState@TestState{..} ->
testState { testNodeLogs = Map.insert nid (Seq.dropWhileR ((>= idx) . entryIndex) log) testNodeLogs }
-------------------------------
-- Handle actions and events --
-------------------------------
testHandleLogs :: Maybe [NodeId] -> (Text -> IO ()) -> [LogMsg] -> Scenario ()
testHandleLogs nIdsM f logs = liftIO $
case nIdsM of
Nothing -> mapM_ (f . logMsgToText) logs
Just nIds ->
mapM_ (f . logMsgToText) $ flip filter logs $ \log ->
logMsgNodeId (logMsgData log) `elem` nIds
testHandleActions :: NodeId -> [Action Store StoreCmd] -> Scenario ()
testHandleActions sender =
mapM_ (testHandleAction sender)
testHandleAction :: NodeId -> Action Store StoreCmd -> Scenario ()
testHandleAction sender action = do
case action of
SendRPC nId rpcAction -> do
msg <- mkRPCfromSendRPCAction sender rpcAction
testHandleEvent nId (MessageEvent (RPCMessageEvent msg))
SendRPCs msgs ->
mapM_ (\(nId, rpcAction) -> do
msg <- mkRPCfromSendRPCAction sender rpcAction
testHandleEvent nId (MessageEvent (RPCMessageEvent msg))
) (Map.toList msgs)
BroadcastRPC nIds rpcAction -> mapM_ (\nId -> do
msg <- mkRPCfromSendRPCAction sender rpcAction
testHandleEvent nId (MessageEvent (RPCMessageEvent msg))) nIds
RespondToClient clientId resp -> sendClient clientId resp
ResetTimeoutTimer _ -> noop
AppendLogEntries entries -> do
runReaderT (updateLog entries) sender
modify $ \testState@TestState{..}
-> case Map.lookup sender testNodeRaftStates of
Nothing -> panic "No NodeState"
Just (RaftNodeState ns) -> testState
{ testNodeRaftStates = Map.insert sender (RaftNodeState (setLastLogEntryData ns entries)) testNodeRaftStates
}
where
noop = pure ()
mkRPCfromSendRPCAction
:: NodeId -> SendRPCAction StoreCmd -> Scenario (RPCMessage StoreCmd)
mkRPCfromSendRPCAction nId sendRPCAction = do
sc <- get
(nodeConfig, _, raftState@(RaftNodeState ns), _) <- getNodeInfo nId
RPCMessage (configNodeId nodeConfig) <$>
case sendRPCAction of
SendAppendEntriesRPC aeData -> do
(entries, prevLogIndex, prevLogTerm, aeReadReq) <-
case aedEntriesSpec aeData of
FromIndex idx -> do
eLogEntries <- runReaderT (readLogEntriesFrom (decrIndexWithDefault0 idx)) nId
case eLogEntries of
Left err -> throw err
Right log ->
case log of
pe :<| entries@(e :<| _)
| idx == 1 -> pure (log, index0, term0, Nothing)
| otherwise -> pure (entries, entryIndex pe, entryTerm pe, Nothing)
_ -> pure (log, index0, term0, Nothing)
FromClientWriteReq e -> prevEntryData nId e
FromNewLeader e -> prevEntryData nId e
NoEntries spec -> do
let readReq' =
case spec of
FromClientReadReq n -> Just n
_ -> Nothing
(lastLogIndex, lastLogTerm) = getLastLogEntryData ns
pure (Empty, lastLogIndex, lastLogTerm, readReq')
let leaderId = LeaderId (configNodeId nodeConfig)
pure . toRPC $
AppendEntries
{ aeTerm = aedTerm aeData
, aeLeaderId = leaderId
, aePrevLogIndex = prevLogIndex
, aePrevLogTerm = prevLogTerm
, aeEntries = entries
, aeLeaderCommit = aedLeaderCommit aeData
, aeReadRequest = aeReadReq
}
SendAppendEntriesResponseRPC aer -> do
pure (toRPC aer)
SendRequestVoteRPC rv -> pure (toRPC rv)
SendRequestVoteResponseRPC rvr -> pure (toRPC rvr)
prevEntryData nId e = do
(x,y,z) <- prevEntryData' nId e
pure (x,y,z,Nothing)
prevEntryData' nId e
| entryIndex e == Index 1 = pure (Seq.singleton e, index0, term0)
| otherwise = do
eLogEntry <- runReaderT (readLogEntry (decrIndexWithDefault0 (entryIndex e))) nId
case eLogEntry of
Left err -> throw err
Right Nothing -> pure (Seq.singleton e, index0, term0)
Right (Just (prevEntry :: Entry StoreCmd)) ->
pure (Seq.singleton e, entryIndex prevEntry, entryTerm prevEntry)
testHandleEvent :: NodeId -> Event StoreCmd -> Scenario ()
testHandleEvent nodeId event = do
(nodeConfig, sm, raftState', persistentState) <- getNodeInfo nodeId
raftState <- loadLogEntryTermAtAePrevLogIndex raftState'
let transitionEnv = TransitionEnv nodeConfig sm raftState
let (newRaftState, newPersistentState, actions, logMsgs) = handleEvent raftState transitionEnv persistentState event
updatePersistentState nodeId newPersistentState
updateRaftNodeState nodeId newRaftState
testHandleActions nodeId actions
testHandleLogs Nothing (const $ pure ()) logMsgs
applyLogEntries nodeId sm
where
applyLogEntries
:: NodeId
-> Store
-> Scenario ()
applyLogEntries nId stateMachine = do
(_, _, raftNodeState@(RaftNodeState nodeState), _) <- getNodeInfo nId
let lastAppliedIndex = lastApplied nodeState
when (commitIndex nodeState > lastAppliedIndex) $ do
let resNodeState = incrLastApplied nodeState
modify $ \testState@TestState{..} -> testState {
testNodeRaftStates = Map.insert nId (RaftNodeState resNodeState) testNodeRaftStates }
let newLastAppliedIndex = lastApplied resNodeState
eLogEntry <- runReaderT (readLogEntry newLastAppliedIndex) nId
case eLogEntry of
Left err -> throw err
Right Nothing -> panic "No log entry at 'newLastAppliedIndex'"
Right (Just logEntry) -> do
case entryValue logEntry of
NoValue -> applyLogEntries nId stateMachine
EntryValue v -> do
let Right newStateMachine = applyCmdRSMP () stateMachine v
updateStateMachine nId newStateMachine
applyLogEntries nId newStateMachine
where
incrLastApplied :: NodeState ns -> NodeState ns
incrLastApplied nodeState =
case nodeState of
NodeFollowerState fs ->
let lastApplied' = incrIndex (fsLastApplied fs)
in NodeFollowerState $ fs { fsLastApplied = lastApplied' }
NodeCandidateState cs ->
let lastApplied' = incrIndex (csLastApplied cs)
in NodeCandidateState $ cs { csLastApplied = lastApplied' }
NodeLeaderState ls ->
let lastApplied' = incrIndex (lsLastApplied ls)
in NodeLeaderState $ ls { lsLastApplied = lastApplied' }
lastApplied :: NodeState ns -> Index
lastApplied = fst . getLastAppliedAndCommitIndex
commitIndex :: NodeState ns -> Index
commitIndex = snd . getLastAppliedAndCommitIndex
-- In the case that a node is a follower receiving an AppendEntriesRPC
-- Event, read the log at the aePrevLogIndex
loadLogEntryTermAtAePrevLogIndex :: RaftNodeState -> Scenario RaftNodeState
loadLogEntryTermAtAePrevLogIndex (RaftNodeState rns) =
case event of
MessageEvent (RPCMessageEvent (RPCMessage _ (AppendEntriesRPC ae))) -> do
case rns of
NodeFollowerState fs -> do
eEntry <- runReaderT (readLogEntry (aePrevLogIndex ae)) nodeId
case eEntry of
Left err -> throw err
Right (mEntry :: Maybe (Entry StoreCmd)) ->
pure $ RaftNodeState $ NodeFollowerState fs
{ fsTermAtAEPrevIndex = entryTerm <$> mEntry }
_ -> pure (RaftNodeState rns)
_ -> pure (RaftNodeState rns)
testHeartbeat :: NodeId -> Scenario ()
testHeartbeat sender = do
nodeRaftStates <- gets testNodeRaftStates
nodePersistentStates <- gets testNodePersistentStates
nIds <- gets testNodeIds
let Just raftState = Map.lookup sender nodeRaftStates
Just persistentState = Map.lookup sender nodePersistentStates
unless (isRaftLeader raftState) $ panic $ toS (show sender ++ " must a be a leader to heartbeat")
let LeaderState{..} = getInnerLeaderState raftState
let aeData = AppendEntriesData
{ aedTerm = currentTerm persistentState
, aedEntriesSpec = NoEntries FromHeartbeat
, aedLeaderCommit = lsCommitIndex
}
-- Broadcast AppendEntriesRPC
testHandleAction sender
(BroadcastRPC (Set.filter (sender /=) nIds) (SendAppendEntriesRPC aeData))
where
getInnerLeaderState :: RaftNodeState -> LeaderState
getInnerLeaderState nodeState = case nodeState of
(RaftNodeState (NodeLeaderState leaderState)) -> leaderState
_ -> panic "Node must be a leader to access its leader state"
----------------------
-- Test raft events --
----------------------
testInitLeader :: NodeId -> Scenario ()
testInitLeader nId =
testHandleEvent nId (TimeoutEvent ElectionTimeout)
testClientReadRequest :: NodeId -> Scenario ()
testClientReadRequest nId =
testHandleEvent nId (MessageEvent
(ClientRequestEvent
(ClientRequest client0 ClientReadReq)))
testClientWriteRequest :: StoreCmd -> NodeId -> Scenario ()
testClientWriteRequest cmd nId =
testHandleEvent nId (MessageEvent
(ClientRequestEvent
(ClientRequest client0 (ClientWriteReq cmd))))
----------------
-- Unit tests --
----------------
-- When the protocol starts, every node is a follower
unit_init_protocol :: IO ()
unit_init_protocol = runScenario $ do
-- Node 0 becomes the leader
testInitLeader node0
raftStates <- gets testNodeRaftStates
-- Node0 has become leader and other nodes are followers
liftIO $ assertLeader raftStates [(node0, NoLeader), (node1, CurrentLeader (LeaderId node0)), (node2, CurrentLeader (LeaderId node0))]
liftIO $ assertNodeState raftStates [(node0, isRaftLeader), (node1, isRaftFollower), (node2, isRaftFollower)]
unit_append_entries_client_request :: IO ()
unit_append_entries_client_request = runScenario $ do
testInitLeader node0
raftStates0 <- gets testNodeRaftStates
sms0 <- gets testNodeSMs
logs0 <- gets testNodeLogs
liftIO $ assertPersistedLogs logs0 [(node0, 1), (node1, 1), (node2, 1)]
liftIO $ assertCommittedLogIndex raftStates0 [(node0, Index 1), (node1, Index 0), (node2, Index 0)]
liftIO $ assertAppliedLogIndex raftStates0 [(node0, Index 1), (node1, Index 0), (node2, Index 0)]
liftIO $ assertSMs sms0 [(node0, mempty), (node1, mempty), (node2, mempty)]
testClientWriteRequest testSetCmd node0
raftStates1 <- gets testNodeRaftStates
sms1 <- gets testNodeSMs
logs1 <- gets testNodeLogs
liftIO $ assertPersistedLogs logs1 [(node0, 2), (node1, 2), (node2, 2)]
liftIO $ assertCommittedLogIndex raftStates1 [(node0, Index 2), (node1, Index 1), (node2, Index 1)]
liftIO $ assertAppliedLogIndex raftStates1 [(node0, Index 2), (node1, Index 1), (node2, Index 1)]
liftIO $ assertSMs sms1 [(node0, Map.fromList [(testVar, testInitVal)]), (node1, mempty), (node2, mempty)]
---------------------------- HEARTBEAT 1 ------------------------------
-- After leader heartbeats, followers commit and apply leader's entries
testHeartbeat node0
raftStates2 <- gets testNodeRaftStates
sms2 <- gets testNodeSMs
logs2 <- gets testNodeLogs
liftIO $ assertPersistedLogs logs2 [(node0, 2), (node1, 2), (node2, 2)]
liftIO $ assertCommittedLogIndex raftStates2 [(node0, Index 2), (node1, Index 2), (node2, Index 2)]
liftIO $ assertAppliedLogIndex raftStates2 [(node0, Index 2), (node1, Index 2), (node2, Index 2)]
liftIO $ assertSMs sms2 [(node0, Map.fromList [(testVar, testInitVal)]), (node1, Map.fromList [(testVar, testInitVal)]), (node2, Map.fromList [(testVar, testInitVal)])]
unit_incr_value :: IO ()
unit_incr_value = runScenario $ do
testInitLeader node0
testClientWriteRequest testSetCmd node0
testClientWriteRequest testIncrCmd node0
testHeartbeat node0
sms <- gets testNodeSMs
liftIO $ assertSMs sms [(node0, Map.fromList [(testVar, succ testInitVal)]), (node1, Map.fromList [(testVar, succ testInitVal)]), (node2, Map.fromList [(testVar, succ testInitVal)])]
unit_mult_incr_value :: IO ()
unit_mult_incr_value = runScenario $ do
testInitLeader node0
testClientWriteRequest testSetCmd node0
let reps = 10
replicateM_ (fromIntegral 10) (testClientWriteRequest testIncrCmd node0)
testHeartbeat node0
sms <- gets testNodeSMs
liftIO $ assertSMs sms [(node0, Map.fromList [(testVar, testInitVal + reps)]), (node1, Map.fromList [(testVar, testInitVal + reps)]), (node2, Map.fromList [(testVar, testInitVal + reps)])]
unit_client_req_no_leader :: IO ()
unit_client_req_no_leader = runScenario $ do
testClientWriteRequest testSetCmd node1
cResps <- gets testClientResps
let ClientRedirectResponse (ClientRedirResp lResp) = lookupLastClientResp client0 cResps
liftIO $ HUnit.assertBool "A follower should return a NoLeader response" (lResp == NoLeader)
unit_redirect_leader :: IO ()
unit_redirect_leader = runScenario $ do
testInitLeader node0
testClientWriteRequest testSetCmd node1
cResps <- gets testClientResps
let ClientRedirectResponse (ClientRedirResp (CurrentLeader (LeaderId lResp))) = lookupLastClientResp client0 cResps
liftIO $ HUnit.assertBool "A follower should point to the current leader" (lResp == node0)
unit_client_read_response :: IO ()
unit_client_read_response = runScenario $ do
testInitLeader node0
testClientWriteRequest testSetCmd node0
testClientReadRequest node0
cResps <- gets testClientResps
let ClientReadResponse (ClientReadResp store) = lookupLastClientResp client0 cResps
liftIO $ HUnit.assertBool "A client should receive the current state of the store"
(store == Map.fromList [(testVar, testInitVal)])
unit_client_write_response :: IO ()
unit_client_write_response = runScenario $ do
testInitLeader node0
testClientReadRequest node0
testClientWriteRequest testSetCmd node0
cResps <- gets testClientResps
let ClientWriteResponse (ClientWriteResp idx) = lookupLastClientResp client0 cResps
liftIO $ HUnit.assertBool "A client should receive an aknowledgement of a writing request"
(idx == Index 2)
unit_new_leader :: IO ()
unit_new_leader = runScenario $ do
testInitLeader node0
testHandleEvent node1 (TimeoutEvent ElectionTimeout)
raftStates <- gets testNodeRaftStates
liftIO $ assertNodeState raftStates [(node0, isRaftFollower), (node1, isRaftLeader), (node2, isRaftFollower)]
liftIO $ assertLeader raftStates [(node0, CurrentLeader (LeaderId node1)), (node1, NoLeader), (node2, CurrentLeader (LeaderId node1))]
------------------
-- Assert utils --
------------------
assertNodeState :: Map NodeId RaftNodeState -> [(NodeId, RaftNodeState -> Bool)] -> IO ()
assertNodeState raftNodeStates =
mapM_ (\(nId, isNodeState) -> HUnit.assertBool (show nId ++ " should be in a different state")
(maybe False isNodeState (Map.lookup nId raftNodeStates)))
assertLeader :: Map NodeId RaftNodeState -> [(NodeId, CurrentLeader)] -> IO ()
assertLeader raftNodeStates =
mapM_ (\(nId, leader) -> HUnit.assertBool (show nId ++ " should recognize " ++ show leader ++ " as its leader")
(maybe False ((== leader) . checkCurrentLeader) (Map.lookup nId raftNodeStates)))
assertCommittedLogIndex :: Map NodeId RaftNodeState -> [(NodeId, Index)] -> IO ()
assertCommittedLogIndex raftNodeStates =
mapM_ (\(nId, idx) -> HUnit.assertBool (show nId ++ " should have " ++ show idx ++ " as its last committed index")
(maybe False ((== idx) . getCommittedLogIndex) (Map.lookup nId raftNodeStates)))
assertAppliedLogIndex :: Map NodeId RaftNodeState -> [(NodeId, Index)] -> IO ()
assertAppliedLogIndex raftNodeStates =
mapM_ (\(nId, idx) -> HUnit.assertBool (show nId ++ " should have " ++ show idx ++ " as its last applied index")
(maybe False ((== idx) . getLastAppliedLog) (Map.lookup nId raftNodeStates)))
assertPersistedLogs :: Map NodeId (Entries v) -> [(NodeId, Int)] -> IO ()
assertPersistedLogs persistedLogs =
mapM_ (\(nId, len) -> HUnit.assertBool (show nId ++ " should have appended " ++ show len ++ " logs")
(maybe False ((== len) . Seq.length) (Map.lookup nId persistedLogs)))
assertSMs :: Map NodeId Store -> [(NodeId, Store)] -> IO ()
assertSMs sms =
mapM_ (\(nId, sm) -> HUnit.assertBool (show nId ++ " state machine " ++ show sm ++ " is not valid")
(maybe False (== sm) (Map.lookup nId sms)))