packages feed

glue-core-0.8.0.0: test/Glue/HedgingSpec.hs

{-# LANGUAGE ScopedTypeVariables #-}

module Glue.HedgingSpec (spec) where

import           Control.Concurrent       (yield)
import           Control.Concurrent.Async (async, wait)
import           Data.IORef
import           Data.List.NonEmpty       (NonEmpty (..))
import           Glue.Clock
import           Glue.Clock.Virtual
import           Glue.Failure
import           Glue.Hedging
import           Glue.Race
import           Test.Hspec
import           UnliftIO.Exception       (Exception, SomeException, finally,
                                           fromException, throwIO)
import           UnliftIO.STM             (atomically, newEmptyTMVarIO,
                                           putTMVar, readTMVar)

data HedgingTestException = HedgingTestException Int
  deriving (Eq, Show)

instance Exception HedgingTestException

-- | How an attempt ended, without the payload, for comparing reports.
data Ended = Answered Int | Failed Int | Cancelled | Crashed
  deriving (Eq, Show)

summarise :: AttemptOutcome SomeException Int -> Ended
summarise (AttemptAnswered n)  = Answered n
summarise (AttemptFailed e)    = maybe Crashed (\(HedgingTestException n) -> Failed n) (fromException e)
summarise AttemptCancelled     = Cancelled
summarise (AttemptCrashed _)   = Crashed

-- | Options on a virtual clock that record launches, attempt endings and the winner.
recording :: VirtualClock -> (Int -> IO Bool) -> IO (HedgeOptions IO SomeException Int, IORef [Int], IORef [(Int, Ended)], IORef [Int])
recording vc admit = do
  launches <- newIORef []
  endings  <- newIORef []
  winners  <- newIORef []
  let record ref x = atomicModifyIORef' ref (\xs -> (xs ++ [x], ()))
      options = (defaultHedgeOptions :: HedgeOptions IO SomeException Int)
        { hedgeDelayMs   = 100
        , admitLaunch    = \i -> record launches i >> admit i
        , onAttemptEnded = \i o -> record endings (i, summarise o)
        , onHedgeWon     = record winners
        , hedgeClock     = virtualClock vc
        }
  pure (options, launches, endings, winners)

spec :: Spec
spec = do
  describe "hedgeAcross" $ do
    it "Returns the primary's answer without launching a backup inside the delay" $ do
      vc <- newVirtualClock
      gate <- newEmptyTMVarIO
      (options, launches, endings, winners) <- recording vc (const (pure True))
      call <- async $ hedgeAcross options ((\x -> atomically (readTMVar gate) >> pure x) :| [pure . negate]) 5
      waitForSleepers vc 1
      atomically $ putTMVar gate ()
      wait call `shouldReturn` 5
      readIORef launches `shouldReturn` []
      readIORef endings `shouldReturn` [(0, Answered 5)]
      readIORef winners `shouldReturn` [0]

    it "Launches the backup after the delay and cancels the slow primary" $ do
      vc <- newVirtualClock
      closed <- newIORef False
      (options, launches, endings, winners) <- recording vc (const (pure True))
      let primary x = (clockSleep (virtualClock vc) 500000 >> pure x) `finally` writeIORef closed True
      call <- async $ hedgeAcross options (primary :| [pure . negate]) 5
      waitForSleepers vc 2
      advanceClock vc 100000
      wait call `shouldReturn` (-5)
      readIORef closed `shouldReturn` True
      readIORef launches `shouldReturn` [1]
      readIORef endings `shouldReturn` [(1, Answered (-5)), (0, Cancelled)]
      readIORef winners `shouldReturn` [1]

    it "Keeps waiting on the primary when the backup's launch is declined" $ do
      vc <- newVirtualClock
      (options, launches, endings, _) <- recording vc (const (pure False))
      let primary x = clockSleep (virtualClock vc) 500000 >> pure x
      call <- async $ hedgeAcross options (primary :| [pure . negate]) 5
      waitForSleepers vc 2
      advanceClock vc 100000
      let waitForDecision = readIORef launches >>= \l -> if null l then yield >> waitForDecision else pure ()
      waitForDecision
      advanceClock vc 400000
      wait call `shouldReturn` 5
      readIORef launches `shouldReturn` [1]
      readIORef endings `shouldReturn` [(0, Answered 5)]

    it "Launches the backup immediately when the primary fails early" $ do
      vc <- newVirtualClock
      (options, launches, endings, _) <- recording vc (const (pure True))
      hedgeAcross options ((\_ -> throwIO (HedgingTestException 0)) :| [pure . negate]) 5 `shouldReturn` (-5)
      readIORef launches `shouldReturn` [1]
      readIORef endings `shouldReturn` [(0, Failed 0), (1, Answered (-5))]

    it "Rethrows the first failure when every attempt fails" $ do
      vc <- newVirtualClock
      (options, _, endings, winners) <- recording vc (const (pure True))
      let failing n _ = throwIO (HedgingTestException n) :: IO Int
      hedgeAcross options (failing 0 :| [failing 1, failing 2]) (5 :: Int) `shouldThrow` (== HedgingTestException 0)
      readIORef endings `shouldReturn` [(0, Failed 0), (1, Failed 1), (2, Failed 2)]
      readIORef winners `shouldReturn` []

    it "Rethrows the primary's failure when the backup is declined" $ do
      vc <- newVirtualClock
      (options, _, _, _) <- recording vc (const (pure False))
      hedgeAcross options ((\_ -> throwIO (HedgingTestException 0)) :| [pure . negate]) 5
        `shouldThrow` (== HedgingTestException 0)

  describe "hedgeAcrossEither" $
    it "Treats Left as a failed attempt and returns the first Left if all fail" $ do
      vc <- newVirtualClock
      let options = (defaultHedgeOptions :: HedgeOptions IO String (Either String Int)) { hedgeClock = virtualClock vc }
      hedgeAcrossEither options ((\_ -> pure (Left "primary")) :| [pure . Right]) 3 `shouldReturn` Right 3
      hedgeAcrossEither options ((\_ -> pure (Left "primary")) :| [\_ -> pure (Left "backup")]) (3 :: Int)
        `shouldReturn` (Left "primary" :: Either String Int)

  describe "Glue.Race" $ do
    it "Reports attempts that ended together in index order" $ do
      result <- withRace exceptionFailures systemClock $ \race -> do
        _ <- launch race (pure (1 :: Int))
        _ <- launch race (pure 2)
        let settle = finishedAttempts race >>= \f -> if length f == 2 then pure () else yield >> settle
        settle
        first  <- awaitNext race Nothing
        second <- awaitNext race Nothing
        none   <- awaitNext race Nothing
        pure (fmap eventText [first, second, none])
      result `shouldBe` ["0 answered 1", "1 answered 2", "nothing running"]

    it "Abandoning an attempt that already completed reports its result" $ do
      result <- withRace exceptionFailures systemClock $ \race -> do
        index <- launch race (pure (7 :: Int))
        let settle = finishedAttempts race >>= \f -> if f == [index] then pure () else yield >> settle
        settle
        summarise <$> abandon race index
      result `shouldBe` Answered 7

    it "Times out when no attempt ends within the limit" $ do
      vc <- newVirtualClock
      gate <- newEmptyTMVarIO
      let clock = virtualClock vc :: Clock IO
      waiting <- async $ withRace exceptionFailures clock $ \race -> do
        _ <- launch race (atomically (readTMVar gate) >> pure (1 :: Int))
        eventText <$> awaitNext race (Just 50)
      waitForSleepers vc 1
      advanceClock vc 50000
      wait waiting `shouldReturn` "timed out"
      atomically $ putTMVar gate ()

  describe "hedgingService" $ do
    it "Completes with the fast hedged call when the primary call is slow" $ do
      vc  <- newVirtualClock
      ref <- newIORef (0 :: Int)
      let clock = virtualClock vc :: Clock IO
          service (req :: Int) = do
            attemptNum <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))
            if attemptNum == 1
              then clockSleep clock (500 * 1000) >> pure (req + 100)
              else pure (req * 2)
          hedged = hedgingService ((defaultHedgingOptions :: HedgingOptions IO) { hedgingCount = 2, hedgingDelayMs = 5, hedgingClock = clock }) service
      call <- async (hedged 21)
      waitForSleepers vc 2
      advanceClock vc 5000
      wait call `shouldReturn` 42

    it "Succeeds if one attempt fails immediately and another succeeds" $ do
      ref <- newIORef (0 :: Int)
      let service (req :: Int) = do
            attemptNum <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))
            if attemptNum == 1
              then throwIO (HedgingTestException 1)
              else pure (req * 3)
          hedged = hedgingService (defaultHedgingOptions { hedgingCount = 2, hedgingDelayMs = 0 }) service
      hedged 10 `shouldReturn` 30

    it "Rethrows the exception if all hedged attempts fail" $ do
      let service (_ :: Int) = throwIO (HedgingTestException 1) :: IO Int
          hedged = hedgingService (defaultHedgingOptions { hedgingCount = 3, hedgingDelayMs = 0 }) service
      hedged 1 `shouldThrow` (== HedgingTestException 1)
  where
    eventText (AttemptEnded i (AttemptAnswered n)) = show i ++ " answered " ++ show (n :: Int)
    eventText (AttemptEnded i _)                   = show i ++ " ended"
    eventText RaceTimedOut                         = "timed out"
    eventText NothingRunning                       = "nothing running"