packages feed

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

{-# LANGUAGE ScopedTypeVariables #-}

module Glue.ConcurrencyLimitSpec (spec) where

import           Control.Concurrent       (threadDelay, yield)
import           Control.Concurrent.Async (async, cancel, mapConcurrently, poll,
                                           wait)
import           Data.IORef
import           Glue.ConcurrencyLimit
import           Test.Hspec
import           UnliftIO.Exception       (Exception, throwIO, try)
import           UnliftIO.STM             (atomically, newEmptyTMVarIO,
                                           putTMVar, readTMVar)

data ConcurrencyTestException = ConcurrencyTestException
  deriving (Eq, Show)

instance Exception ConcurrencyTestException

waitUntil :: IO Bool -> IO ()
waitUntil condition = condition >>= \done -> if done then pure () else yield >> waitUntil condition

spec :: Spec
spec = do
  describe "limitConcurrency" $ do
    it "Never exceeds maxInFlight concurrent executions" $ do
      currentInFlight <- newIORef (0 :: Int)
      peakInFlight    <- newIORef (0 :: Int)
      let maxAllowed = 3
          service (req :: Int) = do
            cur <- atomicModifyIORef' currentInFlight (\c -> (c + 1, c + 1))
            atomicModifyIORef' peakInFlight (\p -> (max p cur, ()))
            threadDelay (20 * 1000)
            atomicModifyIORef' currentInFlight (\c -> (c - 1, ()))
            pure (req * 2)
      limited <- limitConcurrency (defaultConcurrencyLimitOptions { maxInFlight = maxAllowed }) service
      results <- mapConcurrently limited [1 .. 12]
      results `shouldBe` fmap (* 2) [1 .. 12]
      peak <- readIORef peakInFlight
      peak `shouldSatisfy` (<= maxAllowed)
      peak `shouldSatisfy` (> 1)

    it "Rejects requests when maxInFlight and maxQueued are full" $ do
      let opts = ConcurrencyLimitOptions
            { maxInFlight                 = 1
            , maxQueued                   = Just 1
            , concurrencyLimitDescription = "Busy"
            }
      gate    <- newEmptyTMVarIO
      entered <- newIORef (0 :: Int)
      let service (req :: Int) = do
            atomicModifyIORef' entered (\n -> (n + 1, ()))
            atomically $ readTMVar gate
            pure req
      limited <- limitConcurrency opts service
      a1 <- async (try (limited 1) :: IO (Either ConcurrencyLimitExceededException Int))
      waitUntil ((== 1) <$> readIORef entered)
      -- With 1 in flight, one of these queues and the other is rejected at once.
      a2 <- async (try (limited 2) :: IO (Either ConcurrencyLimitExceededException Int))
      a3 <- async (try (limited 3) :: IO (Either ConcurrencyLimitExceededException Int))
      let rejected = fmap (maybe False (either (const False) (either (const True) (const False)))) . poll
      waitUntil ((||) <$> rejected a2 <*> rejected a3)
      atomically $ putTMVar gate ()
      wait a1 `shouldReturn` Right 1
      results <- traverse wait [a2, a3]
      length [() | Left (ConcurrencyLimitExceededException "Busy") <- results] `shouldBe` 1
      length [r | Right r <- results, r `elem` [2, 3]] `shouldBe` 1

    it "Releases slot when underlying service throws an exception" $ do
      let opts = defaultConcurrencyLimitOptions { maxInFlight = 1, maxQueued = Just 0 }
          failingOrOk (req :: Int) =
            if req == 0 then throwIO ConcurrencyTestException else pure (req + 10)
      limited <- limitConcurrency opts failingOrOk
      limited 0 `shouldThrow` (== ConcurrencyTestException)
      limited 5 `shouldReturn` 15

  describe "limitConcurrencyEither" $
    it "Returns the saturation failure as a value" $ do
      gate    <- newEmptyTMVarIO
      started <- newEmptyTMVarIO
      let opts = defaultConcurrencyLimitOptions { maxInFlight = 1, maxQueued = Just 0 }
      limited <- limitConcurrencyEither opts "busy" $ \(req :: Int) -> do
        atomically $ putTMVar started ()
        atomically $ readTMVar gate
        pure (Right req)
      first <- async (limited 1)
      atomically $ readTMVar started
      limited 2 `shouldReturn` (Left "busy" :: Either String Int)
      atomically $ putTMVar gate ()
      wait first `shouldReturn` Right 1

  describe "limitConcurrencyBy" $ do
    it "Refuses a second call on a key with a limit of one" $ do
      store <- newInMemoryAdmissionStore (const 1)
      gate  <- newEmptyTMVarIO
      let limited = limitConcurrencyByWithStore store (`div` 10) (\(req :: Int) -> atomically (readTMVar gate) >> pure req)
      first <- async (limited 1)
      waitUntil ((== 1) <$> slotsInUse store 0)
      limited 2 `shouldReturn` Left Saturated
      atomically $ putTMVar gate ()
      wait first `shouldReturn` Right 1
      limited 3 `shouldReturn` Right 3

    it "Keeps keys isolated" $ do
      store <- newInMemoryAdmissionStore (const 1)
      gate  <- newEmptyTMVarIO
      let limited = limitConcurrencyByWithStore store (`div` 10) (\(req :: Int) -> atomically (readTMVar gate) >> pure req)
      first <- async (limited 1)
      waitUntil ((== 1) <$> slotsInUse store 0)
      other <- async (limited 11)
      waitUntil ((== 1) <$> slotsInUse store 1)
      atomically $ putTMVar gate ()
      wait first `shouldReturn` Right 1
      wait other `shouldReturn` Right 11

    it "Uses a separate limit per key" $ do
      store <- newInMemoryAdmissionStore (\k -> if k == "wide" then 2 else 1)
      gate  <- newEmptyTMVarIO
      let limited = limitConcurrencyByWithStore store fst (\(_, n :: Int) -> atomically (readTMVar gate) >> pure n)
      a <- async (limited ("wide" :: String, 1))
      b <- async (limited ("wide", 2))
      waitUntil ((== 2) <$> slotsInUse store "wide")
      limited ("wide", 3) `shouldReturn` Left Saturated
      atomically $ putTMVar gate ()
      wait a `shouldReturn` Right 1
      wait b `shouldReturn` Right 2

    it "Releases the slot on exception and on cancellation, leaving no keys behind" $ do
      store <- newInMemoryAdmissionStore (const 1)
      let failing (_ :: Int) = throwIO ConcurrencyTestException :: IO Int
      limitConcurrencyByWithStore store id failing 1 `shouldThrow` (== ConcurrencyTestException)
      slotsInUse store 1 `shouldReturn` 0
      gate <- newEmptyTMVarIO
      blocked <- async (limitConcurrencyByWithStore store id (\(req :: Int) -> atomically (readTMVar gate) >> pure req) 2)
      waitUntil ((== 1) <$> slotsInUse store 2)
      cancel blocked
      slotsInUse store 2 `shouldReturn` 0
      trackedKeys store `shouldReturn` 0

    it "Never exceeds the limit for a key under concurrent load" $ do
      current <- newIORef (0 :: Int)
      peak    <- newIORef (0 :: Int)
      let service (req :: Int) = do
            cur <- atomicModifyIORef' current (\c -> (c + 1, c + 1))
            atomicModifyIORef' peak (\p -> (max p cur, ()))
            yield
            atomicModifyIORef' current (\c -> (c - 1, ()))
            pure req
      limited <- limitConcurrencyBy (const ()) (const 3) service
      results <- mapConcurrently limited [1 .. 200]
      length [r | Right r <- results] `shouldSatisfy` (> 0)
      readIORef peak >>= (`shouldSatisfy` (<= 3))

  describe "withSlot" $
    it "Holds the slot until the work finishes, beyond any service call" $ do
      store <- newInMemoryAdmissionStore (const 1)
      gate  <- newEmptyTMVarIO
      streaming <- async (withSlot store "stream" (atomically (readTMVar gate) >> pure "consumed"))
      waitUntil ((== 1) <$> slotsInUse store ("stream" :: String))
      withSlot store "stream" (pure "second") `shouldReturn` Left Saturated
      atomically $ putTMVar gate ()
      wait streaming `shouldReturn` Right "consumed"
      withSlot store "stream" (pure "second") `shouldReturn` Right "second"

  describe "limitConcurrencyByEither" $
    it "Returns the given failure when the key is full" $ do
      limited <- limitConcurrencyByEither (const ()) (const 0) "full" (\(req :: Int) -> pure (Right req))
      limited 1 `shouldReturn` (Left "full" :: Either String Int)