glue-core-0.8.0.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.
--
-- 'limitConcurrency' caps one wrapped service. 'limitConcurrencyBy' keeps one limit per key (a
-- backend or target, say) and refuses a full key at once with v'Saturated' as a value, so a full
-- guard can feed a fallback without exception plumbing and is never mistaken for a backend failure.
-- A slot covers the wrapped call only; 'withSlot' holds one for work that outlives the call, such
-- as a streamed response consumed later.
module Glue.ConcurrencyLimit
( ConcurrencyLimitOptions (..)
, ConcurrencyLimitExceededException (..)
, defaultConcurrencyLimitOptions
, limitConcurrency
, limitConcurrencyEither
-- * Keyed limits
, Saturated (..)
, AdmissionLease (..)
, AdmissionStore (..)
, newInMemoryAdmissionStore
, withSlot
, limitConcurrencyBy
, limitConcurrencyByWithStore
, limitConcurrencyByEither
) where
import Data.Foldable (traverse_)
import Data.Hashable (Hashable)
import qualified Data.HashMap.Strict as M
import Glue.Failure
import Glue.Types
import UnliftIO (Exception, MonadIO, MonadUnliftIO)
import UnliftIO.Exception (bracket, 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
limitVia
:: (MonadUnliftIO m, MonadIO n)
=> m r
-> ConcurrencyLimitOptions
-> BasicService m a r
-> n (BasicService m a r)
limitVia refuse 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 () -> refuse
Right True ->
finally (service request) releaseInFlight
Right False -> do
bracketOnError (pure ()) (\_ -> releaseQueued) (\_ -> waitForSlot)
finally (service request) releaseInFlight
pure limitedService
-- | 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 =
limitVia (throwIO $ ConcurrencyLimitExceededException (concurrencyLimitDescription options)) options
-- | Variant of 'limitConcurrency' for services returning failures as values, returning the given
-- failure when both limits are reached instead of throwing.
limitConcurrencyEither
:: (MonadUnliftIO m, MonadIO n)
=> ConcurrencyLimitOptions
-- ^ Options configuring the concurrency and queue bounds ('concurrencyLimitDescription' is unused).
-> e
-- ^ Failure returned when the limits are reached.
-> EitherService m a e b
-- ^ Service to protect.
-> n (EitherService m a e b)
limitConcurrencyEither options onSaturated = limitVia (pure (Left onSaturated)) options
-- | Refusal by a keyed limit whose slots are all in use.
data Saturated = Saturated
deriving (Eq, Show)
-- | A claimed slot, released exactly once by 'withSlot'.
newtype AdmissionLease m = AdmissionLease
{ releaseLease :: m ()
}
-- | Where keyed slots are counted. The in-memory store is the default; an implementation backed by
-- shared storage (claim, renew and release of expiring leases, say) gives limits shared across
-- instances, renewing its own leases for as long as they are held.
data AdmissionStore m k = AdmissionStore
{ claimSlot :: k -> m (Maybe (AdmissionLease m))
-- ^ Claims a slot for the key, or 'Nothing' if its limit is reached. Must be atomic.
, slotsInUse :: k -> m Int
-- ^ Slots currently held for the key.
, trackedKeys :: m Int
-- ^ Number of keys with slots held. Keys with nothing in flight are not kept.
}
-- | An in-memory store with the given limit per key (a limit of zero or less refuses every call).
newInMemoryAdmissionStore :: (Hashable k, MonadIO m, MonadIO n) => (k -> Int) -> n (AdmissionStore m k)
newInMemoryAdmissionStore limitFor = do
countsVar <- newTVarIO M.empty
let release key = atomically $ modifyTVar' countsVar (M.update (\n -> if n <= 1 then Nothing else Just (n - 1)) key)
claim key = atomically $ do
counts <- readTVar countsVar
let current = M.findWithDefault 0 key counts
if current < limitFor key
then do
writeTVar countsVar (M.insert key (current + 1) counts)
pure (Just (AdmissionLease (release key)))
else pure Nothing
pure
AdmissionStore
{ claimSlot = claim
, slotsInUse = \key -> atomically $ M.findWithDefault 0 key <$> readTVar countsVar
, trackedKeys = atomically $ M.size <$> readTVar countsVar
}
-- | Runs the work holding a slot for the key, or returns v'Saturated' at once if none is free.
-- The slot is released on every exit path, including asynchronous cancellation.
withSlot :: MonadUnliftIO m => AdmissionStore m k -> k -> m r -> m (Either Saturated r)
withSlot store key work =
bracket (claimSlot store key) (traverse_ releaseLease) $
maybe (pure (Left Saturated)) (const (Right <$> work))
-- | Wraps a service with one concurrency limit per key, refusing a call whose key is full with
-- v'Saturated' instead of queueing it.
limitConcurrencyBy
:: (MonadUnliftIO m, MonadIO n, Hashable k)
=> (a -> k)
-- ^ The key a request is limited under.
-> (k -> Int)
-- ^ The limit for each key.
-> BasicService m a b
-- ^ Service to protect.
-> n (BasicService m a (Either Saturated b))
limitConcurrencyBy keyOf limitFor service = do
store <- newInMemoryAdmissionStore limitFor
pure (limitConcurrencyByWithStore store keyOf service)
-- | Variant of 'limitConcurrencyBy' counting slots in the given store.
limitConcurrencyByWithStore
:: MonadUnliftIO m
=> AdmissionStore m k
-- ^ Store counting the slots.
-> (a -> k)
-- ^ The key a request is limited under.
-> BasicService m a b
-- ^ Service to protect.
-> BasicService m a (Either Saturated b)
limitConcurrencyByWithStore store keyOf service request = withSlot store (keyOf request) (service request)
-- | Variant of 'limitConcurrencyBy' for services returning failures as values, returning the given
-- failure when the key is full.
limitConcurrencyByEither
:: (MonadUnliftIO m, MonadIO n, Hashable k)
=> (a -> k)
-- ^ The key a request is limited under.
-> (k -> Int)
-- ^ The limit for each key.
-> e
-- ^ Failure returned when the key is full.
-> EitherService m a e b
-- ^ Service to protect.
-> n (EitherService m a e b)
limitConcurrencyByEither keyOf limitFor onSaturated service = do
limited <- limitConcurrencyBy keyOf limitFor service
pure $ fmap (either (const (Left onSaturated)) id) . limited