haskell-bee-0.1.0.0: src/Async/Worker.hs
{-|
Module : Async.Worker
Description : Abstract async worker implementation using the 'Queue' typeclass
Copyright : (c) Gargantext, 2024-Present
License : AGPL
Maintainer : gargantext@iscpif.fr
Stability : experimental
Portability : POSIX
Asynchronous worker.
-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Async.Worker
( KillWorkerSafely(..)
-- * Running
, run
, runSingle
, runSingle'
-- * Sending jobs
, sendJob
-- ** 'SendJob' wrappers
-- $sendJob
, mkDefaultSendJob
, mkDefaultSendJob'
, sendJob'
, SendJob(..) )
where
{- | 'Broker' class type for the underlying broker
-}
import Async.Worker.Broker
{- | Various worker types, in particular 'State'
-}
import Async.Worker.Types
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TVar (readTVarIO, newTVarIO, writeTVar)
import Control.Concurrent.Timeout qualified as Timeout
import Control.Exception.Safe (catches, Handler(..), throwIO, SomeException, Exception)
import Control.Monad (forever, void, when)
-- | If you want to stop a worker safely, use `throwTo'
-- 'workerThreadId' 'KillWorkerSafely'. This way the worker will stop
-- whatever is doing now and resend the message back to the
-- broker. This way you won't lose your jobs. If you don't care about
-- resuming a job, just set 'resendWhenWorkerKilled' property to
-- 'False'.
data KillWorkerSafely = KillWorkerSafely
deriving (Show)
instance Exception KillWorkerSafely
-- | This is the main function to start a worker. It's an infinite
-- loop of reading the next broker message, processing it and handling
-- any errors, issues that might arrise in the meantime.
run :: (HasWorkerBroker b a) => State b a -> IO ()
run state@(State { .. }) = do
createQueue broker queueName
forever $ runSingle state
-- | Fetch a single job and run it (create queue first)
runSingle :: (HasWorkerBroker b a) => State b a -> IO ()
runSingle state@(State { .. }) = do
createQueue broker queueName
runSingle' state
-- | Fetch a single job and run it. Assumes the queue already exists
runSingle' :: (HasWorkerBroker b a) => State b a -> IO ()
runSingle' state@(State { .. }) = do
-- TVar to hold currently processed job. This is used for
-- exception handling.
mBrokerMessageTVar <- newTVarIO Nothing -- :: IO (TVar (Maybe (BrokerMessage b (Job a))))
catches (do
brokerMessage <- readMessageWaiting broker queueName
atomically $ writeTVar mBrokerMessageTVar (Just brokerMessage)
handleMessage state brokerMessage
callWorkerJobEvent onJobFinish state brokerMessage
) [
Handler $ \(_err :: KillWorkerSafely) -> do
mBrokerMessage <- readTVarIO mBrokerMessageTVar
case mBrokerMessage of
Just brokerMessage -> do
let job = toA $ getMessage brokerMessage
let mdata = metadata job
-- Should we resend this message?
when (resendWhenWorkerKilled mdata) $ do
-- putStrLn $ formatStr state $ "resending job: " <> show job
-- NOTE: Delete first, then create job. It's a bit
-- safer (same job won't be picked up twice, though we
-- should be safe anyways, with the timeout)
void $ deleteMessage broker queueName $ messageId brokerMessage
void $ sendJob broker queueName (job { metadata = mdata { readCount = readCount mdata + 1 } })
-- size <- getQueueSize broker queueName
-- putStrLn $ formatStr state $ "queue size: " <> show size
-- In any case, deinit the broker (i.e. close connection)
-- deinitBroker broker
Nothing -> pure ()
-- callback
callWorkerMJobEvent onWorkerKilledSafely state mBrokerMessage
-- kill worker
throwIO KillWorkerSafely
, Handler $ \(err :: JobTimeout b a) -> handleTimeoutError state err
, Handler $ \err -> do
mBrokerMessage <- readTVarIO mBrokerMessageTVar
case mBrokerMessage of
Just brokerMessage -> do
callWorkerJobErrorEvent onJobError state brokerMessage err
handleJobError state brokerMessage
Nothing -> handleUnknownError state err
]
handleMessage :: (HasWorkerBroker b a) => State b a -> BrokerMessage b (Job a) -> IO ()
handleMessage state@(State { .. }) brokerMessage = do
callWorkerJobEvent onMessageReceived state brokerMessage
let msgId = messageId brokerMessage
let msg = getMessage brokerMessage
let job' = toA msg
-- putStrLn $ formatStr state $ "received job: " <> show (job job')
let mdata = metadata job'
let timeoutS = jobTimeout job'
-- Inform the broker how long a task could take. This way we prevent
-- the broker from sending this task to another worker (e.g. 'vt' in
-- PGMQ).
setMessageTimeout broker queueName msgId (TimeoutS timeoutS)
-- mTimeout <- Timeout.timeout timeoutS (wrapPerformActionInJobException state brokerMessage)
mTimeout <- Timeout.timeout ((fromIntegral timeoutS) * microsecond) (runAction state brokerMessage)
let archiveHandler = do
case archiveStrategy mdata of
ASDelete -> do
-- putStrLn $ formatStr state $ "deleting completed job " <> show msgId <> " (strategy: " <> show archiveStrategy <> ")"
deleteMessage broker queueName msgId
ASArchive -> do
-- putStrLn $ formatStr state $ "archiving completed job " <> show msgId <> " (strategy: " <> show archiveStrategy <> ")"
archiveMessage broker queueName msgId
case mTimeout of
Just _ -> archiveHandler
Nothing -> do
callWorkerJobEvent onJobTimeout state brokerMessage
throwIO $ JobTimeout { jtBMessage = brokerMessage
, jtTimeout = timeoutS }
-- onMessageFetched broker queue msg
-- -- | It's important to know if an exception occured inside a job. This
-- -- way we can apply error recovering strategy and adjust this job in
-- -- the broker
-- wrapPerformActionInJobException :: (HasWorkerBroker b a) => State b a -> BrokerMessage b (Job a) -> IO ()
-- wrapPerformActionInJobException state@(State { onJobError }) brokerMessage = do
-- catch (do
-- runAction state brokerMessage
-- )
-- (\(err :: SomeException) -> do
-- callWorkerJobEvent onJobError state brokerMessage
-- throwIO err
-- )
callWorkerJobEvent :: WorkerJobEvent b a
-> State b a
-> BrokerMessage b (Job a)
-> IO ()
callWorkerJobEvent Nothing _ _ = pure ()
callWorkerJobEvent (Just event) state brokerMessage = event state brokerMessage
callWorkerJobErrorEvent :: WorkerJobErrorEvent b a
-> State b a
-> BrokerMessage b (Job a)
-> SomeException
-> IO ()
callWorkerJobErrorEvent Nothing _ _ _ = pure ()
callWorkerJobErrorEvent (Just event) state brokerMessage err = event state brokerMessage err
callWorkerMJobEvent :: WorkerMJobEvent b a
-> State b a
-> Maybe (BrokerMessage b (Job a))
-> IO ()
callWorkerMJobEvent Nothing _ _ = pure ()
callWorkerMJobEvent (Just event) state mBrokerMessage = event state mBrokerMessage
handleTimeoutError :: (HasWorkerBroker b a) => State b a -> JobTimeout b a -> IO ()
handleTimeoutError _state@(State { .. }) _jt@(JobTimeout { .. }) = do
-- putStrLn $ formatStr state $ show jt
let msgId = messageId jtBMessage
let job = toA $ getMessage jtBMessage
-- putStrLn $ formatStr state $ "timeout for job: " <> show job
let mdata = metadata job
case timeoutStrategy mdata of
TSDelete -> deleteMessage broker queueName msgId
TSArchive -> archiveMessage broker queueName msgId
TSRepeat -> do
void $ deleteMessage broker queueName msgId
void $ sendJob broker queueName (job { metadata = mdata { readCount = readCount mdata + 1 } })
TSRepeatNElseArchive n -> do
let readCt = readCount mdata
-- OK so this can be repeated at most 'n' times, compare 'readCt' with 'n'
if readCt >= n then
archiveMessage broker queueName msgId
else do
-- NOTE In rare cases, when worker hangs, we might lose a job
-- here? (i.e. delete, then resend)
-- Also, be aware that messsage id will change with resend
-- Delete this job first, otherwise we'll be duplicating jobs.
deleteMessage broker queueName msgId
-- Send this job again, with increased 'readCount'
void $ sendJob broker queueName (job { metadata = mdata { readCount = readCt + 1 } })
TSRepeatNElseDelete n -> do
let readCt = readCount mdata
-- OK so this can be repeated at most 'n' times, compare 'readCt' with 'n'
if readCt >= n then
deleteMessage broker queueName msgId
else do
-- NOTE In rare cases, when worker hangs, we might lose a job
-- here? (i.e. delete, then resend)
-- Also, be aware that messsage id will change with resend
-- Delete this job first, otherwise we'll be duplicating jobs.
deleteMessage broker queueName msgId
-- Send this job again, with increased 'readCount'
void $ sendJob broker queueName (job { metadata = mdata { readCount = readCt + 1 } })
handleJobError :: (HasWorkerBroker b a) => State b a -> BrokerMessage b (Job a) -> IO ()
handleJobError _state@(State { .. }) brokerMessage = do
let msgId = messageId brokerMessage
let job = toA $ getMessage brokerMessage
-- putStrLn $ formatStr state $ "error: " <> show je <> " for job " <> show job
let mdata = metadata job
case errorStrategy mdata of
ESDelete -> deleteMessage broker queueName msgId
ESArchive -> archiveMessage broker queueName msgId
ESRepeatNElseArchive n -> do
let readCt = readCount mdata
if readCt >= n then
archiveMessage broker queueName msgId
else do
-- Delete this job first, otherwise we'll be duplicating jobs.
deleteMessage broker queueName msgId
void $ sendJob broker queueName (job { metadata = mdata { readCount = readCt + 1 } })
handleUnknownError :: State b a -> SomeException -> IO ()
handleUnknownError state err = putStrLn $ formatStr state $ "unknown error: " <> show err
sendJob :: (HasWorkerBroker b a) => Broker b (Job a) -> Queue -> Job a -> IO (MessageId b)
sendJob broker queueName job =
sendJobDelayed broker queueName job 0
sendJobDelayed :: (HasWorkerBroker b a) => Broker b (Job a) -> Queue -> Job a -> TimeoutS -> IO (MessageId b)
sendJobDelayed broker queueName job timeoutS = do
sendMessageDelayed broker queueName (toMessage job) timeoutS
microsecond :: Integer
microsecond = 10^(6 :: Integer)
{- $sendJob
A worker job has quite a few metadata. Here are some utilities for
constructing them more easily.
-}
-- | Wraps parameters for the 'sendJob' function
data SendJob b a =
SendJob { broker :: Broker b (Job a)
, queue :: Queue
, msg :: a
, delay :: TimeoutS -- initial delay for the message
, archStrat :: ArchiveStrategy
, errStrat :: ErrorStrategy
, toStrat :: TimeoutStrategy
, timeout :: Timeout
, resendOnKill :: Bool}
-- | Create a 'SendJob' data with some defaults
mkDefaultSendJob :: Broker b (Job a)
-> Queue
-> a
-> Timeout
-> SendJob b a
mkDefaultSendJob broker queue msg timeout =
SendJob { broker
, queue
, msg
, delay = 0
-- | remove finished jobs
, archStrat = ASDelete
-- | archive errored jobs (for inspection later)
, errStrat = ESArchive
-- | repeat timed out jobs
, toStrat = TSRepeat
, timeout
, resendOnKill = True }
-- | Like 'mkDefaultSendJob' but with default timeout
mkDefaultSendJob' :: Broker b (Job a)
-> Queue
-> a
-> SendJob b a
mkDefaultSendJob' b q m = mkDefaultSendJob b q m defaultTimeout
where
defaultTimeout = 10
-- | Call 'sendJob' with 'SendJob b a' data
sendJob' :: (HasWorkerBroker b a) => SendJob b a -> IO (MessageId b)
sendJob' (SendJob { .. }) = do
let metadata = defaultMetadata { archiveStrategy = archStrat
, errorStrategy = errStrat
, timeoutStrategy = toStrat
, timeout = timeout
, resendWhenWorkerKilled = resendOnKill }
let job = Job { job = msg, metadata }
sendJobDelayed broker queue job delay