redis-schema-0.2.0: src/Database/Redis/Schema/RemoteJob.hs
{-# LANGUAGE AllowAmbiguousTypes #-} -- for remoteJobWorker
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
module Database.Redis.Schema.RemoteJob (
-- * Types
WorkerId (..),
WorkerHandle,
RemoteJobError (..),
JobQueue (..),
-- * Main functionality
runRemoteJob,
runRemoteJobAsync,
remoteJobWorker,
withRemoteJobWorker,
gracefulShutdown,
-- * Inspection
countWorkers,
queueLength,
countRunningJobs,
) where
import Data.Binary ( decode, encode, Binary(..) )
import Data.Bifunctor as BF ( second )
import Data.List.NonEmpty ( NonEmpty (..) )
import Data.Kind ( Type )
import Data.Maybe ( isJust )
import Data.Proxy ( Proxy(..) )
import Data.String ( IsString )
import Data.Text ( Text )
import Data.Time ( UTCTime, getCurrentTime, addUTCTime )
import Data.Time.Clock.POSIX ( utcTimeToPOSIXSeconds )
import Data.UUID ( UUID )
import Data.UUID.V4 ( nextRandom )
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as BSL
import qualified Data.Set as Set
import Database.Redis ( ConnectionLostException )
import Database.Redis.Schema as Redis
import Control.Concurrent.MonadIO
import Control.Monad ( when, forever )
import Control.Monad.Catch
import Control.Exception ( SomeAsyncException )
import GHC.Generics ( Generic )
-- | Errors that can occur in the remote job running.
data RemoteJobError
= RemoteJobException String
| NoActiveWorkers
| Timeout
deriving ( Show, Generic )
instance Binary RemoteJobError
instance Serializable RemoteJobError where
fromBS = readBinary
toBS = showBinary
-- | Identifier for the worker process, is only used for inspecting the queue
newtype WorkerId = WorkerId { unWorkerId :: Text }
deriving newtype ( Show, Eq, Ord, IsString, Serializable, Binary )
-- | Handle of the worker process, which can be used in the graceful shutdown procedure
data WorkerHandle = WorkerHandle (MVar ()) ThreadId
class JobQueue jq where
-- | The remote job protocol, a list of 'i -> o' entries indicating
-- this queue can contains jobs taking type 'i' as input and
-- returning type 'o'. Both 'i' and 'o' must have a binary instance.
type RPC jq :: [Type]
-- | Prefix for the Redis keys
keyPrefix :: BS.ByteString
-- | Which Redis instance the queue lives in.
type RedisInstance jq :: Instance
type RedisInstance jq = DefaultInstance
-- | Type for representing a job request in Redis
data Job = Job
{ jobId :: UUID
, jobHandlerIdx :: Int
, jobInput :: BSL.ByteString
} deriving ( Eq, Ord )
instance Serializable Job where
toBS j = toBS (jobId j, jobHandlerIdx j, jobInput j)
fromBS = fmap (\(jid,jidx,jinp) -> Job jid jidx jinp) . fromBS
-- | This queue contains many requests.
-- There is only one request queue and it's read by all workers.
data RequestQueue jq = RequestQueue
instance JobQueue jq => Ref (RequestQueue jq) where
type RefInstance (RequestQueue jq) = RedisInstance jq
type ValueType (RequestQueue jq) = [(Priority, Job)]
toIdentifier RequestQueue =
colonSep [keyPrefix @jq, "requests"]
-- | This set contains the requests that are currently being processed.
data RunningJobs jq = RunningJobs
instance JobQueue jq => Ref (RunningJobs jq) where
type RefInstance (RunningJobs jq) = RedisInstance jq
type ValueType (RunningJobs jq) = Set.Set (WorkerId, Job)
toIdentifier RunningJobs =
colonSep [keyPrefix @jq, "running"]
-- | A box that contains only one response.
-- For every response, a unique box is created (tagged with job ID).
newtype ResultBox jq = ResultBox UUID
instance JobQueue jq => Ref (ResultBox jq) where
type RefInstance (ResultBox jq) = RedisInstance jq
type ValueType (ResultBox jq) = [Either RemoteJobError BSL.ByteString]
toIdentifier (ResultBox uuid) =
colonSep [keyPrefix @jq, "result", toBS uuid]
-- | A registry of all active workers
data Workers jq = Workers
instance JobQueue jq => Ref (Workers jq) where
type RefInstance (Workers jq) = RedisInstance jq
type ValueType (Workers jq) = [(Priority, WorkerId)]
toIdentifier Workers =
Redis.colonSep [keyPrefix @jq, "workers"]
-- | Type class to check where in the 'RPC' list a i->o job occurs, which
-- is then used together with 'CanHandle' to use the right handler.
class HasHandler (i :: Type) (o :: Type) (xs :: [Type]) where
handlerIdx :: Proxy (i -> o) -> Proxy xs -> Int
instance (HasHandler' i o xs (IsHead (i -> o) xs)) => HasHandler i o xs where
handlerIdx = handlerIdx' (Proxy @(IsHead (i -> o) xs))
class HasHandler' (i :: Type) (o :: Type) (xs :: [Type]) (isHead :: Bool) where
handlerIdx' :: Proxy isHead -> Proxy (i -> o) -> Proxy xs -> Int
instance HasHandler' i o ((i -> o) ': xs) 'True where
handlerIdx' _ _ _ = 0
instance HasHandler i o xs => HasHandler' i o (x ': xs) 'False where
handlerIdx' _ _ _ = 1 + handlerIdx (Proxy @(i -> o)) (Proxy @xs)
type family IsHead (x :: Type) (xs :: [Type]) :: Bool where
IsHead x (x ': _) = 'True
IsHead x _ = 'False
-- | An instance 'CanHandle m xs' means that the list xs of i->o jobs
-- can be handled in monad m, e.g. there exists Binary instances for all
-- i and o, and the instances take care of encoding and decoding as the
-- right type.
class CanHandle (m :: Type -> Type) (xs :: [Type]) where
type HandleTy m xs r :: Type
doHandle :: Proxy m -> Proxy xs -> ((Int -> BSL.ByteString -> m BSL.ByteString) -> m r) -> HandleTy m xs r
instance CanHandle m '[] where
type HandleTy m '[] r = m r
doHandle Proxy Proxy cont = cont $ \_ _ -> error "remoteJobWorker: protocol broken"
instance (Monad m, Binary i, Binary o, CanHandle m xs) => CanHandle m ((i -> o) ': xs) where
type HandleTy m ((i -> o) ': xs) r = (i -> m o) -> HandleTy m xs r
doHandle Proxy Proxy cont f = doHandle (Proxy @m) (Proxy @xs) (cont . g) where
g handler i bsi
| i == 0 = encode <$> f (decode bsi)
| otherwise = handler (i - 1) bsi
-- | Run a job on a remote worker. This will block until a 'remoteJobWorker' process picks up the
-- task. The 'Double' argument is the priority, jobs with a lower priority are picked up earlier.
runRemoteJob ::
forall q i o m.
(MonadCatch m, MonadIO m, JobQueue q, HasHandler i o (RPC q), Binary i, Binary o) =>
Bool -> Pool (RedisInstance q) -> Priority -> i -> m (Either RemoteJobError o)
runRemoteJob waitForWorkers pool prio a = do
-- Check that there are active workers
abort <-
if waitForWorkers
then return False
else (==0) <$> run pool (countWorkers @q)
if abort then return $ Left NoActiveWorkers
else do
-- Add the job
jid <- liftIO nextRandom
let job = Job
{ jobId = jid
, jobHandlerIdx = handlerIdx (Proxy @(i -> o)) (Proxy @(RPC q))
, jobInput = encode a
}
-- Add to the queue and wait for the result. If any exception occurs at this point
-- (which is then likely an async exception), we remove the element from the queue,
-- because we will not listen to the result anymore anyway.
popResult <- run pool
( do zInsert (RequestQueue @q) [(prio,job)]
lPopRightBlocking 0 (ResultBox @q jid)
) `onException`
run pool (zDelete (RequestQueue @q) job)
-- Now look at the result and decode it.
return $ case popResult of
Just r -> BF.second decode r
Nothing -> Left Timeout
-- | Run a job on a remote worker but do not wait for any results. This assumes the remote
-- job has some side-effect, which is executed by a 'remoteJobWorker' process that picks
-- up this task.
runRemoteJobAsync ::
forall q i m.
(MonadCatch m, MonadIO m, JobQueue q, HasHandler i () (RPC q), Binary i) =>
Pool (RedisInstance q) -> Priority -> i -> m ()
runRemoteJobAsync pool prio a = do
-- Add to the queue and forget about it.
jid <- liftIO nextRandom
let job = Job
{ jobId = jid
, jobHandlerIdx = handlerIdx (Proxy @(i -> ())) (Proxy @(RPC q))
, jobInput = encode a
}
run pool $ zInsert (RequestQueue @q) [(prio,job)]
-- | The actual worker loop, this generalizes over 'remoteJobWorker' and 'forkRemoteJobWorker'
remoteJobWorker' :: forall q m r. (MonadIO m, MonadCatch m, MonadMask m, JobQueue q, CanHandle m (RPC q)) =>
(MVar () -> m () -> m r) -> WorkerId -> Pool (RedisInstance q) -> (SomeException -> m ()) -> HandleTy m (RPC q) r
remoteJobWorker' cont wid pool logger = doHandle (Proxy @m) (Proxy @(RPC q)) $ \handler -> do
-- MVar that is used for the graceful shutdown procedure. When it is full, the worker
-- thread is not doing anything and can be killed. As soon as the worker starts working
-- it takes the value and puts it back when the work is done.
workerFree <- liftIO $ newMVar ()
let
-- Main loop, pop elements from the queue and handle them
loop :: m ()
loop = run pool (bzPopMin (RequestQueue @q) 0) >>= \case
Just (_, job) -> do
-- Update the RunningJobs queue at the start and end of this block,
-- and keep the workerFree var up to date
bracket_
(run pool (sInsert (RunningJobs @q) ((wid,job) :| [])) >> liftIO (takeMVar workerFree))
(run pool (sDelete (RunningJobs @q) ((wid,job) :| [])) >> liftIO (putMVar workerFree ())) $ do
-- Call the actual handler
resp <- fmap Right (handler (jobHandlerIdx job) (jobInput job))
`catchAll`
(return . Left)
-- Send back the result
let bso = case resp of
Left e -> Left $ RemoteJobException $ show e
Right b -> Right b
run pool $ do
let box = ResultBox @q (jobId job)
lPushLeft box (bso :| [])
-- set ttl to ensure the data is not left behind in case of crashes,
-- the caller should be awaiting this already, so it's either read
-- directly or it is never read.
setTTLIfExists_ box (5 * Redis.second)
-- Check for exceptions
case resp of
Right _ -> return ()
Left e -> do
-- Call the parent logger
logger e
-- And in case of an async exception, rethrow
let mbAsync :: Maybe SomeAsyncException
mbAsync = fromException e
when (isJust mbAsync) $ throwM e
-- Sleep for a tiny bit, to allow the graceful shutdown procedure to interrupt when needed
liftIO $ threadDelay 1000 -- 1ms
loop
-- With BRPOP and no timeout there should always be a result
_ -> error "remoteJobWorker: Got no result with BRPOP and timeout 0"
-- Fork a keep-alive loop that updates our latest-seen time every
-- 5 seconds. We use the current UTC timestamp as priority, so
-- that we can efficiently count the servers that checked in recently.
signup = liftIO $ forkIO $ forever $ do
t <- liftIO getCurrentTime
run pool $ zInsert (Workers @q) [(utcTimeToPriority t, wid)]
threadDelay 5_000_000 -- 5s
-- Kill the keep-alive loop and remove ourselves from the list.
signout tid = do
liftIO $ killThread tid
run pool $ zDelete (Workers @q) wid
-- Signup and signout in an exception-safe way.
-- When the connection is lost (which will also throw exceptions
-- in signup/signout), we sleep for a while and try again
let outerLoop = bracket signup signout (const loop)
`catch`
\(e :: ConnectionLostException) -> do
-- Make sure to log the exception
logger (toException e)
-- Sleep for 10s
liftIO $ threadDelay 10_000_000
-- And run the loop again
outerLoop
-- Pass the continuation the function to start up the outer loop
cont workerFree outerLoop
-- | Worker for handling jobs from the queue. The first function that matches the given input/output types
-- will be executed. Multiple workers can be run in parallel.
-- When exceptions appear in the handling function, this exception will be sent to the caller
-- as a String and this exception is thrown from the worker process.
remoteJobWorker :: forall q m. (MonadIO m, MonadCatch m, MonadMask m, JobQueue q, CanHandle m (RPC q)) =>
WorkerId -> Pool (RedisInstance q) -> (SomeException -> m ()) -> HandleTy m (RPC q) ()
remoteJobWorker = remoteJobWorker' @q cont where
cont :: MVar () -> m () -> m ()
cont _ runLoop = runLoop
-- | Forking version of 'remoteJobWorker', which forks a thread to do the work and returns a 'WorkerHandle'
-- that can be passed to 'gracefulShutdown' to end the worker thread in a graceful way.
-- This function ensures that on exceptions, the worker is cleaned up properly.
withRemoteJobWorker :: forall q m a. (HasFork m, MonadIO m, MonadCatch m, MonadMask m, JobQueue q, CanHandle m (RPC q)) =>
WorkerId -> Pool (RedisInstance q) -> (SomeException -> m ()) -> (WorkerHandle -> m a) -> HandleTy m (RPC q) a
withRemoteJobWorker wid pool logger outerCont = remoteJobWorker' @q cont wid pool logger where
cont :: MVar () -> m () -> m a
cont workerFree runLoop = do
tid <- fork runLoop
let hd = WorkerHandle workerFree tid
outerCont hd
`finally` -- on exceptions or when the outer thread finishes, make sure to clean up
hardShutdown hd
-- | Gracefully shut down the worker, which means waiting for the current job to complete
-- There is a tiny race condition, so there is a small chance the worker just took
-- a new job when it is killed.
gracefulShutdown :: MonadIO m => WorkerHandle -> m ()
gracefulShutdown (WorkerHandle workerFree tid) = liftIO $ do
takeMVar workerFree
killThread tid
-- | Send an async exception to the worker thread to kill it and clean up. The remote job
-- caller will receive a 'RemoteJobException' if a job is running.
hardShutdown :: MonadIO m => WorkerHandle -> m ()
hardShutdown (WorkerHandle _ tid) = liftIO $ killThread tid
-- | Returns the number of workers that are currently connected to the job queue.
countWorkers :: forall jq. JobQueue jq => RedisM (RedisInstance jq) Integer
countWorkers = do
-- Count workers that checked in at most 10s ago. Workers are supposed to
-- do this every 5 seconds, so we allow missing one beat.
t <- liftIO getCurrentTime
zCount (Workers @jq) (utcTimeToPriority $ addUTCTime (-10) t) maxBound
-- | Helper for worker list, which converts the current timestamp to a priority
utcTimeToPriority :: UTCTime -> Priority
utcTimeToPriority = Priority . realToFrac . utcTimeToPOSIXSeconds
-- | Returns the number of jobs that are currently queued
queueLength :: forall jq. JobQueue jq => RedisM (RedisInstance jq) Integer
queueLength = zSize (RequestQueue @jq)
-- | Returns the number of jobs that are currently being processed
countRunningJobs :: forall jq. JobQueue jq => RedisM (RedisInstance jq) Integer
countRunningJobs = sSize (RunningJobs @jq)