packages feed

glue-core-0.8.0.0: src/Glue/CircuitBreaker.hs

{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module containing circuit breaker functionality, which is the ability to open a circuit
-- once a number of failures have occurred, thereby preventing later calls from attempting
-- to make unsuccessful calls.
-- Often this is useful if the underlying service were to repeatedly time out, so as to
-- reduce the number of calls inflight holding up upstream callers.
--
-- The breaker is closed until 'breakerFailureThreshold' counted failures have occurred (consecutive
-- failures by default, or failures within 'breakerFailureWindowMs'), then open for
-- 'breakerOpenDurationMs', refusing calls. After that the next call is admitted as the single
-- half-open trial: if it answers the breaker closes, if it fails the breaker opens again.
--
-- Only synchronous failures accepted by 'countsAsFailure' count. A call cancelled by an
-- asynchronous exception (a timeout, a losing hedge, a killed request thread) counts for nothing,
-- and the exception is rethrown.
module Glue.CircuitBreaker
  ( CircuitBreakerOptions (..)
  , defaultCircuitBreakerOptions
  , BreakerPhase (..)
  , BreakerTransition (..)
  , Admission (..)
  , Settlement (..)
  , BreakerStore (..)
  , newInMemoryBreakerStore
  , CircuitBreakerState
  , CircuitBreakerException (..)
  , combineCircuitBreakerStates
  , circuitBreakerPhases
  , isCircuitBreakerOpen
  , isCircuitBreakerClosed
  , circuitBreaker
  , circuitBreakerEither
  , circuitBreakerWithStore
  , circuitBreakerEitherWithStore
  ) where

import           Data.Foldable      (traverse_)
import           Data.Int           (Int64)
import           Glue.Clock
import           Glue.Failure
import           Glue.Types
import           UnliftIO           (Exception, MonadIO, MonadUnliftIO,
                                     SomeException)
import           UnliftIO.Exception (mask, onException, throwIO)
import           UnliftIO.IORef     (atomicModifyIORef', newIORef, readIORef)

-- | Options for determining behaviour of circuit breaking services.
data CircuitBreakerOptions m failure = CircuitBreakerOptions
  { breakerFailureThreshold :: Int
  -- ^ The circuit opens on this many counted failures, so @3@ opens on the third (minimum 1).
  , breakerFailureWindowMs  :: Maybe Int
  -- ^ 'Nothing' counts consecutive failures, a call that answers resetting the count.
  -- @'Just' w@ counts the failures within the last @w@ milliseconds instead.
  , breakerOpenDurationMs   :: Int
  -- ^ How long (in milliseconds) the circuit stays open before a single trial call is admitted.
  , breakerDescription      :: String
  -- ^ Description that is attached to the failure so as to identify the particular circuit.
  , countsAsFailure         :: failure -> Bool
  -- ^ Whether a failure says something about the backend's health. Failures that don't count
  -- (a caller's own bad request, say) are treated as the backend having answered.
  , onBreakerTransition     :: BreakerTransition -> m ()
  -- ^ Called after each change of phase, for metrics and alerts.
  , breakerClock            :: Clock m
  -- ^ Clock used for the failure window and the open duration.
  }

-- | Defaulted options for the circuit breaker: opens on the 3rd consecutive failure, for 60 seconds,
-- counting every failure.
defaultCircuitBreakerOptions :: MonadUnliftIO m => CircuitBreakerOptions m failure
defaultCircuitBreakerOptions =
  CircuitBreakerOptions
    { breakerFailureThreshold = 3
    , breakerFailureWindowMs  = Nothing
    , breakerOpenDurationMs   = 60000
    , breakerDescription      = "Circuit breaker open."
    , countsAsFailure         = const True
    , onBreakerTransition     = const (pure ())
    , breakerClock            = systemClock
    }

-- | The phase a circuit breaker is in.
data BreakerPhase
  = BreakerClosed
  -- ^ Calls pass through.
  | BreakerOpen
  -- ^ Calls are refused.
  | BreakerHalfOpen
  -- ^ A single trial call is in flight; other calls are refused.
  deriving (Eq, Ord, Show, Enum, Bounded)

-- | A change of phase.
data BreakerTransition = BreakerTransition
  { transitionFrom :: BreakerPhase
  , transitionTo   :: BreakerPhase
  }
  deriving (Eq, Show)

-- | The store's decision about a call.
data Admission
  = AdmittedClosed
  -- ^ The circuit is closed and the call may proceed.
  | AdmittedTrial
  -- ^ The call is the single half-open trial.
  | Refused
  -- ^ The circuit is open (or a trial is in flight) and the call must not be made.
  deriving (Eq, Show)

-- | How an admitted call ended, as far as the breaker is concerned.
data Settlement
  = SettledAnswered
  -- ^ The backend answered: a success, or a failure that 'countsAsFailure' rejected.
  | SettledFailed
  -- ^ A counted failure.
  | SettledAbandoned
  -- ^ The call was cancelled (or threw outside the failure mode) and says nothing about health.
  deriving (Eq, Show)

-- | The state behind a circuit breaker, abstracted so that it can be shared between instances of a
-- horizontally scaled service (a database row updated with one conditional statement using the
-- database clock, say). Implementations must make 'admitCall' atomic so only one caller is ever
-- given 'AdmittedTrial' for a given half-open period.
data BreakerStore m = BreakerStore
  { admitCall  :: m (Admission, Maybe BreakerTransition)
  -- ^ Decide whether a call may proceed, reporting any phase change this caused.
  , settleCall :: Admission -> Settlement -> m (Maybe BreakerTransition)
  -- ^ Record how an admitted call ended, reporting any phase change this caused.
  , readPhase  :: m BreakerPhase
  -- ^ The current phase, for display.
  }

data MemoryState
  = MemoryClosed [Int64]
  -- ^ Times of the counted failures still in play, newest first.
  | MemoryOpen Int64
  -- ^ Open until the given time.
  | MemoryHalfOpen

-- | The in-memory t'BreakerStore' used by 'circuitBreaker', configured from the threshold, window,
-- open duration and clock in the options.
newInMemoryBreakerStore :: (MonadIO m, MonadIO n) => CircuitBreakerOptions m failure -> n (BreakerStore m)
newInMemoryBreakerStore options = do
  ref <- newIORef (MemoryClosed [])
  let clock       = breakerClock options
      threshold   = max 1 (breakerFailureThreshold options)
      openUs      = fromIntegral (max 0 (breakerOpenDurationMs options)) * 1000
      windowUs    = fmap (\w -> fromIntegral (max 0 w) * 1000) (breakerFailureWindowMs options)
      inWindow now = maybe id (\w -> takeWhile (> now - w)) windowUs
      transition from to = Just (BreakerTransition from to)
      admit now state = case state of
        MemoryClosed _ -> (state, (AdmittedClosed, Nothing))
        MemoryOpen until'
          | now >= until' -> (MemoryHalfOpen, (AdmittedTrial, transition BreakerOpen BreakerHalfOpen))
          | otherwise     -> (state, (Refused, Nothing))
        MemoryHalfOpen -> (state, (Refused, Nothing))
      settle now AdmittedClosed settlement (MemoryClosed failures) = case settlement of
        SettledAnswered
          | Just _ <- windowUs -> (MemoryClosed (inWindow now failures), Nothing)
          | otherwise          -> (MemoryClosed [], Nothing)
        SettledFailed ->
          let failures' = now : inWindow now failures
          in  if length failures' >= threshold
                then (MemoryOpen (now + openUs), transition BreakerClosed BreakerOpen)
                else (MemoryClosed failures', Nothing)
        SettledAbandoned -> (MemoryClosed failures, Nothing)
      settle now AdmittedTrial settlement MemoryHalfOpen = case settlement of
        SettledAnswered  -> (MemoryClosed [], transition BreakerHalfOpen BreakerClosed)
        SettledFailed    -> (MemoryOpen (now + openUs), transition BreakerHalfOpen BreakerOpen)
        -- The trial told us nothing, so the next call may try again straight away.
        SettledAbandoned -> (MemoryOpen now, transition BreakerHalfOpen BreakerOpen)
      settle _ _ _ state = (state, Nothing)
      phaseOf (MemoryClosed _) = BreakerClosed
      phaseOf (MemoryOpen _)   = BreakerOpen
      phaseOf MemoryHalfOpen   = BreakerHalfOpen
  pure
    BreakerStore
      { admitCall  = do
          now <- clockNow clock
          atomicModifyIORef' ref (admit now)
      , settleCall = \admission settlement -> do
          now <- clockNow clock
          atomicModifyIORef' ref (settle now admission settlement)
      , readPhase  = phaseOf <$> readIORef ref
      }

-- | Representation of the state the circuit breaker is currently in.
newtype CircuitBreakerState m = CircuitBreakerState [m BreakerPhase]

-- | Exception thrown when the circuit is open.
newtype CircuitBreakerException = CircuitBreakerException String
  deriving (Eq, Show)

instance Exception CircuitBreakerException

-- | Combines multiple states together.
combineCircuitBreakerStates :: Foldable t => t (CircuitBreakerState m) -> CircuitBreakerState m
combineCircuitBreakerStates = CircuitBreakerState . foldMap (\(CircuitBreakerState phases) -> phases)

-- | The phases of each breaker in the state.
circuitBreakerPhases :: Applicative m => CircuitBreakerState m -> m [BreakerPhase]
circuitBreakerPhases (CircuitBreakerState phases) = sequenceA phases

-- | Determines if a circuit breaker is open (or half-open) in any of its breakers.
isCircuitBreakerOpen :: Applicative m => CircuitBreakerState m -> m Bool
isCircuitBreakerOpen state = any (/= BreakerClosed) <$> circuitBreakerPhases state

-- | Determines if a circuit breaker is closed in all of its breakers.
isCircuitBreakerClosed :: Applicative m => CircuitBreakerState m -> m Bool
isCircuitBreakerClosed state = all (== BreakerClosed) <$> circuitBreakerPhases state

breakerVia
  :: MonadUnliftIO m
  => FailureMode m r failure
  -> m r
  -> CircuitBreakerOptions m failure
  -> BreakerStore m
  -> BasicService m a r
  -> BasicService m a r
breakerVia mode refuse options store service request =
  mask $ \restore -> do
    let notify = traverse_ (onBreakerTransition options)
    (admission, admitted) <- admitCall store
    case admission of
      Refused -> restore (notify admitted >> refuse)
      _ -> do
        outcome <-
          restore (notify admitted >> observeCall mode (service request))
            `onException` (settleCall store admission SettledAbandoned >>= notify)
        let settlement = either (\f -> if countsAsFailure options f then SettledFailed else SettledAnswered) (const SettledAnswered) outcome
        settled <- settleCall store admission settlement
        restore $ do
          notify settled
          either (reraise mode) pure outcome

-- | Circuit breaking services can be constructed with this function.
circuitBreaker
  :: (MonadUnliftIO m, MonadIO n)
  => CircuitBreakerOptions m SomeException
  -- ^ Options for specifying the circuit breaker behaviour.
  -> BasicService m a b
  -- ^ Service to protect with the circuit breaker.
  -> n (CircuitBreakerState m, BasicService m a b)
circuitBreaker options service = do
  store <- newInMemoryBreakerStore options
  pure (CircuitBreakerState [readPhase store], circuitBreakerWithStore options store service)

-- | Variant of 'circuitBreaker' using the given store, for state shared between instances.
-- The threshold, window and open duration in the options are the store's concern and unused here.
circuitBreakerWithStore
  :: MonadUnliftIO m
  => CircuitBreakerOptions m SomeException
  -- ^ Options for specifying the circuit breaker behaviour.
  -> BreakerStore m
  -- ^ Store holding the breaker's state.
  -> BasicService m a b
  -- ^ Service to protect with the circuit breaker.
  -> BasicService m a b
circuitBreakerWithStore options =
  breakerVia exceptionFailures (throwIO $ CircuitBreakerException $ breakerDescription options) options

-- | Variant of 'circuitBreaker' for services returning failures as values: @Left@ results are
-- classified by 'countsAsFailure', and a refused call returns the given failure instead of throwing.
circuitBreakerEither
  :: (MonadUnliftIO m, MonadIO n)
  => CircuitBreakerOptions m e
  -- ^ Options for specifying the circuit breaker behaviour ('breakerDescription' is unused).
  -> e
  -- ^ Failure returned when the circuit is open.
  -> EitherService m a e b
  -- ^ Service to protect with the circuit breaker.
  -> n (CircuitBreakerState m, EitherService m a e b)
circuitBreakerEither options onBreakerOpen service = do
  store <- newInMemoryBreakerStore options
  pure (CircuitBreakerState [readPhase store], circuitBreakerEitherWithStore options store onBreakerOpen service)

-- | Variant of 'circuitBreakerEither' using the given store.
circuitBreakerEitherWithStore
  :: MonadUnliftIO m
  => CircuitBreakerOptions m e
  -- ^ Options for specifying the circuit breaker behaviour.
  -> BreakerStore m
  -- ^ Store holding the breaker's state.
  -> e
  -- ^ Failure returned when the circuit is open.
  -> EitherService m a e b
  -- ^ Service to protect with the circuit breaker.
  -> EitherService m a e b
circuitBreakerEitherWithStore options store onBreakerOpen =
  breakerVia valueFailures (pure (Left onBreakerOpen)) options store