haskell-bee-tests-0.1.0.0: tests/Test/Integration/Worker.hs
{-|
Generic Worker tests.
TODO Add tests for which there are multiple workers in the same
queue.
-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MonoLocalBinds #-} -- TODO can remove this if 'Show a' is removed from 'HasWorkerBroker'
{-# LANGUAGE ScopedTypeVariables #-}
module Test.Integration.Worker
( Message(..)
, workerTests
, multiWorkerTests )
where
import Async.Worker (run, mkDefaultSendJob, mkDefaultSendJob', sendJob', errStrat, toStrat, resendOnKill, KillWorkerSafely(..))
import Async.Worker.Broker.Types qualified as BT
import Async.Worker.Types
import Control.Concurrent (forkIO, killThread, threadDelay, ThreadId, throwTo)
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TVar (readTVarIO, newTVarIO, TVar, modifyTVar)
import Control.Exception (bracket, Exception, throwIO)
import Control.Monad (void)
import Data.Aeson (ToJSON, FromJSON)
import Data.Maybe (fromJust, isJust)
import Data.Set qualified as Set
import GHC.Generics (Generic)
import Test.Hspec
import Test.Integration.Utils (randomQueueName, waitUntil, waitUntilTVarEq, waitUntilTVarPred, waitUntil, waitUntilQueueEmpty)
data TestEnv b =
TestEnv { state :: State b Message
, events :: TVar [Event]
, threadId :: ThreadId
, brokerInitParams :: BT.BrokerInitParams b (Job Message)
-- | a separate broker so that we use separate connection from the worker
, broker :: BT.Broker b (Job Message)
, queueName :: BT.Queue
}
testQueuePrefix :: BT.Queue
testQueuePrefix = "test_worker"
-- | Messages that we will send to worker via the broker (i.e. jobs)
data Message =
Message { text :: String }
| Error
| Timeout { delay :: Int }
deriving (Show, Eq, Ord, Generic)
instance ToJSON Message
instance FromJSON Message
-- instance ToJSON Message where
-- toJSON (Message { text }) = toJSON $ object [ "type" .= ("Message" :: String), "text" .= text ]
-- toJSON Error = toJSON $ object [ "type" .= ("Error" :: String) ]
-- toJSON (Timeout { delay }) = toJSON $ object [ "type" .= ("Timeout" :: String), "delay" .= delay ]
-- instance FromJSON Message where
-- parseJSON = withObject "Message" $ \o -> do
-- type_ <- o .: "type"
-- case type_ of
-- "Message" -> do
-- text <- o .: "text"
-- pure $ Message { text }
-- "Error" -> pure Error
-- "Timeout" -> do
-- delay <- o .: "delay"
-- pure $ Timeout { delay }
-- _ -> fail $ "Unknown type " <> type_
-- | We will handle job events from the worker to track what and how
-- was processed
data Event =
EMessageReceived Message
| EJobFinished Message
| EJobTimeout Message
| EJobError Message
| EWorkerKilledSafely (Maybe Message)
deriving (Eq, Show, Ord)
data SimpleException = SimpleException String
deriving Show
instance Exception SimpleException
-- | Perform Action for this worker
pa :: (HasWorkerBroker b Message) => State b Message -> BT.BrokerMessage b (Job Message) -> IO ()
pa _state bm = do
let job' = BT.toA $ BT.getMessage bm
case job job' of
Message { text = _text } -> return () -- putStrLn text
Error -> throwIO $ SimpleException "Error!"
Timeout { delay } -> threadDelay (delay * second)
pushEvent :: BT.MessageBroker b (Job Message)
=> TVar [Event]
-> (Message -> Event)
-> BT.BrokerMessage b (Job Message)
-> IO ()
pushEvent events evt bm = atomically $ modifyTVar events (\e -> e ++ [evt $ job $ BT.toA $ BT.getMessage bm])
pushEventMMsg :: BT.MessageBroker b (Job Message)
=> TVar [Event]
-> (Maybe Message -> Event)
-> Maybe (BT.BrokerMessage b (Job Message))
-> IO ()
pushEventMMsg events evt mbm = atomically $ modifyTVar events (\e -> e ++ [evt $ job <$> BT.toA <$> BT.getMessage <$> mbm])
initState :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> TVar [Event]
-> BT.Queue
-> String
-> IO (State b Message, ThreadId)
initState bInitParams events queue workerName = do
let pushEvt evt _s = pushEvent events evt
b' <- BT.initBroker bInitParams
let state = State { broker = b'
, queueName = queue
, name = workerName <> " for " <> BT.renderQueue queue
, performAction = pa
, onMessageReceived = Just (pushEvt EMessageReceived)
, onJobFinish = Just (pushEvt EJobFinished)
, onJobTimeout = Just (pushEvt EJobTimeout)
, onJobError = Just (\_s bm _exc -> pushEvent events EJobError bm)
, onWorkerKilledSafely = Just (const $ pushEventMMsg events EWorkerKilledSafely) }
threadId <- forkIO $ run state
pure (state, threadId)
withWorker :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> (TestEnv b -> IO ())
-> IO ()
withWorker brokerInitParams = bracket (setUpWorker brokerInitParams) tearDownWorker
where
-- NOTE I need to pass 'b' again, otherwise GHC can't infer the
-- type of 'b' (even with 'ScopedTypeVariables' turned on)
setUpWorker :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> IO (TestEnv b)
setUpWorker bInitParams = do
b <- BT.initBroker bInitParams
queue <- randomQueueName testQueuePrefix
BT.dropQueue b queue
BT.createQueue b queue
events <- newTVarIO []
(state, threadId) <- initState bInitParams events queue "test worker"
return $ TestEnv { state
, events
, threadId
, brokerInitParams = bInitParams
, broker = b
, queueName = queue }
tearDownWorker :: (HasWorkerBroker b Message)
=> TestEnv b
-> IO ()
tearDownWorker (TestEnv { broker = b, queueName, state = State { broker = b' }, threadId }) = do
killThread threadId
BT.deinitBroker b'
BT.dropQueue b queueName
BT.deinitBroker b
-- | Single 'Worker' tests, abstracting the 'Broker' away.
workerTests :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> Spec
workerTests brokerInitParams =
parallel $ around (withWorker brokerInitParams) $ describe "Worker tests" $ do
it "can process a simple job" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let text = "simple test"
let msg = Message { text }
let job = mkDefaultSendJob' broker queueName msg
void $ sendJob' job
waitUntilTVarEq events [ EMessageReceived msg, EJobFinished msg ] 500
-- queue should be empty
waitUntilQueueEmpty broker queueName 100
it "can handle a job with error" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Error
let job = mkDefaultSendJob' broker queueName msg
void $ sendJob' job
waitUntilTVarEq events [ EMessageReceived msg, EJobError msg ] 500
-- queue should be empty (error jobs archived by default)
waitUntilQueueEmpty broker queueName 100
it "can handle a job with error (with archive)" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Error
let job = (mkDefaultSendJob' broker queueName msg) { errStrat = ESDelete }
void $ sendJob' job
waitUntilTVarEq events [ EMessageReceived msg, EJobError msg ] 500
-- queue should be empty (error job deleted)
waitUntilQueueEmpty broker queueName 100
it "can handle a job with error (with repeat n)" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Error
let job = (mkDefaultSendJob' broker queueName msg) { errStrat = ESRepeatNElseArchive 1 }
void $ sendJob' job
waitUntilTVarEq events [ EMessageReceived msg, EJobError msg
, EMessageReceived msg, EJobError msg ] 500
-- queue should be empty (error job archived)
waitUntilQueueEmpty broker queueName 100
it "can handle a job with timeout (archive strategy)" $ \(TestEnv { broker, queueName, events}) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Timeout { delay = 2 }
let job' = (mkDefaultSendJob broker queueName msg 1) { toStrat = TSArchive }
msgId <- sendJob' job'
waitUntilTVarEq events [ EMessageReceived msg, EJobTimeout msg ] 1200
-- There might be a slight delay before the message is archived
-- (handling exception step in the thread)
waitUntil (isJust <$> BT.getArchivedMessage broker queueName msgId) 100
-- The archive should contain our message
mMsgArchive <- BT.getArchivedMessage broker queueName msgId
mMsgArchive `shouldSatisfy` isJust
let msgArchive = fromJust mMsgArchive
job (BT.toA $ BT.getMessage msgArchive) `shouldBe` msg
-- Queue should be empty, since we archive timed out jobs
waitUntilQueueEmpty broker queueName 100
it "can handle a job with timeout (delete strategy)" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Timeout { delay = 2 }
let job = (mkDefaultSendJob broker queueName msg 1) { toStrat = TSDelete }
void $ sendJob' job
waitUntilTVarEq events [ EMessageReceived msg, EJobTimeout msg ] 1200
-- Queue should be empty, since we archive timed out jobs
waitUntilQueueEmpty broker queueName 100
it "can handle a job with timeout (repeat strategy)" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Timeout { delay = 2 }
let job = (mkDefaultSendJob broker queueName msg 1) { toStrat = TSRepeat }
void $ sendJob' job
waitUntilTVarPred events (\evts -> ((EMessageReceived msg) `elem` evts)
&& ((EJobTimeout msg) `elem` evts)) 2500
-- NOTE It doesn't make sense to check queue size here, the
-- worker just continues to run the errored task in background
-- and currently there is no way to stop it. Maybe implementing
-- a custom test queue could help us here.
it "can handle a job with timeout (repeat N times, then archive strategy)" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Timeout { delay = 2 }
let job = (mkDefaultSendJob broker queueName msg 1) { toStrat = TSRepeatNElseArchive 1 }
void $ sendJob' job
-- | Should have been run 2 times, then archived
waitUntilTVarEq events [ EMessageReceived msg, EJobTimeout msg
, EMessageReceived msg, EJobTimeout msg ] 2500
-- Queue should be empty, since we archive timed out jobs
waitUntilQueueEmpty broker queueName 100
it "can handle a job with timeout (repeat N times, then delete strategy)" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msg = Timeout { delay = 2 }
let job = (mkDefaultSendJob broker queueName msg 1) { toStrat = TSRepeatNElseDelete 1 }
void $ sendJob' job
-- | Should have been run 2 times, then archived
waitUntilTVarEq events [ EMessageReceived msg, EJobTimeout msg
, EMessageReceived msg, EJobTimeout msg ] 2500
-- Queue should be empty, since we deleted timed out jobs
waitUntilQueueEmpty broker queueName 100
it "can process two jobs" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let text1 = "simple test 1"
let msg1 = Message { text = text1 }
let job1 = mkDefaultSendJob' broker queueName msg1
void $ sendJob' job1
let text2 = "simple test 2"
let msg2 = Message { text = text2 }
let job2 = mkDefaultSendJob' broker queueName msg2
void $ sendJob' job2
-- The jobs don't have to be process exactly in this order so we just use Set here
waitUntilTVarPred events (
\e -> Set.fromList e == Set.fromList
[ EMessageReceived msg1, EJobFinished msg1
, EMessageReceived msg2, EJobFinished msg2 ]) 500
-- queue should be empty
waitUntilQueueEmpty broker queueName 100
it "after job with error, continue with another one" $ \(TestEnv { broker, queueName, events }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
let msgErr = Error
let jobErr = mkDefaultSendJob' broker queueName msgErr
void $ sendJob' jobErr
let text = "simple test"
let msg = Message { text }
let job = mkDefaultSendJob' broker queueName msg
void $ sendJob' job
waitUntilTVarPred events (
\e -> Set.fromList e ==
Set.fromList [ EMessageReceived msgErr, EJobError msgErr
, EMessageReceived msg, EJobFinished msg ]) 500
-- queue should be empty
waitUntilQueueEmpty broker queueName 100
it "killing worker should leave a currently processed message on queue (when resendWhenWorkerKilled is True)" $ \(TestEnv { broker, queueName, events, threadId }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
-- Perform some long job
let msg = Timeout { delay = 2 }
let job = (mkDefaultSendJob broker queueName msg 2) { resendOnKill = True }
void $ sendJob' job
-- Let's wait a bit to make sure the message is picked up by the
-- worker
waitUntilTVarEq events [ EMessageReceived msg ] 300
-- Now let's kill the thread immediately
throwTo threadId KillWorkerSafely
putStrLn $ "After KillWorkerSafely: " <> BT.renderQueue queueName
waitUntilTVarEq events [ EMessageReceived msg
, EWorkerKilledSafely (Just msg) ] 300
-- The message should still be there
BT.getQueueSize broker queueName >>= \qs -> do
putStrLn $ "After threadDelay: " <> BT.renderQueue queueName <> " size: " <> show qs
qs `shouldBe` 1
it "killing worker should discard the currently processed message (when resendWhenWorkerKilled is False)" $ \(TestEnv { broker, queueName, events, threadId }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= flip shouldBe 0
-- Perform some long job
let msg = Timeout { delay = 2 }
let job = (mkDefaultSendJob broker queueName msg 2) { resendOnKill = False }
void $ sendJob' job
-- Let's wait a bit to make sure the message is picked up by the
-- worker
waitUntilTVarEq events [ EMessageReceived msg ] 300
waitUntilQueueEmpty broker queueName 300
-- Now let's kill the thread immediately
throwTo threadId KillWorkerSafely
waitUntilTVarEq events [ EMessageReceived msg
, EWorkerKilledSafely (Just msg) ] 300
-- The message shouldn't be there
BT.getQueueSize broker queueName >>= shouldBe 0
data TestEnvMulti b =
TestEnvMulti { broker :: BT.Broker b (Job Message) -- a broker with which you can query its state
, statesWithThreadIds :: [(State b Message, ThreadId)]
, events :: TVar [Event]
, brokerInitParams :: BT.BrokerInitParams b (Job Message)
, queueName :: BT.Queue
, numWorkers :: Int }
testMultiQueuePrefix :: BT.Queue
testMultiQueuePrefix = "test_workers"
withWorkers :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> Int
-> (TestEnvMulti b -> IO ())
-> IO ()
withWorkers brokerInitParams numWorkers = bracket (setUpWorkers brokerInitParams) tearDownWorkers
where
-- NOTE I need to pass 'b' again, otherwise GHC can't infer the
-- type of 'b' (even with 'ScopedTypeVariables' turned on)
setUpWorkers :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> IO (TestEnvMulti b)
setUpWorkers bInitParams = do
b <- BT.initBroker bInitParams
queue <- randomQueueName testMultiQueuePrefix
BT.dropQueue b queue
BT.createQueue b queue
events <- newTVarIO []
statesWithThreadIds <-
mapM (\idx -> initState bInitParams events queue ("test worker " <> show idx)) [1..numWorkers]
return $ TestEnvMulti { broker = b
, queueName = queue
, statesWithThreadIds
, events
, brokerInitParams = bInitParams
, numWorkers }
tearDownWorkers :: (HasWorkerBroker b Message)
=> TestEnvMulti b
-> IO ()
tearDownWorkers (TestEnvMulti { broker = b, queueName, statesWithThreadIds }) = do
mapM_ (\(State { broker = b' }, threadId) -> do
killThread threadId
BT.deinitBroker b'
) statesWithThreadIds
BT.dropQueue b queueName
BT.deinitBroker b
-- | Multiple 'Worker' tests, abstracting the 'Broker' away. All these
-- workers operate on the same queue.
multiWorkerTests :: (HasWorkerBroker b Message)
=> BT.BrokerInitParams b (Job Message)
-> Int
-> Spec
multiWorkerTests brokerInitParams numWorkers =
parallel $ around (withWorkers brokerInitParams numWorkers) $ describe "Worker tests" $ do
it "can process simple jobs" $ \(TestEnvMulti { broker, queueName, events, numWorkers = nw }) -> do
-- no events initially
readTVarIO events >>= shouldBe []
-- queue should be empty
BT.getQueueSize broker queueName >>= shouldBe 0
-- create some messages and make sure they are processed (bombarding with messages)
let msgs = [Message { text = "task " <> show idx } | idx <- [1..20*nw]]
let jobs = map (mkDefaultSendJob' broker queueName) msgs
mapM_ sendJob' jobs
-- The jobs don't have to be process exactly in this order so we just use Set here
let expected = concat [[EMessageReceived msg, EJobFinished msg] | msg <- msgs]
waitUntilTVarPred events (\e -> Set.fromList e == Set.fromList expected) (fromIntegral $ 200*nw)
-- queue should be empty
waitUntilQueueEmpty broker queueName 100
-- it "multiple workers and one long message should result in one message processed" $ \(TestEnvMulti { broker, queueName, events }) -> do
-- let msg = Timeout { delay = 2 }
-- let job' = (mkDefaultSendJob broker queueName msg 1) { toStrat = TSArchive }
-- msgId <- sendJob' job'
-- waitUntilTVarEq events [ EMessageReceived msg, EJobTimeout msg ] 1500
-- -- There might be a slight delay before the message is archived
-- -- (handling exception step in the thread)
-- waitUntil (isJust <$> BT.getArchivedMessage broker queueName msgId) 100
-- -- The archive should contain our message
-- mMsgArchive <- BT.getArchivedMessage broker queueName msgId
-- mMsgArchive `shouldSatisfy` isJust
-- let msgArchive = fromJust mMsgArchive
-- job (BT.toA $ BT.getMessage msgArchive) `shouldBe` msg
-- -- Queue should be empty, since we archive timed out jobs
-- waitUntilQueueEmpty broker queueName 100
second :: Int
second = 1000 * millisecond
millisecond :: Int
millisecond = 1000