packages feed

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

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

-- | Module supporting request hedging: dispatching further attempts if earlier ones haven't
-- answered within a delay, and completing with the first successful result.
--
-- 'hedgeAcross' races distinct backends, a primary then its backups, leaving the caller in control
-- of whether each backup may launch and informed of how every attempt ended. 'hedgingService'
-- duplicates the same call to one replicated backend. Both are built on "Glue.Race", which callers
-- with their own launch policy can drive directly.
module Glue.Hedging
  ( HedgeOptions (..)
  , defaultHedgeOptions
  , hedgeAcross
  , hedgeAcrossEither
  , HedgingOptions (..)
  , defaultHedgingOptions
  , hedgingService
  , AttemptOutcome (..)
  ) where

import           Data.Foldable      (traverse_)
import           Data.List.NonEmpty (NonEmpty (..))
import           Data.Maybe         (fromMaybe)
import           Glue.Clock
import           Glue.Failure
import           Glue.Race
import           Glue.Types
import           UnliftIO           (MonadUnliftIO, SomeException)
import           UnliftIO.Exception (throwIO)

-- | Options for hedging across distinct backends.
data HedgeOptions m failure r = HedgeOptions
  { hedgeDelayMs   :: Int
  -- ^ How long (in milliseconds) to wait for an attempt to end before launching the next backend.
  -- A failed attempt launches the next backend straight away.
  , admitLaunch    :: Int -> m Bool
  -- ^ Asked before launching the backend with this index (from 1; the primary always launches).
  -- Declining skips that backend without failing the attempts already running; the next backend
  -- is considered after a further delay.
  , onAttemptEnded :: Int -> AttemptOutcome failure r -> m ()
  -- ^ Told how each launched attempt ended, including losers that were cancelled, once each has
  -- actually finished.
  , onHedgeWon     :: Int -> m ()
  -- ^ Told which attempt's answer was returned.
  , hedgeClock     :: Clock m
  -- ^ Clock used for the delays.
  }

-- | Defaults launching each backend 100ms after the previous one with no further control.
defaultHedgeOptions :: MonadUnliftIO m => HedgeOptions m failure r
defaultHedgeOptions =
  HedgeOptions
    { hedgeDelayMs   = 100
    , admitLaunch    = const (pure True)
    , onAttemptEnded = \_ _ -> pure ()
    , onHedgeWon     = const (pure ())
    , hedgeClock     = systemClock
    }

-- | Thrown if a hedge ends with no outcome to report, which the race guarantees cannot happen.
noOutcome :: MonadUnliftIO m => m a
noOutcome = throwIO (userError "Glue.Hedging: every attempt ended without an outcome")

hedgeVia
  :: forall m a r failure.
     MonadUnliftIO m
  => FailureMode m r failure
  -> HedgeOptions m failure r
  -> NonEmpty (BasicService m a r)
  -> BasicService m a r
hedgeVia mode options (primary :| backups) request =
  withRace mode (hedgeClock options) $ \race -> do
    let finishLosers = abandonAll race >>= traverse_ (uncurry (onAttemptEnded options))

        launchNext :: [(Int, BasicService m a r)] -> m ([(Int, BasicService m a r)], Bool)
        launchNext []                      = pure ([], False)
        launchNext ((index, service) : rest) = do
          admitted <- admitLaunch options index
          if admitted
            then launch race (service request) >> pure (rest, True)
            else pure (rest, False)

        launchUntilRunning pending = do
          (pending', launched) <- launchNext pending
          if launched || null pending' then pure pending' else launchUntilRunning pending'

        loop :: [(Int, BasicService m a r)] -> Maybe failure -> m r
        loop pending firstFailure = do
          event <- awaitNext race (if null pending then Nothing else Just (hedgeDelayMs options))
          case event of
            AttemptEnded index outcome@(AttemptAnswered result) -> do
              onAttemptEnded options index outcome
              finishLosers
              onHedgeWon options index
              pure result
            AttemptEnded index outcome@(AttemptFailed failure) -> do
              onAttemptEnded options index outcome
              (pending', _) <- launchNext pending
              loop pending' (Just (fromMaybe failure firstFailure))
            AttemptEnded index outcome@(AttemptCrashed e) -> do
              onAttemptEnded options index outcome
              finishLosers
              throwIO e
            AttemptEnded index outcome@AttemptCancelled -> do
              onAttemptEnded options index outcome
              loop pending firstFailure
            RaceTimedOut -> do
              (pending', _) <- launchNext pending
              loop pending' firstFailure
            NothingRunning
              | null pending -> maybe noOutcome (reraise mode) firstFailure
              | otherwise    -> launchUntilRunning pending >>= \pending' -> loop pending' firstFailure

    _ <- launch race (primary request)
    loop (zip [1 ..] backups) Nothing

-- | Races distinct backends: the primary is launched at once and each backup after a further
-- 'hedgeDelayMs' if no attempt has ended (or straight away when an attempt fails). The first success is returned and the other attempts are cancelled, waiting until they
-- have finished. Attempts ending together are decided by their order in the list. If every attempt
-- fails, the first failure is rethrown.
hedgeAcross
  :: MonadUnliftIO m
  => HedgeOptions m SomeException b
  -- ^ Options configuring the delay and the caller's control over launches and outcomes.
  -> NonEmpty (BasicService m a b)
  -- ^ The backends, primary first.
  -> BasicService m a b
hedgeAcross = hedgeVia exceptionFailures

-- | Variant of 'hedgeAcross' for services returning failures as values: a @Left@ result is a
-- failed attempt, and the first @Left@ is returned if every attempt fails.
hedgeAcrossEither
  :: MonadUnliftIO m
  => HedgeOptions m e (Either e b)
  -- ^ Options configuring the delay and the caller's control over launches and outcomes.
  -> NonEmpty (EitherService m a e b)
  -- ^ The backends, primary first.
  -> EitherService m a e b
hedgeAcrossEither = hedgeVia valueFailures

-- | Options for configuring hedged (duplicated) service calls.
data HedgingOptions m = HedgingOptions
  { hedgingCount   :: Int
  -- ^ Total number of concurrent attempts to make (including the primary call).
  , hedgingDelayMs :: Int
  -- ^ Stagger delay in milliseconds before each subsequent hedged call (`0` to fire all simultaneously).
  , hedgingClock   :: Clock m
  -- ^ Clock used for the stagger delay.
  }

-- | Default hedging options that fire 2 concurrent requests with no stagger delay.
defaultHedgingOptions :: MonadUnliftIO m => HedgingOptions m
defaultHedgingOptions =
  HedgingOptions
    { hedgingCount   = 2
    , hedgingDelayMs = 0
    , hedgingClock   = systemClock
    }

-- | Creates a service that duplicates requests up to 'hedgingCount' times and returns
-- the first successful response, automatically cancelling remaining in-flight attempts.
-- If all attempts fail, the first encountered exception is rethrown.
hedgingService
  :: MonadUnliftIO m
  => HedgingOptions m
  -- ^ Options configuring the number of hedged calls and stagger delay.
  -> BasicService m a b
  -- ^ Underlying service to invoke.
  -> BasicService m a b
hedgingService options service
  | hedgingCount options <= 1 = service
  | otherwise =
      hedgeAcross
        defaultHedgeOptions { hedgeDelayMs = max 0 (hedgingDelayMs options), hedgeClock = hedgingClock options }
        (service :| replicate (hedgingCount options - 1) service)