packages feed

glue-core-0.7.0: src/Glue/ConcurrencyLimit.hs

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

-- | Module supporting concurrent request constraints (bulkhead pattern),
-- limiting the number of in-flight and optionally queued requests.
module Glue.ConcurrencyLimit
  ( ConcurrencyLimitOptions (..)
  , ConcurrencyLimitExceededException (..)
  , defaultConcurrencyLimitOptions
  , limitConcurrency
  ) where

import           Glue.Types
import           UnliftIO           (Exception, MonadIO, MonadUnliftIO)
import           UnliftIO.Exception (bracketOnError, finally, throwIO)
import           UnliftIO.STM       (atomically, modifyTVar', newTVarIO,
                                     readTVar, retrySTM, writeTVar)

-- | Options for configuring concurrent request limits.
data ConcurrencyLimitOptions = ConcurrencyLimitOptions
  { maxInFlight                 :: Int
  -- ^ Maximum number of concurrent requests allowed to execute the underlying service simultaneously.
  , maxQueued                   :: Maybe Int
  -- ^ Optional maximum number of requests allowed to wait in the queue (`Nothing` for unbounded queue, `Just 0` for no queueing).
  , concurrencyLimitDescription :: String
  -- ^ Description attached to t'ConcurrencyLimitExceededException' when a request is rejected.
  }
  deriving (Eq, Show)

-- | Default concurrency limit options allowing 10 in-flight requests and an unbounded queue.
defaultConcurrencyLimitOptions :: ConcurrencyLimitOptions
defaultConcurrencyLimitOptions =
  ConcurrencyLimitOptions
    { maxInFlight                 = 10
    , maxQueued                   = Nothing
    , concurrencyLimitDescription = "Concurrency limit exceeded."
    }

-- | Exception thrown when both in-flight and queue limits have been reached.
newtype ConcurrencyLimitExceededException = ConcurrencyLimitExceededException String
  deriving (Eq, Show)

instance Exception ConcurrencyLimitExceededException

-- | Wraps a service with a concurrency limit restricting in-flight and queued requests.
limitConcurrency
  :: (MonadUnliftIO m, MonadIO n)
  => ConcurrencyLimitOptions
  -- ^ Options configuring the concurrency and queue bounds.
  -> BasicService m a b
  -- ^ Service to protect.
  -> n (BasicService m a b)
limitConcurrency options service = do
  let maxActive = max 1 (maxInFlight options)
  inFlightVar <- newTVarIO (0 :: Int)
  queuedVar   <- newTVarIO (0 :: Int)
  let releaseInFlight = atomically $ modifyTVar' inFlightVar (subtract 1)
      releaseQueued   = atomically $ modifyTVar' queuedVar (subtract 1)
      waitForSlot     = atomically $ do
        inFlight <- readTVar inFlightVar
        if inFlight < maxActive
          then do
            modifyTVar' queuedVar (subtract 1)
            writeTVar inFlightVar (inFlight + 1)
          else retrySTM
      limitedService request = do
        admission <- atomically $ do
          inFlight <- readTVar inFlightVar
          queued   <- readTVar queuedVar
          if inFlight < maxActive && queued == 0
            then do
              writeTVar inFlightVar (inFlight + 1)
              pure (Right True)
            else case maxQueued options of
              Just maxQ | queued >= maxQ -> pure (Left ())
              _ -> do
                writeTVar queuedVar (queued + 1)
                pure (Right False)
        case admission of
          Left () ->
            throwIO $ ConcurrencyLimitExceededException (concurrencyLimitDescription options)
          Right True ->
            finally (service request) releaseInFlight
          Right False -> do
            bracketOnError (pure ()) (\_ -> releaseQueued) (\_ -> waitForSlot)
            finally (service request) releaseInFlight
  pure limitedService