glue-core-0.8.0.0: src/Glue/Failover.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Module supporting failover: either re-invoking one service with a transformed request each
-- time a failure occurs ('failover'), or trying an ordered list of distinct backends, a primary
-- then its backups, until one answers ('failoverAcross').
module Glue.Failover
( FailoverOptions (..)
, defaultFailoverOptions
, failover
, FailoverAcrossOptions (..)
, ReportedFailure (..)
, defaultFailoverAcrossOptions
, failoverAcross
, failoverAcrossIndexed
, failoverAcrossEither
, failoverAcrossIndexedEither
) where
import Data.List.NonEmpty (NonEmpty (..))
import Data.Maybe (fromMaybe)
import Glue.Failure
import Glue.Types
import UnliftIO (MonadUnliftIO, SomeException)
import UnliftIO.Exception (catchAny)
-- | Options for determining behaviour of failover services.
data FailoverOptions a = FailoverOptions
{ maxFailovers :: Int
-- ^ The maximum number of times the service will failover and make another attempt.
, transformFailoverRequest :: a -> a
-- ^ Each time the service performs a failover, transform the request with this function.
}
-- | Simple defaults that result in retrying 3 times immediately without transforming the request.
defaultFailoverOptions :: FailoverOptions a
defaultFailoverOptions =
FailoverOptions
{ maxFailovers = 3
, transformFailoverRequest = id
}
-- | Creates a service that can transform requests with each subsequent failure.
failover
:: MonadUnliftIO m
=> FailoverOptions a
-- ^ Instance of t'FailoverOptions' to configure the failover functionality.
-> BasicService m a b
-- ^ The service to perform failover around.
-> BasicService m a b
failover options service =
let invokeService failCount request =
let afterFail _ = invokeService (failCount + 1) (transformFailoverRequest options request)
invoke = service request
in if failCount >= maxFailovers options
then invoke
else catchAny invoke afterFail
in invokeService 0
-- | Which failure is reported when every backend has failed.
data ReportedFailure
= ReportFirstFailure
-- ^ The primary's failure.
| ReportLastFailure
-- ^ The last backup's failure.
deriving (Eq, Show)
-- | Options for failing over across distinct backends.
data FailoverAcrossOptions failure = FailoverAcrossOptions
{ advanceOn :: failure -> Bool
-- ^ Whether a failure moves on to the next backend. A failure that doesn't (an invalid request,
-- say, which a backup would only repeat) stops the chain and is reported at once.
, reportedFailure :: ReportedFailure
-- ^ Which failure is reported when every backend has failed.
}
-- | Defaults that advance on every failure and report the last one.
defaultFailoverAcrossOptions :: FailoverAcrossOptions failure
defaultFailoverAcrossOptions =
FailoverAcrossOptions
{ advanceOn = const True
, reportedFailure = ReportLastFailure
}
failoverAcrossVia
:: Monad m
=> FailureMode m r failure
-> FailoverAcrossOptions failure
-> NonEmpty (BasicService m a r)
-> BasicService m a (Int, r)
failoverAcrossVia mode options (primary :| backups) request = go 0 Nothing primary backups
where
report index failure = (,) index <$> reraise mode failure
go index firstFailure service rest = do
outcome <- observeCall mode (service request)
case outcome of
Right result -> pure (index, result)
Left failure
| not (advanceOn options failure) -> report index failure
| otherwise -> case rest of
next : rest' -> go (index + 1) (Just (fromMaybe (index, failure) firstFailure)) next rest'
[] -> case (reportedFailure options, firstFailure) of
(ReportFirstFailure, Just (firstIndex, first)) -> report firstIndex first
_ -> report index failure
-- | Creates a service that tries each backend in order and returns the first success.
-- Asynchronous exceptions always propagate and never advance to the next backend.
failoverAcross
:: MonadUnliftIO m
=> FailoverAcrossOptions SomeException
-- ^ Options deciding which failures advance and which failure is reported.
-> NonEmpty (BasicService m a b)
-- ^ The backends, primary first.
-> BasicService m a b
failoverAcross options services = fmap snd . failoverAcrossIndexed options services
-- | Variant of 'failoverAcross' that also reports the index of the backend that answered,
-- for tracing and billing.
failoverAcrossIndexed
:: MonadUnliftIO m
=> FailoverAcrossOptions SomeException
-- ^ Options deciding which failures advance and which failure is reported.
-> NonEmpty (BasicService m a b)
-- ^ The backends, primary first.
-> BasicService m a (Int, b)
failoverAcrossIndexed = failoverAcrossVia exceptionFailures
-- | Variant of 'failoverAcross' for services returning failures as values: @Left@ results are
-- judged by 'advanceOn'. Exceptions are not caught and do not advance.
failoverAcrossEither
:: Monad m
=> FailoverAcrossOptions e
-- ^ Options deciding which failures advance and which failure is reported.
-> NonEmpty (EitherService m a e b)
-- ^ The backends, primary first.
-> EitherService m a e b
failoverAcrossEither options services = fmap (fmap snd) . failoverAcrossIndexedEither options services
-- | Variant of 'failoverAcrossEither' that also reports the index of the backend that answered.
failoverAcrossIndexedEither
:: Monad m
=> FailoverAcrossOptions e
-- ^ Options deciding which failures advance and which failure is reported.
-> NonEmpty (EitherService m a e b)
-- ^ The backends, primary first.
-> EitherService m a e (Int, b)
failoverAcrossIndexedEither options services =
fmap (\(index, result) -> (,) index <$> result) . failoverAcrossVia valueFailures options services