packages feed

glue-core 0.6.3 → 0.7.0

raw patch · 28 files changed

+1343/−627 lines, 28 filesdep +unliftiodep −ekg-coredep −lifted-basedep −monad-controldep ~basedep ~glue-commonsetup-changedPVP ok

version bump matches the API change (PVP)

Dependencies added: unliftio

Dependencies removed: ekg-core, lifted-base, monad-control, text, transformers, transformers-base

Dependency ranges changed: base, glue-common

API changes (from Hackage documentation)

- Glue.Batcher: batchWindowMs :: BatchingOptions -> Int
- Glue.Batcher: data BatchingOptions
- Glue.CircuitBreaker: breakerDescription :: CircuitBreakerOptions -> String
- Glue.CircuitBreaker: data CircuitBreakerException
- Glue.CircuitBreaker: maxBreakerFailures :: CircuitBreakerOptions -> Int
- Glue.CircuitBreaker: resetTimeoutSecs :: CircuitBreakerOptions -> Int
- Glue.Failover: maxFailovers :: FailoverOptions a -> Int
- Glue.Failover: transformFailoverRequest :: FailoverOptions a -> a -> a
- Glue.Preload: preloadedKeys :: PreloadedOptions m a b -> HashSet a
- Glue.Preload: preloadingRefreshTimeMs :: PreloadedOptions m a b -> Int
- Glue.Preload: preloadingRun :: PreloadedOptions m a b -> MToIO m
- Glue.Retry: maximumRetries :: RetryOptions a -> Int
- Glue.Retry: retryAllowed :: RetryOptions a -> a -> Bool
- Glue.Retry: retryInitialWaitTimeMs :: RetryOptions a -> Int
- Glue.Retry: retryWaitTimeMultiplier :: RetryOptions a -> Double
- Glue.Timeout: data TimeoutException
- Glue.Timeout: timeoutDescription :: TimeoutOptions -> String
- Glue.Timeout: timeoutLimitMs :: TimeoutOptions -> Int
+ Glue.Batcher: BatchingOptions :: Int -> BatchingOptions
+ Glue.Batcher: [batchWindowMs] :: BatchingOptions -> Int
+ Glue.Batcher: newtype BatchingOptions
+ Glue.CircuitBreaker: CircuitBreakerOptions :: Int -> Int -> String -> CircuitBreakerOptions
+ Glue.CircuitBreaker: [breakerDescription] :: CircuitBreakerOptions -> String
+ Glue.CircuitBreaker: [maxBreakerFailures] :: CircuitBreakerOptions -> Int
+ Glue.CircuitBreaker: [resetTimeoutSecs] :: CircuitBreakerOptions -> Int
+ Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.CircuitBreakerOptions
+ Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.CircuitBreakerOptions
+ Glue.CircuitBreaker: newtype CircuitBreakerException
+ Glue.ConcurrencyLimit: ConcurrencyLimitExceededException :: String -> ConcurrencyLimitExceededException
+ Glue.ConcurrencyLimit: ConcurrencyLimitOptions :: Int -> Maybe Int -> String -> ConcurrencyLimitOptions
+ Glue.ConcurrencyLimit: [concurrencyLimitDescription] :: ConcurrencyLimitOptions -> String
+ Glue.ConcurrencyLimit: [maxInFlight] :: ConcurrencyLimitOptions -> Int
+ Glue.ConcurrencyLimit: [maxQueued] :: ConcurrencyLimitOptions -> Maybe Int
+ Glue.ConcurrencyLimit: data ConcurrencyLimitOptions
+ Glue.ConcurrencyLimit: defaultConcurrencyLimitOptions :: ConcurrencyLimitOptions
+ Glue.ConcurrencyLimit: instance GHC.Classes.Eq Glue.ConcurrencyLimit.ConcurrencyLimitExceededException
+ Glue.ConcurrencyLimit: instance GHC.Classes.Eq Glue.ConcurrencyLimit.ConcurrencyLimitOptions
+ Glue.ConcurrencyLimit: instance GHC.Exception.Type.Exception Glue.ConcurrencyLimit.ConcurrencyLimitExceededException
+ Glue.ConcurrencyLimit: instance GHC.Show.Show Glue.ConcurrencyLimit.ConcurrencyLimitExceededException
+ Glue.ConcurrencyLimit: instance GHC.Show.Show Glue.ConcurrencyLimit.ConcurrencyLimitOptions
+ Glue.ConcurrencyLimit: limitConcurrency :: (MonadUnliftIO m, MonadIO n) => ConcurrencyLimitOptions -> BasicService m a b -> n (BasicService m a b)
+ Glue.ConcurrencyLimit: newtype ConcurrencyLimitExceededException
+ Glue.DogpileProtection: DogpileOptions :: Int -> DogpileOptions
+ Glue.DogpileProtection: [dogpileCacheTimeMs] :: DogpileOptions -> Int
+ Glue.DogpileProtection: defaultDogpileOptions :: DogpileOptions
+ Glue.DogpileProtection: dogpileProtectWith :: (MonadUnliftIO m, MonadIO n, Hashable a) => DogpileOptions -> BasicService m a b -> n (BasicService m a b)
+ Glue.DogpileProtection: instance GHC.Classes.Eq Glue.DogpileProtection.DogpileOptions
+ Glue.DogpileProtection: instance GHC.Show.Show Glue.DogpileProtection.DogpileOptions
+ Glue.DogpileProtection: newtype DogpileOptions
+ Glue.Failover: FailoverOptions :: Int -> (a -> a) -> FailoverOptions a
+ Glue.Failover: [maxFailovers] :: FailoverOptions a -> Int
+ Glue.Failover: [transformFailoverRequest] :: FailoverOptions a -> a -> a
+ Glue.Hedging: HedgingOptions :: Int -> Int -> HedgingOptions
+ Glue.Hedging: [hedgingCount] :: HedgingOptions -> Int
+ Glue.Hedging: [hedgingDelayMs] :: HedgingOptions -> Int
+ Glue.Hedging: data HedgingOptions
+ Glue.Hedging: defaultHedgingOptions :: HedgingOptions
+ Glue.Hedging: hedgingService :: MonadUnliftIO m => HedgingOptions -> BasicService m a b -> BasicService m a b
+ Glue.Hedging: instance GHC.Classes.Eq Glue.Hedging.HedgingOptions
+ Glue.Hedging: instance GHC.Show.Show Glue.Hedging.HedgingOptions
+ Glue.LoadBalancer: NoServicesAvailableException :: NoServicesAvailableException
+ Glue.LoadBalancer: data NoServicesAvailableException
+ Glue.LoadBalancer: instance GHC.Classes.Eq Glue.LoadBalancer.NoServicesAvailableException
+ Glue.LoadBalancer: instance GHC.Exception.Type.Exception Glue.LoadBalancer.NoServicesAvailableException
+ Glue.LoadBalancer: instance GHC.Show.Show Glue.LoadBalancer.NoServicesAvailableException
+ Glue.LoadBalancer: roundRobinLoadBalancer :: (MonadIO m, MonadIO n, Foldable t) => t (BasicService m a b) -> n (BasicService m a b)
+ Glue.LoadBalancer: roundRobinLoadBalancerNE :: (MonadIO m, MonadIO n) => NonEmpty (BasicService m a b) -> n (BasicService m a b)
+ Glue.Preload: PreloadStoppedBeforeExecutedException :: PreloadStoppedBeforeExecutedException
+ Glue.Preload: PreloadedOptions :: HashSet a -> Int -> MToIO m -> PreloadedOptions (m :: Type -> Type) a b
+ Glue.Preload: [preloadedKeys] :: PreloadedOptions (m :: Type -> Type) a b -> HashSet a
+ Glue.Preload: [preloadingRefreshTimeMs] :: PreloadedOptions (m :: Type -> Type) a b -> Int
+ Glue.Preload: [preloadingRun] :: PreloadedOptions (m :: Type -> Type) a b -> MToIO m
+ Glue.Preload: data PreloadStoppedBeforeExecutedException
+ Glue.Retry: RetryOptions :: (a -> Bool) -> Int -> Int -> Double -> RetryOptions a
+ Glue.Retry: [maximumRetries] :: RetryOptions a -> Int
+ Glue.Retry: [retryAllowed] :: RetryOptions a -> a -> Bool
+ Glue.Retry: [retryInitialWaitTimeMs] :: RetryOptions a -> Int
+ Glue.Retry: [retryWaitTimeMultiplier] :: RetryOptions a -> Double
+ Glue.Shutdown: ServiceShuttingDownException :: String -> ServiceShuttingDownException
+ Glue.Shutdown: ShutdownHandle :: m Bool -> m Bool -> m Int -> ShutdownHandle (m :: Type -> Type)
+ Glue.Shutdown: ShutdownOptions :: Maybe b -> Int -> String -> ShutdownOptions b
+ Glue.Shutdown: [inFlightCount] :: ShutdownHandle (m :: Type -> Type) -> m Int
+ Glue.Shutdown: [initiateShutdown] :: ShutdownHandle (m :: Type -> Type) -> m Bool
+ Glue.Shutdown: [isShuttingDown] :: ShutdownHandle (m :: Type -> Type) -> m Bool
+ Glue.Shutdown: [shutdownDescription] :: ShutdownOptions b -> String
+ Glue.Shutdown: [shutdownFallback] :: ShutdownOptions b -> Maybe b
+ Glue.Shutdown: [shutdownGracePeriodMs] :: ShutdownOptions b -> Int
+ Glue.Shutdown: data ShutdownHandle (m :: Type -> Type)
+ Glue.Shutdown: data ShutdownOptions b
+ Glue.Shutdown: defaultShutdownOptions :: ShutdownOptions b
+ Glue.Shutdown: gracefulShutdown :: forall m n a b. (MonadUnliftIO m, MonadIO n) => ShutdownOptions b -> BasicService m a b -> n (BasicService m a b, ShutdownHandle m)
+ Glue.Shutdown: instance GHC.Classes.Eq Glue.Shutdown.ServiceShuttingDownException
+ Glue.Shutdown: instance GHC.Classes.Eq b => GHC.Classes.Eq (Glue.Shutdown.ShutdownOptions b)
+ Glue.Shutdown: instance GHC.Exception.Type.Exception Glue.Shutdown.ServiceShuttingDownException
+ Glue.Shutdown: instance GHC.Show.Show Glue.Shutdown.ServiceShuttingDownException
+ Glue.Shutdown: instance GHC.Show.Show b => GHC.Show.Show (Glue.Shutdown.ShutdownOptions b)
+ Glue.Shutdown: newtype ServiceShuttingDownException
+ Glue.Timeout: TimeoutOptions :: String -> Int -> TimeoutOptions
+ Glue.Timeout: [timeoutDescription] :: TimeoutOptions -> String
+ Glue.Timeout: [timeoutLimitMs] :: TimeoutOptions -> Int
+ Glue.Timeout: instance GHC.Classes.Eq Glue.Timeout.TimeoutOptions
+ Glue.Timeout: instance GHC.Show.Show Glue.Timeout.TimeoutOptions
+ Glue.Timeout: newtype TimeoutException
- Glue.Batcher: batchingService :: (Eq a, Hashable a, MonadBaseControl IO m, Applicative m, MonadBaseControl IO n) => BatchingOptions -> MultiGetService m a b -> n (BasicService m a (Maybe b), MultiGetService m a b)
+ Glue.Batcher: batchingService :: (Hashable a, MonadUnliftIO m, MonadIO n) => BatchingOptions -> MultiGetService m a b -> n (BasicService m a (Maybe b), MultiGetService m a b)
- Glue.Caching: cacheWithMulti :: (Monad m, Functor m, Eq a, Hashable a) => (MultiGetRequest a -> m (MultiGetResponse a b)) -> (MultiGetResponse a b -> m ()) -> MultiGetService m a b -> MultiGetService m a b
+ Glue.Caching: cacheWithMulti :: (Monad m, Hashable a) => (MultiGetRequest a -> m (MultiGetResponse a b)) -> (MultiGetResponse a b -> m ()) -> MultiGetService m a b -> MultiGetService m a b
- Glue.CircuitBreaker: circuitBreaker :: (MonadBaseControl IO m, MonadBaseControl IO n) => CircuitBreakerOptions -> BasicService m a b -> n (CircuitBreakerState, BasicService m a b)
+ Glue.CircuitBreaker: circuitBreaker :: (MonadUnliftIO m, MonadIO n) => CircuitBreakerOptions -> BasicService m a b -> n (CircuitBreakerState, BasicService m a b)
- Glue.CircuitBreaker: isCircuitBreakerClosed :: MonadBaseControl IO m => CircuitBreakerState -> m Bool
+ Glue.CircuitBreaker: isCircuitBreakerClosed :: MonadIO m => CircuitBreakerState -> m Bool
- Glue.CircuitBreaker: isCircuitBreakerOpen :: MonadBaseControl IO m => CircuitBreakerState -> m Bool
+ Glue.CircuitBreaker: isCircuitBreakerOpen :: MonadIO m => CircuitBreakerState -> m Bool
- Glue.DogpileProtection: dogpileProtect :: (Eq a, Hashable a) => BasicService IO a b -> IO (BasicService IO a b)
+ Glue.DogpileProtection: dogpileProtect :: (MonadUnliftIO m, MonadIO n, Hashable a) => BasicService m a b -> n (BasicService m a b)
- Glue.Failover: failover :: MonadBaseControl IO m => FailoverOptions a -> BasicService m a b -> BasicService m a b
+ Glue.Failover: failover :: MonadUnliftIO m => FailoverOptions a -> BasicService m a b -> BasicService m a b
- Glue.Preload: data PreloadedOptions m a b
+ Glue.Preload: data PreloadedOptions (m :: Type -> Type) a b
- Glue.Preload: defaultPreloadedOptions :: HashSet a -> MToIO m -> PreloadedOptions m a b
+ Glue.Preload: defaultPreloadedOptions :: forall a (m :: Type -> Type) b. HashSet a -> MToIO m -> PreloadedOptions m a b
- Glue.Preload: preloadingCall :: forall m n b. (MonadIO m, MonadIO n, MonadBaseControl IO m, MonadBaseControl IO n) => MToIO m -> Int -> m b -> n (m b, () -> n ())
+ Glue.Preload: preloadingCall :: (MonadUnliftIO m, MonadIO n) => MToIO m -> Int -> m b -> n (m b, () -> n ())
- Glue.Preload: preloadingService :: forall m n a b. (MonadIO m, MonadIO n, MonadBaseControl IO m, MonadBaseControl IO n, Eq a, Hashable a, Show a) => PreloadedOptions m a b -> MultiGetService m a b -> n (MultiGetService m a b, () -> n ())
+ Glue.Preload: preloadingService :: (MonadUnliftIO m, MonadIO n, Hashable a) => PreloadedOptions m a b -> MultiGetService m a b -> n (MultiGetService m a b, () -> n ())
- Glue.Retry: retryingService :: MonadBaseControl IO m => RetryOptions a -> BasicService m a b -> BasicService m a b
+ Glue.Retry: retryingService :: MonadUnliftIO m => RetryOptions a -> BasicService m a b -> BasicService m a b
- Glue.Switching: switchingService :: (MonadBase IO m, MonadBase IO n) => MultiGetService m a b -> n (MultiGetService m a b, MultiGetService m a b -> n ())
+ Glue.Switching: switchingService :: (MonadIO m, MonadIO n) => BasicService m a b -> n (BasicService m a b, BasicService m a b -> n ())
- Glue.Timeout: addTimeout :: MonadBaseControl IO m => TimeoutOptions -> BasicService m a b -> BasicService m a b
+ Glue.Timeout: addTimeout :: MonadUnliftIO m => TimeoutOptions -> BasicService m a b -> BasicService m a b

Files

− Setup.hs
@@ -1,2 +0,0 @@-import Distribution.Simple-main = defaultMain
glue-core.cabal view
@@ -1,78 +1,92 @@-name:                   glue-core-version:                0.6.3-synopsis:               Make better services and clients.-description:            Combinator library to enhance the general functionality of services and clients.-license:                BSD3-license-file:           LICENSE-author:                 Sean Parsons-maintainer:             github@futurenotfound.com-category:               Network-build-type:             Simple-cabal-version:          >=1.10+cabal-version:      3.0+name:               glue-core+version:            0.7.0+synopsis:           Make better services and clients.+description:        Combinator library to enhance the general functionality of services and clients.+license:            BSD-3-Clause+license-file:       LICENSE+author:             Sean Parsons+maintainer:         github@futurenotfound.com+category:           Network+build-type:         Simple+tested-with:        GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3, GHC == 9.12.3  source-repository head-  type:                 git-  location:             git://github.com/seanparsons/glue.git+  type:     git+  location: https://github.com/seanparsons/glue.git +common common-options+  default-language: Haskell2010+  ghc-options:+    -Wall+    -Wcompat+    -Widentities+    -Wincomplete-record-updates+    -Wincomplete-uni-patterns+    -Wmissing-home-modules+    -Wpartial-fields+    -Wredundant-constraints+ library-  exposed-modules:      Glue.Failover-                        Glue.Caching-                        Glue.CircuitBreaker-                        Glue.DogpileProtection-                        Glue.Timeout-                        Glue.Retry-                        Glue.Batcher-                        Glue.Preload-                        Glue.Switching-  -- other-extensions:-  build-depends:        base >=4.6 && <5,-                        glue-common,-                        transformers,-                        transformers-base,-                        lifted-base,-                        time,-                        monad-control,-                        unordered-containers,-                        hashable,-                        text-  ghc-options:          -Wall-  hs-source-dirs:       src-  default-language:     Haskell2010+  import:         common-options+  hs-source-dirs: src+  exposed-modules:+    Glue.Batcher+    Glue.Caching+    Glue.CircuitBreaker+    Glue.ConcurrencyLimit+    Glue.DogpileProtection+    Glue.Failover+    Glue.Hedging+    Glue.LoadBalancer+    Glue.Preload+    Glue.Retry+    Glue.Shutdown+    Glue.Switching+    Glue.Timeout+  build-depends:+    , base                  >= 4.16 && < 5+    , glue-common           ^>= 0.7.0+    , hashable+    , time+    , unliftio              >= 0.2+    , unordered-containers  test-suite glue-core-tests-  build-depends:        base >=4.6 && <5,-                        glue-common ,-                        glue-core,-                        QuickCheck -any,-                        quickcheck-instances,-                        hspec,-                        transformers,-                        transformers-base,-                        lifted-base,-                        time,-                        monad-control,-                        unordered-containers,-                        hashable,-                        ekg-core,-                        text,-                        async-  other-modules:        Glue.CachingSpec-                        Glue.FailoverSpec-                        Glue.RetrySpec-                        Glue.DogpileProtectionSpec-                        Glue.TimeoutSpec-                        Glue.CircuitBreakerSpec-                        Glue.BatcherSpec-                        Glue.PreloadSpec-                        Glue.SwitchingSpec-                        Spec-  ghc-options:          -rtsopts-                        -Wall-                        -O2-                        -threaded-  build-tools:          hspec-discover-  type:                 exitcode-stdio-1.0-  main-is:              Main.hs-  buildable:            True-  default-language:     Haskell2010-  hs-source-dirs:       test+  import:             common-options+  type:               exitcode-stdio-1.0+  hs-source-dirs:     test+  main-is:            Main.hs+  other-modules:+    Glue.BatcherSpec+    Glue.CachingSpec+    Glue.CircuitBreakerSpec+    Glue.ConcurrencyLimitSpec+    Glue.DogpileProtectionSpec+    Glue.FailoverSpec+    Glue.HedgingSpec+    Glue.LoadBalancerSpec+    Glue.PreloadSpec+    Glue.RetrySpec+    Glue.ShutdownSpec+    Glue.SwitchingSpec+    Glue.TimeoutSpec+    Spec+  build-tool-depends: hspec-discover:hspec-discover+  ghc-options:+    -threaded+    -rtsopts+    -with-rtsopts=-N+    -O2+  build-depends:+    , async+    , base                  >= 4.16 && < 5+    , glue-common+    , glue-core+    , hashable+    , hspec+    , QuickCheck+    , quickcheck-instances+    , time+    , unliftio              >= 0.2+    , unordered-containers
src/Glue/Batcher.hs view
@@ -2,85 +2,100 @@ {-# LANGUAGE ScopedTypeVariables #-}  -- | Module for creating a service that batches calls across requests.-module Glue.Batcher(-    BatchingOptions+module Glue.Batcher+  ( BatchingOptions (..)   , batchingService   , defaultBatchingOptions-  , batchWindowMs-) where+  ) where -import           Control.Concurrent.Lifted-import           Control.Exception.Lifted-import           Control.Monad-import           Control.Monad.Trans.Control-import           Data.Foldable-import           Data.Hashable-import qualified Data.HashMap.Strict         as M-import qualified Data.HashSet                as S-import           Data.IORef.Lifted+import           Control.Monad       (join, void)+import           Data.Foldable       (traverse_)+import           Data.Hashable       (Hashable)+import qualified Data.HashMap.Strict as M+import qualified Data.HashSet        as S import           Glue.Types+import           UnliftIO            (MonadIO, MonadUnliftIO, SomeException)+import           UnliftIO.Concurrent (forkIO, threadDelay)+import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef)+import           UnliftIO.MVar       (newEmptyMVar, putMVar)  -- | Options for configuring the batching.-data BatchingOptions = BatchingOptions {-    batchWindowMs :: Int           -- ^ Window in milliseconds over which to batch.-} deriving (Eq, Show)+newtype BatchingOptions = BatchingOptions+  { batchWindowMs :: Int+  -- ^ Window in milliseconds over which to batch.+  }+  deriving (Eq, Show) --- | Default instance of 'BatchingOptions' with a batching window of 10ms.+-- | Default instance of t'BatchingOptions' with a batching window of 10ms. defaultBatchingOptions :: BatchingOptions-defaultBatchingOptions = BatchingOptions {-   batchWindowMs = 10-}+defaultBatchingOptions =+  BatchingOptions+    { batchWindowMs = 10+    }  -- | Cumulative request batch of all the outstanding calls. data RequestBatch a b = RequestBatch [PendingRequest a b] (MultiGetRequest a)  -- | Individual request to satisfy, for either a single key or a multi request.-data PendingRequest a b =-  SingleRequest a (ResultVar (Maybe b)) |-  MultiRequest (MultiGetRequest a) (ResultVar (MultiGetResponse a b))+data PendingRequest a b+  = SingleRequest a (ResultVar (Maybe b))+  | MultiRequest (MultiGetRequest a) (ResultVar (MultiGetResponse a b)) -applyToPending :: (Eq a, Hashable a, MonadBaseControl IO m) => Either SomeException (M.HashMap a b) -> PendingRequest a b -> m ()-applyToPending (Left e) (SingleRequest _ var)         = putMVar var $ Left e-applyToPending (Right results) (SingleRequest a var)  = putMVar var $ Right $ M.lookup a results-applyToPending (Left e) (MultiRequest _ var)          = putMVar var $ Left e-applyToPending (Right results) (MultiRequest as var)  = putMVar var $ Right $ M.filterWithKey (\k -> \_ -> S.member k as) results+applyToPending :: (Hashable a, MonadIO m) => Either SomeException (M.HashMap a b) -> PendingRequest a b -> m ()+applyToPending (Left e) (SingleRequest _ var)        = putMVar var $ Left e+applyToPending (Right results) (SingleRequest a var) = putMVar var $ Right $ M.lookup a results+applyToPending (Left e) (MultiRequest _ var)         = putMVar var $ Left e+applyToPending (Right results) (MultiRequest as var) = putMVar var $ Right $ M.filterWithKey (\k _ -> S.member k as) results -emptyBatch :: (Eq a, Hashable a) => RequestBatch a b+emptyBatch :: RequestBatch a b emptyBatch = RequestBatch [] S.empty -processCalls :: (Eq a, Hashable a, MonadBaseControl IO m) => MultiGetService m a b -> RequestBatch a b -> m ()+processCalls :: (Hashable a, MonadUnliftIO m) => MultiGetService m a b -> RequestBatch a b -> m () processCalls service (RequestBatch pendings requests) = do-                                                          result <- makeCall service requests-                                                          traverse_ (applyToPending result) pendings+  result <- makeCall service requests+  traverse_ (applyToPending result) pendings -startBatch :: (Eq a, Hashable a, MonadBaseControl IO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> m ()-startBatch options service ref = fmap (\_ -> ()) $ fork $ do-                                                            threadDelay (1000 * batchWindowMs options)-                                                            pendings <- atomicModifyIORef' ref (\p -> (emptyBatch, p))-                                                            processCalls service pendings+startBatch :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> m ()+startBatch options service ref =+  void $ forkIO $ do+    threadDelay (1000 * batchWindowMs options)+    pendings <- atomicModifyIORef' ref (\p -> (emptyBatch, p))+    processCalls service pendings -addPending :: (Eq a, Hashable a, MonadBaseControl IO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> PendingRequest a b -> m ()-addPending options service ref p@(SingleRequest request _) = join $ atomicModifyIORef' ref (\(RequestBatch ps rs) -> (RequestBatch (p : ps) (S.insert request rs), if null ps then startBatch options service ref else return ()))-addPending options service ref p@(MultiRequest requests _) = join $ atomicModifyIORef' ref (\(RequestBatch ps rs) -> (RequestBatch (p : ps) (S.union rs requests), if null ps then startBatch options service ref else return ()))+addPending :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> PendingRequest a b -> m ()+addPending options service ref p@(SingleRequest request _) =+  join $+    atomicModifyIORef'+      ref+      (\(RequestBatch ps rs) -> (RequestBatch (p : ps) (S.insert request rs), if null ps then startBatch options service ref else pure ()))+addPending options service ref p@(MultiRequest requests _) =+  join $+    atomicModifyIORef'+      ref+      (\(RequestBatch ps rs) -> (RequestBatch (p : ps) (S.union rs requests), if null ps then startBatch options service ref else pure ())) -singleService :: (Eq a, Hashable a, MonadBaseControl IO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> BasicService m a (Maybe b)+singleService :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> BasicService m a (Maybe b) singleService options service ref request = do-                                              mvar <- newEmptyMVar-                                              let pending = SingleRequest request mvar-                                              addPending options service ref pending-                                              getResult mvar-multiService :: (Eq a, Hashable a, MonadBaseControl IO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> MultiGetService m a b+  mvar <- newEmptyMVar+  let pending = SingleRequest request mvar+  addPending options service ref pending+  getResult mvar++multiService :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> MultiGetService m a b multiService options service ref requests = do-                                              mvar <- newEmptyMVar-                                              let pending = MultiRequest requests mvar-                                              addPending options service ref pending-                                              getResult mvar+  mvar <- newEmptyMVar+  let pending = MultiRequest requests mvar+  addPending options service ref pending+  getResult mvar  -- | Function for constructing a batching service.-batchingService :: (Eq a, Hashable a, MonadBaseControl IO m, Applicative m, MonadBaseControl IO n)-                => BatchingOptions            -- ^ Options to configure the batched service.-                -> MultiGetService m a b      -- ^ Service around which to batch requests.-                -> n (BasicService m a (Maybe b), MultiGetService m a b)+batchingService+  :: (Hashable a, MonadUnliftIO m, MonadIO n)+  => BatchingOptions+  -- ^ Options to configure the batched service.+  -> MultiGetService m a b+  -- ^ Service around which to batch requests.+  -> n (BasicService m a (Maybe b), MultiGetService m a b) batchingService options service = do   ref <- newIORef emptyBatch-  return (singleService options service ref, multiService options service ref)+  pure (singleService options service ref, multiService options service ref)
src/Glue/Caching.hs view
@@ -2,48 +2,58 @@ {-# LANGUAGE ScopedTypeVariables #-}  -- | Module supporting the caching of a service.-module Glue.Caching(-    cacheWithBasic+module Glue.Caching+  ( cacheWithBasic   , cacheWithMulti-) where+  ) where -import           Data.Hashable+import           Data.Hashable       (Hashable) import qualified Data.HashMap.Strict as M import qualified Data.HashSet        as S import           Glue.Types --- | Caching of a `BasicService` instance, that defers to external functions for the actual caching.--- | Note: Values within m will be lost for calls that hit the cache.-cacheWithBasic :: (Monad m)-               => (a -> m (Maybe b))  -- ^ Cache lookup function, used before potentially invoking the fallback service.-               -> (a -> b -> m ())    -- ^ Cache write function, used after invoking the fallback service to populate the cache.-               -> BasicService m a b  -- ^ The service to cache.-               -> BasicService m a b+-- | Caching of a 'BasicService' instance, that defers to external functions for the actual caching.+-- Note: Values within @m@ will be lost for calls that hit the cache.+cacheWithBasic+  :: Monad m+  => (a -> m (Maybe b))+  -- ^ Cache lookup function, used before potentially invoking the fallback service.+  -> (a -> b -> m ())+  -- ^ Cache write function, used after invoking the fallback service to populate the cache.+  -> BasicService m a b+  -- ^ The service to cache.+  -> BasicService m a b cacheWithBasic lookupWith insertWith service =   let fallback request = do-                            result <- service request-                            insertWith request result-                            return result+        result <- service request+        insertWith request result+        pure result       cachedService request = do-                                fromCache <- lookupWith request-                                maybe (fallback request) return fromCache-  in cachedService+        fromCache <- lookupWith request+        maybe (fallback request) pure fromCache+  in  cachedService --- | Caching of a `MultiGetService` instance, that defers to external functions for the actual caching.--- | Partial responses will result in partial fallback calls that get just the missing keys.--- | Values within m will be lost for calls that hit the cache.-cacheWithMulti :: (Monad m, Functor m, Eq a, Hashable a)-               => ((MultiGetRequest a) -> m (MultiGetResponse a b)) -- ^ Cache lookup function, used before potentially invoking the fallback service.-               -> ((MultiGetResponse a b) -> m ())                  -- ^ Cache write function, used after invoking the fallback service to populate the cache.-               -> MultiGetService m a b                             -- ^ The service to cache.-               -> MultiGetService m a b+-- | Caching of a 'MultiGetService' instance, that defers to external functions for the actual caching.+-- Partial responses will result in partial fallback calls that get just the missing keys.+-- Values within @m@ will be lost for calls that hit the cache.+cacheWithMulti+  :: (Monad m, Hashable a)+  => (MultiGetRequest a -> m (MultiGetResponse a b))+  -- ^ Cache lookup function, used before potentially invoking the fallback service.+  -> (MultiGetResponse a b -> m ())+  -- ^ Cache write function, used after invoking the fallback service to populate the cache.+  -> MultiGetService m a b+  -- ^ The service to cache.+  -> MultiGetService m a b cacheWithMulti lookupWith insertWith service =   let fallback request = do-                            result <- service request-                            insertWith result-                            return result+        result <- service request+        insertWith result+        pure result       cachedService request = do-                                fromCache <- lookupWith request-                                let uncachedKeys = S.difference request (S.fromList $ M.keys fromCache)-                                if (S.null uncachedKeys) then (return fromCache) else (fmap (M.union fromCache) $ fallback uncachedKeys)-  in cachedService+        fromCache <- lookupWith request+        let uncachedKeys = S.difference request (S.fromList $ M.keys fromCache)+        if S.null uncachedKeys+          then pure fromCache+          else M.union fromCache <$> fallback uncachedKeys+  in  cachedService
src/Glue/CircuitBreaker.hs view
@@ -1,55 +1,68 @@-{-# LANGUAGE DeriveDataTypeable  #-} {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE ScopedTypeVariables #-} --- | Module containing circuit breaker functionality, which is the ability to open a circuit once a number of failures have occurred, thereby preventing later calls from attempting to make unsuccessful calls.--- | Often this is useful if the underlying service were to repeatedly time out, so as to reduce the number of calls inflight holding up upstream callers.-module Glue.CircuitBreaker(-    CircuitBreakerOptions+-- | Module containing circuit breaker functionality, which is the ability to open a circuit+-- once a number of failures have occurred, thereby preventing later calls from attempting+-- to make unsuccessful calls.+-- Often this is useful if the underlying service were to repeatedly time out, so as to+-- reduce the number of calls inflight holding up upstream callers.+module Glue.CircuitBreaker+  ( CircuitBreakerOptions (..)   , CircuitBreakerState-  , CircuitBreakerException(..)+  , CircuitBreakerException (..)   , combineCircuitBreakerStates   , isCircuitBreakerOpen   , isCircuitBreakerClosed   , defaultCircuitBreakerOptions   , circuitBreaker-  , maxBreakerFailures-  , resetTimeoutSecs-  , breakerDescription-) where+  ) where -import           Control.Exception.Lifted-import           Control.Monad.Base-import           Control.Monad.Trans.Control-import           Data.IORef.Lifted-import           Data.Time.Clock.POSIX-import           Data.Typeable+import           Data.Time.Clock.POSIX (getPOSIXTime) import           Glue.Types+import           UnliftIO              (Exception, MonadIO, MonadUnliftIO,+                                        liftIO)+import           UnliftIO.Exception    (bracketOnError, throwIO)+import           UnliftIO.IORef        (IORef, atomicModifyIORef', newIORef,+                                        readIORef, writeIORef)  -- | Options for determining behaviour of circuit breaking services.-data CircuitBreakerOptions = CircuitBreakerOptions {-    maxBreakerFailures :: Int        -- ^ How many times the underlying service must fail in the given window before the circuit opens.-  , resetTimeoutSecs   :: Int        -- ^ The window of time in which the underlying service must fail for the circuit to open.-  , breakerDescription :: String     -- ^ Description that is attached to the failure so as to identify the particular circuit.-}+data CircuitBreakerOptions = CircuitBreakerOptions+  { maxBreakerFailures :: Int+  -- ^ How many times the underlying service must fail in the given window before the circuit opens.+  , resetTimeoutSecs   :: Int+  -- ^ The window of time in which the underlying service must fail for the circuit to open.+  , breakerDescription :: String+  -- ^ Description that is attached to the failure so as to identify the particular circuit.+  }+  deriving (Eq, Show)  -- | Defaulted options for the circuit breaker with 3 failures over 60 seconds. defaultCircuitBreakerOptions :: CircuitBreakerOptions-defaultCircuitBreakerOptions = CircuitBreakerOptions { maxBreakerFailures = 3, resetTimeoutSecs = 60, breakerDescription = "Circuit breaker open." }+defaultCircuitBreakerOptions =+  CircuitBreakerOptions+    { maxBreakerFailures = 3+    , resetTimeoutSecs   = 60+    , breakerDescription = "Circuit breaker open."+    }  -- | Status indicating if the circuit is open.-data BreakerStatus = BreakerClosed Int | BreakerOpen Int deriving (Eq, Show)+data BreakerStatus+  = BreakerClosed Int+  | BreakerOpen Int+  deriving (Eq, Show)  -- | Representation of the state the circuit breaker is currently in.-data CircuitBreakerState = CircuitBreakerState [IORef BreakerStatus]+newtype CircuitBreakerState = CircuitBreakerState [IORef BreakerStatus]  -- | Exception thrown when the circuit is open.-data CircuitBreakerException = CircuitBreakerException String deriving (Eq, Show, Typeable)+newtype CircuitBreakerException = CircuitBreakerException String+  deriving (Eq, Show)+ instance Exception CircuitBreakerException  -- | Combines multiple states together.-combineCircuitBreakerStates :: (Foldable t) => t CircuitBreakerState -> CircuitBreakerState-combineCircuitBreakerStates states = CircuitBreakerState $ foldMap (\(CircuitBreakerState refs) -> refs) states+combineCircuitBreakerStates :: Foldable t => t CircuitBreakerState -> CircuitBreakerState+combineCircuitBreakerStates = CircuitBreakerState . foldMap (\(CircuitBreakerState refs) -> refs)  -- | Determines if a specific status is open. isStatusOpen :: BreakerStatus -> Bool@@ -62,45 +75,55 @@ isStatusClosed (BreakerClosed _) = True  -- | Determines if a circuit breaker is open.-isCircuitBreakerOpen :: (MonadBaseControl IO m) => CircuitBreakerState -> m Bool-isCircuitBreakerOpen (CircuitBreakerState states) = fmap or $ traverse (\ref -> fmap isStatusOpen $ readIORef ref) states+isCircuitBreakerOpen :: MonadIO m => CircuitBreakerState -> m Bool+isCircuitBreakerOpen (CircuitBreakerState states) =+  or <$> traverse (fmap isStatusOpen . readIORef) states  -- | Determines if a circuit breaker is closed.-isCircuitBreakerClosed :: (MonadBaseControl IO m) => CircuitBreakerState -> m Bool-isCircuitBreakerClosed (CircuitBreakerState states) = fmap and $ traverse (\ref -> fmap isStatusClosed $ readIORef ref) states+isCircuitBreakerClosed :: MonadIO m => CircuitBreakerState -> m Bool+isCircuitBreakerClosed (CircuitBreakerState states) =+  and <$> traverse (fmap isStatusClosed . readIORef) states --- TODO: Check that values within m aren't lost on a successful call. -- | Circuit breaking services can be constructed with this function.-circuitBreaker :: (MonadBaseControl IO m, MonadBaseControl IO n)-               => CircuitBreakerOptions       -- ^ Options for specifying the circuit breaker behaviour.-               -> BasicService m a b          -- ^ Service to protect with the circuit breaker.-               -> n (CircuitBreakerState, BasicService m a b)-circuitBreaker options service =-  let retrieveTime                = liftBase $ round `fmap` getPOSIXTime-      failureMax                  = maxBreakerFailures options-      callIfClosed request ref    = bracketOnError (return ()) (\_ -> incErrors ref) (\_ -> service request)-      canaryCall request ref      = do-                                      result <- callIfClosed request ref-                                      writeIORef ref $ BreakerClosed 0-                                      return result-      incErrors ref               = do-                                      currentTime <- retrieveTime-                                      atomicModifyIORef' ref $ \status -> case status of-                                        (BreakerClosed errorCount) -> (if errorCount >= failureMax then BreakerOpen (currentTime + (resetTimeoutSecs options)) else BreakerClosed (errorCount + 1), ())-                                        other                             -> (other, ())-      failingCall                 = throw $ CircuitBreakerException $ breakerDescription options-      callIfOpen request ref      = do-                                      currentTime <- retrieveTime-                                      canaryRequest <- atomicModifyIORef' ref $ \status -> case status of-                                                              (BreakerClosed _)  -> (status, False)-                                                              (BreakerOpen time) -> if currentTime > time then ((BreakerOpen (currentTime + (resetTimeoutSecs options))), True) else (status, False)-                                      if canaryRequest then canaryCall request ref else failingCall-      breakerService ref request  = do-                                      status <- readIORef ref-                                      case status of-                                        (BreakerClosed _)  -> callIfClosed request ref-                                        (BreakerOpen _)    -> callIfOpen request ref--  in do-        ref <- newIORef $ BreakerClosed 0-        return (CircuitBreakerState [ref], breakerService ref)+circuitBreaker+  :: (MonadUnliftIO m, MonadIO n)+  => CircuitBreakerOptions+  -- ^ Options for specifying the circuit breaker behaviour.+  -> BasicService m a b+  -- ^ Service to protect with the circuit breaker.+  -> n (CircuitBreakerState, BasicService m a b)+circuitBreaker options service = do+  let retrieveTime = liftIO $ round <$> getPOSIXTime+      failureMax = maxBreakerFailures options+      callIfClosed request ref = bracketOnError (pure ()) (\_ -> incErrors ref) (\_ -> service request)+      canaryCall request ref = do+        result <- callIfClosed request ref+        writeIORef ref $ BreakerClosed 0+        pure result+      incErrors ref = do+        currentTime <- retrieveTime+        atomicModifyIORef' ref $ \status -> case status of+          BreakerClosed errorCount ->+            ( if errorCount >= failureMax+                then BreakerOpen (currentTime + resetTimeoutSecs options)+                else BreakerClosed (errorCount + 1)+            , ()+            )+          other -> (other, ())+      failingCall = throwIO $ CircuitBreakerException $ breakerDescription options+      callIfOpen request ref = do+        currentTime <- retrieveTime+        canaryRequest <- atomicModifyIORef' ref $ \status -> case status of+          BreakerClosed _ -> (status, False)+          BreakerOpen time ->+            if currentTime > time+              then (BreakerOpen (currentTime + resetTimeoutSecs options), True)+              else (status, False)+        if canaryRequest then canaryCall request ref else failingCall+      breakerService ref request = do+        status <- readIORef ref+        case status of+          BreakerClosed _ -> callIfClosed request ref+          BreakerOpen _   -> callIfOpen request ref+  ref <- newIORef $ BreakerClosed 0+  pure (CircuitBreakerState [ref], breakerService ref)
+ src/Glue/ConcurrencyLimit.hs view
@@ -0,0 +1,87 @@+{-# 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
src/Glue/DogpileProtection.hs view
@@ -1,57 +1,94 @@ {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE ScopedTypeVariables #-} --- | Module supporting the dogpile protection of a service, see <http://en.wikipedia.org/wiki/Cache_stampede http://en.wikipedia.org/wiki/Cache_stampede>.-module Glue.DogpileProtection(-  dogpileProtect-) where+-- | Module supporting the dogpile protection of a service,+-- see <https://en.wikipedia.org/wiki/Cache_stampede>.+module Glue.DogpileProtection+  ( DogpileOptions (..)+  , defaultDogpileOptions+  , dogpileProtectWith+  , dogpileProtect+  ) where -import           Control.Concurrent-import           Control.Exception-import           Data.Hashable+import           Control.Monad       (void)+import           Data.Hashable       (Hashable) import qualified Data.HashMap.Strict as M-import           Data.IORef import           Glue.Types+import           UnliftIO            (MonadIO, MonadUnliftIO, SomeException,+                                      liftIO)+import           UnliftIO.Concurrent (forkIO, threadDelay)+import           UnliftIO.Exception  (onException, throwIO, toException, tryAny)+import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef)+import           UnliftIO.MVar       (MVar, newEmptyMVar, putMVar, readMVar,+                                      tryPutMVar) -data DogpileResult b = CachedValue (Either SomeException b)-                     | RequestInProgress (IO b)+-- | Options for configuring dogpile protection.+newtype DogpileOptions = DogpileOptions+  { dogpileCacheTimeMs :: Int+  -- ^ How long (in milliseconds) to retain a completed result for subsequent callers.+  }+  deriving (Eq, Show) -type ResultMap a b = M.HashMap a (DogpileResult b)+-- | Default options for dogpile protection, caching completed values for 5 seconds (5000ms).+defaultDogpileOptions :: DogpileOptions+defaultDogpileOptions =+  DogpileOptions+    { dogpileCacheTimeMs = 5000+    } -type ResultRef a b = IORef (ResultMap a b)+data DogpileResult b+  = CachedValue (Either SomeException b)+  | RequestInProgress (MVar (Either SomeException b)) -getDogpileResult :: DogpileResult b -> IO b-getDogpileResult (RequestInProgress requestWait) = requestWait-getDogpileResult (CachedValue result)            = either throw return result+type ResultMap a b = M.HashMap a (DogpileResult b)+type ResultRef a b = IORef (ResultMap a b) -waitForMVar :: MVar (Either SomeException b) -> IO b-waitForMVar mvar = do-  result <- readMVar mvar-  either throw return result+getDogpileResult :: MonadIO m => DogpileResult b -> m b+getDogpileResult (RequestInProgress mvar) = readMVar mvar >>= either throwIO pure+getDogpileResult (CachedValue result)     = either throwIO pure result -getProtectedResult :: (Eq a, Hashable a)-                   => a -> BasicService IO a b -> MVar (Either SomeException b) -> ResultRef a b -> ResultMap a b -> (ResultMap a b, IO b)-getProtectedResult request service mvar mapRef resultMap =-  let mvarWait = waitForMVar mvar-      invokeService = do-        result <- try $ service request-        putMVar mvar result-        atomicModifyIORef' mapRef (\mapToUpdate -> (M.insert request (CachedValue result) mapToUpdate, ()))-        forkIO $ do-          threadDelay (5 * 1000 * 1000)-          atomicModifyIORef' mapRef (\mapToUpdate -> (M.delete request mapToUpdate, ()))-      inCache = M.lookup request resultMap-  in  maybe (M.insert request (RequestInProgress mvarWait) resultMap, invokeService >> mvarWait) (\r -> (resultMap, getDogpileResult r)) inCache+-- | Dogpile protection of a service with configurable options.+dogpileProtectWith+  :: forall m n a b.+     (MonadUnliftIO m, MonadIO n, Hashable a)+  => DogpileOptions+  -- ^ Options configuring dogpile protection.+  -> BasicService m a b+  -- ^ The service to protect.+  -> n (BasicService m a b)+dogpileProtectWith options service = do+  mapRef <- newIORef M.empty :: n (ResultRef a b)+  let ttlMs = dogpileCacheTimeMs options+      protectedService request = do+        newVar <- newEmptyMVar+        actionOrExisting <- atomicModifyIORef' mapRef $ \resultMap ->+          case M.lookup request resultMap of+            Just existing -> (resultMap, Right existing)+            Nothing       -> (M.insert request (RequestInProgress newVar) resultMap, Left newVar)+        case actionOrExisting of+          Right existing -> getDogpileResult existing+          Left mvar -> do+            let cleanupOnAbort = do+                  atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))+                  void $ tryPutMVar mvar (Left (toException (userError "Dogpile leader aborted")))+            result <- tryAny (service request) `onException` cleanupOnAbort+            putMVar mvar result+            if ttlMs > 0+              then do+                atomicModifyIORef' mapRef (\m -> (M.insert request (CachedValue result) m, ()))+                void $ liftIO $ forkIO $ do+                  threadDelay (ttlMs * 1000)+                  atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))+              else+                atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))+            either throwIO pure result+  pure protectedService --- | Dogpile protection of a service, to prevent multiple calls for the same value being submitted.--- | Loses the values held within m.-dogpileProtect :: (Eq a, Hashable a)-               => BasicService IO a b   -- ^ The service to protect.-               -> IO (BasicService IO a b)-dogpileProtect service = do-  mapRef <- newIORef M.empty-  let protectedService request = do-        mvar <- newEmptyMVar-        resultAction <- atomicModifyIORef' mapRef $ getProtectedResult request service mvar mapRef-        resultAction-  return protectedService+-- | Dogpile protection of a service using 'defaultDogpileOptions',+-- preventing multiple concurrent calls for the same value from being submitted.+dogpileProtect+  :: (MonadUnliftIO m, MonadIO n, Hashable a)+  => BasicService m a b+  -- ^ The service to protect.+  -> n (BasicService m a b)+dogpileProtect = dogpileProtectWith defaultDogpileOptions
src/Glue/Failover.hs view
@@ -2,36 +2,45 @@ {-# LANGUAGE ScopedTypeVariables #-}  -- | Module supporting failover of a service by transforming the request each time a failure occurs up to a fixed number of attempts.-module Glue.Failover(-    FailoverOptions+module Glue.Failover+  ( FailoverOptions (..)   , defaultFailoverOptions   , failover-  , maxFailovers-  , transformFailoverRequest-) where+  ) where -import           Control.Exception.Lifted    hiding (throw)-import           Control.Monad.Trans.Control import           Glue.Types+import           UnliftIO           (MonadUnliftIO)+import           UnliftIO.Exception (catchAny)  -- | Options for determining behaviour of failover services.-data FailoverOptions a = FailoverOptions {-    maxFailovers             :: Int      -- ^ The maximum numer of times the service will failover and make another attempt.-  , transformFailoverRequest :: a -> a   -- ^ Each time the service performs a failover, transform the request with this function.-}+data FailoverOptions a = FailoverOptions+  { maxFailovers             :: Int+  -- ^ The maximum number of times the service will failover and make another attempt.+  , transformFailoverRequest :: a -> a+  -- ^ Each time the service performs a failover, transform the request with this function.+  } --- | Simple defaults that results in retrying 3 times in a dumb way immediately without transforming the request.+-- | Simple defaults that result in retrying 3 times immediately without transforming the request. defaultFailoverOptions :: FailoverOptions a-defaultFailoverOptions = FailoverOptions { maxFailovers = 3, transformFailoverRequest = id }+defaultFailoverOptions =+  FailoverOptions+    { maxFailovers             = 3+    , transformFailoverRequest = id+    }  -- | Creates a service that can transform requests with each subsequent failure.-failover :: (MonadBaseControl IO m)-         => FailoverOptions a     -- ^ Instance of 'FailoverOptions' to configure the failover functionality.-         -> BasicService m a b    -- ^ The service to perform failover around.-         -> BasicService m a b+failover+  :: MonadUnliftIO m+  => FailoverOptions a+  -- ^ Instance of t'FailoverOptions' to configure the failover functionality.+  -> BasicService m a b+  -- ^ The service to perform failover around.+  -> BasicService m a b failover options service =   let invokeService failCount request =-                                        let afterFail   = (\(_ :: SomeException) -> invokeService (failCount + 1) ((transformFailoverRequest options) request))-                                            invoke      = service request-                                        in  if failCount >= (maxFailovers options) then invoke else catch invoke afterFail+        let afterFail _ = invokeService (failCount + 1) (transformFailoverRequest options request)+            invoke      = service request+        in  if failCount >= maxFailovers options+              then invoke+              else catchAny invoke afterFail   in  invokeService 0
+ src/Glue/Hedging.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE FlexibleContexts    #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Module supporting request hedging (duplication), dispatching multiple concurrent+-- requests (optionally staggered) and completing with the first successful result.+module Glue.Hedging+  ( HedgingOptions (..)+  , defaultHedgingOptions+  , hedgingService+  ) where++import           Control.Monad       (when)+import           Data.Maybe          (fromMaybe)+import           Glue.Types+import           UnliftIO            (Async, MonadUnliftIO, SomeException,+                                      withAsync)+import           UnliftIO.Concurrent (threadDelay)+import           UnliftIO.Exception  (throwIO, tryAny)+import           UnliftIO.STM        (atomically, newTVarIO, readTVar, retrySTM,+                                      writeTVar)++-- | Options for configuring hedged (duplicated) service calls.+data HedgingOptions = 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).+  }+  deriving (Eq, Show)++-- | Default hedging options that fire 2 concurrent requests with no stagger delay.+defaultHedgingOptions :: HedgingOptions+defaultHedgingOptions =+  HedgingOptions+    { hedgingCount   = 2+    , hedgingDelayMs = 0+    }++withAsyncs :: MonadUnliftIO m => [m a] -> ([Async a] -> m b) -> m b+withAsyncs []     k = k []+withAsyncs (m:ms) k = withAsync m $ \a -> withAsyncs ms (\as -> k (a : as))++-- | 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+  :: forall m a b.+     MonadUnliftIO m+  => HedgingOptions+  -- ^ 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 request+  | hedgingCount options <= 1 = service request+  | otherwise = do+      let total     = hedgingCount options+          delayUs   = max 0 (hedgingDelayMs options) * 1000+      stateVar <- newTVarIO (Nothing :: Maybe b, total, Nothing :: Maybe SomeException)+      let runAttempt :: Int -> m ()+          runAttempt idx = do+            when (idx > 0 && delayUs > 0) $+              threadDelay (idx * delayUs)+            res <- tryAny (service request)+            atomically $ do+              (mWin, remaining, mErr) <- readTVar stateVar+              case res of+                Right val -> writeTVar stateVar (Just (fromMaybe val mWin), remaining - 1, mErr)+                Left err  -> writeTVar stateVar (mWin, remaining - 1, Just (fromMaybe err mErr))+      withAsyncs [runAttempt i | i <- [0 .. total - 1]] $ \_ -> do+        outcome <- atomically $ do+          (mWin, remaining, mErr) <- readTVar stateVar+          case mWin of+            Just val -> pure (Right val)+            Nothing+              | remaining <= 0 -> pure (Left mErr)+              | otherwise      -> retrySTM+        case outcome of+          Right val        -> pure val+          Left (Just err)  -> throwIO err+          Left Nothing     -> service request
+ src/Glue/LoadBalancer.hs view
@@ -0,0 +1,51 @@+{-# LANGUAGE FlexibleContexts    #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Module providing round-robin load balancing across multiple services.+module Glue.LoadBalancer+  ( NoServicesAvailableException (..)+  , roundRobinLoadBalancer+  , roundRobinLoadBalancerNE+  ) where++import           Data.Foldable      (toList)+import           Data.List.NonEmpty (NonEmpty (..))+import qualified Data.List.NonEmpty as NE+import           Glue.Types+import           UnliftIO           (Exception, MonadIO)+import           UnliftIO.Exception (throwIO)+import           UnliftIO.IORef     (atomicModifyIORef', newIORef)++-- | Exception thrown when attempting to call a load balancer configured with an empty set of services.+data NoServicesAvailableException = NoServicesAvailableException+  deriving (Eq, Show)++instance Exception NoServicesAvailableException++-- | Creates a service that distributes requests across a 'NonEmpty' list of backend services in round-robin order.+roundRobinLoadBalancerNE+  :: (MonadIO m, MonadIO n)+  => NonEmpty (BasicService m a b)+  -- ^ Non-empty collection of backend services to balance across.+  -> n (BasicService m a b)+roundRobinLoadBalancerNE services = do+  let svcList = NE.toList services+      count   = length svcList+  indexRef <- newIORef (0 :: Int)+  let balanced request = do+        idx <- atomicModifyIORef' indexRef (\i -> ((i + 1) `mod` count, i))+        let target = svcList !! idx+        target request+  pure balanced++-- | Creates a service that distributes requests across a foldable collection of backend services in round-robin order.+-- If the collection is empty, invoking the returned service throws t'NoServicesAvailableException'.+roundRobinLoadBalancer+  :: (MonadIO m, MonadIO n, Foldable t)+  => t (BasicService m a b)+  -- ^ Collection of backend services to balance across.+  -> n (BasicService m a b)+roundRobinLoadBalancer services =+  case toList services of+    []     -> pure $ const $ throwIO NoServicesAvailableException+    (x:xs) -> roundRobinLoadBalancerNE (x :| xs)
src/Glue/Preload.hs view
@@ -1,109 +1,148 @@ {-# LANGUAGE BangPatterns        #-}-{-# LANGUAGE DeriveDataTypeable  #-} {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE RankNTypes          #-} {-# LANGUAGE RecordWildCards     #-} {-# LANGUAGE ScopedTypeVariables #-}  -- | Module containing a form of caching where values for given keys are preloaded ahead of time.--- | Once warmed up requests for preloaded keys will be instant, with the values refreshed in the background.-module Glue.Preload(-    PreloadedOptions+-- Once warmed up, requests for preloaded keys will be instant, with the values refreshed in the background.+module Glue.Preload+  ( PreloadedOptions (..)+  , PreloadStoppedBeforeExecutedException (..)   , defaultPreloadedOptions   , preloadingService   , preloadingCall-  , preloadedKeys-  , preloadingRefreshTimeMs-  , preloadingRun-) where+  ) where -import           Control.Concurrent.Lifted-import           Control.Exception.Base      hiding (throwIO)-import           Control.Exception.Lifted-import           Control.Monad.IO.Class-import           Control.Monad.Trans.Control-import           Data.Hashable-import qualified Data.HashMap.Strict         as M-import qualified Data.HashSet                as S-import           Data.IORef.Lifted-import           Data.Maybe-import           Data.Typeable+import           Control.Monad       (void)+import           Data.Hashable       (Hashable)+import qualified Data.HashMap.Strict as M+import qualified Data.HashSet        as S+import           Data.Maybe          (fromJust) import           Glue.Types+import           UnliftIO            (Exception, MonadIO, MonadUnliftIO,+                                      SomeException (..), liftIO)+import           UnliftIO.Async      (race_)+import           UnliftIO.Concurrent (forkIO, threadDelay)+import           UnliftIO.Exception  (throwIO)+import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef,+                                      readIORef)+import           UnliftIO.MVar       (MVar, newEmptyMVar, readMVar, tryPutMVar)  -- | Options for determining behaviour of preloading services.-data PreloadedOptions m a b = PreloadedOptions {-  preloadedKeys           :: S.HashSet a,       -- ^ Keys to preload.-  preloadingRefreshTimeMs :: Int,               -- ^ Amount of time between refreshes.-  preloadingRun           :: MToIO m            -- ^ Get an IO of the response for the caching.-}+data PreloadedOptions m a b = PreloadedOptions+  { preloadedKeys           :: S.HashSet a+  -- ^ Keys to preload.+  , preloadingRefreshTimeMs :: Int+  -- ^ Amount of time between refreshes.+  , preloadingRun           :: MToIO m+  -- ^ Get an 'IO' of the response for the caching.+  } --- | Defaulted options for preloading a HashSet of keys with a 30 second refresh time.+-- | Defaulted options for preloading a 'S.HashSet' of keys with a 30 second refresh time. defaultPreloadedOptions :: S.HashSet a -> MToIO m -> PreloadedOptions m a b-defaultPreloadedOptions toPreload pRun = PreloadedOptions {-  preloadedKeys           = toPreload,-  preloadingRefreshTimeMs = 30 * 1000,-  preloadingRun           = pRun-}+defaultPreloadedOptions toPreload pRun =+  PreloadedOptions+    { preloadedKeys           = toPreload+    , preloadingRefreshTimeMs = 30 * 1000+    , preloadingRun           = pRun+    } -data PreloadedState a b = PreloadedStarted-                        | PreloadedWithResult (FailOrSuccess a b) Bool+data PreloadedState a b+  = PreloadedStarted+  | PreloadedWithResult (FailOrSuccess a b) Bool -data PreloadStoppedBeforeExecutedException = PreloadStoppedBeforeExecutedException deriving (Eq, Show, Typeable)+-- | Exception thrown when preloading is stopped before the initial preload has executed.+data PreloadStoppedBeforeExecutedException = PreloadStoppedBeforeExecutedException+  deriving (Eq, Show)+ instance Exception PreloadStoppedBeforeExecutedException -modifyStateWithResult :: forall a b . Either SomeException (MultiGetResponse a b) -> PreloadedState a b -> (PreloadedState a b, Bool)-modifyStateWithResult result PreloadedStarted           = (PreloadedWithResult result True, True)-modifyStateWithResult result (PreloadedWithResult _ c)  = (PreloadedWithResult result c, c)+modifyStateWithResult :: Either SomeException (MultiGetResponse a b) -> PreloadedState a b -> (PreloadedState a b, Bool)+modifyStateWithResult result PreloadedStarted          = (PreloadedWithResult result True, True)+modifyStateWithResult result (PreloadedWithResult _ c) = (PreloadedWithResult result c, c) -applyResultToState :: forall m a b . MonadBaseControl IO m => IORef (PreloadedState a b) -> FailOrSuccess a b -> m Bool-applyResultToState stateRef result = atomicModifyIORef' stateRef $ modifyStateWithResult result+applyResultToState :: MonadIO m => IORef (PreloadedState a b) -> MVar () -> FailOrSuccess a b -> m Bool+applyResultToState stateRef readyVar result = do+  continue <- atomicModifyIORef' stateRef $ modifyStateWithResult result+  void $ tryPutMVar readyVar ()+  pure continue -waitForResult :: forall m a b . (MonadBaseControl IO m) => IORef (PreloadedState a b) -> m (MultiGetResponse a b)-waitForResult stateRef = do-                            state <- readIORef stateRef-                            let tryAgainLater = threadDelay 1000 >> waitForResult stateRef-                            case state of-                                          PreloadedStarted                      -> tryAgainLater-                                          PreloadedWithResult (Right success) _ -> return success-                                          PreloadedWithResult (Left failure)  _ -> throwIO failure+waitForResult :: MonadIO m => IORef (PreloadedState a b) -> MVar () -> m (MultiGetResponse a b)+waitForResult stateRef readyVar = do+  readMVar readyVar+  state <- readIORef stateRef+  case state of+    PreloadedStarted                      -> throwIO PreloadStoppedBeforeExecutedException+    PreloadedWithResult (Right success) _ -> pure success+    PreloadedWithResult (Left failure)  _ -> throwIO failure  markAsFinished :: PreloadedState a b -> (PreloadedState a b, ())-markAsFinished PreloadedStarted = (PreloadedWithResult (Left (SomeException PreloadStoppedBeforeExecutedException)) False, ())+markAsFinished PreloadedStarted          = (PreloadedWithResult (Left (SomeException PreloadStoppedBeforeExecutedException)) False, ()) markAsFinished (PreloadedWithResult r _) = (PreloadedWithResult r False, ())  -- | Preloads the results of calls for given keys.-preloadingService :: forall m n a b . (MonadIO m, MonadIO n, MonadBaseControl IO m, MonadBaseControl IO n, Eq a, Hashable a, Show a)-                  => PreloadedOptions m a b      -- ^ Instance of 'PreloadedOptions' to configure the preloading functionality.-                  -> MultiGetService m a b       -- ^ The service to perform preloading of.-                  -> n (MultiGetService m a b, () -> n ())+preloadingService+  :: forall m n a b.+     (MonadUnliftIO m, MonadIO n, Hashable a)+  => PreloadedOptions m a b+  -- ^ Instance of t'PreloadedOptions' to configure the preloading functionality.+  -> MultiGetService m a b+  -- ^ The service to perform preloading of.+  -> n (MultiGetService m a b, () -> n ()) preloadingService PreloadedOptions{..} service = do   !stateIORef <- newIORef PreloadedStarted-  let stop _ = atomicModifyIORef' stateIORef markAsFinished+  !readyVar   <- newEmptyMVar+  !stopVar    <- newEmptyMVar+  let stop _ = do+        atomicModifyIORef' stateIORef markAsFinished+        void $ tryPutMVar readyVar ()+        void $ tryPutMVar stopVar ()   let runUpdate = do-                    result <- makeCall service preloadedKeys-                    applyResultToState stateIORef result+        result <- makeCall service preloadedKeys+        applyResultToState stateIORef readyVar result   let updatePreloaded = do-                          continue <- liftIO $ preloadingRun $ runUpdate-                          if continue then threadDelay (preloadingRefreshTimeMs * 1000) >> updatePreloaded else return ()+        continue <- liftIO $ preloadingRun runUpdate+        if continue+          then do+            stopped <- liftIO $ raceWait stopVar (preloadingRefreshTimeMs * 1000)+            if stopped then pure () else updatePreloaded+          else pure ()   let plService request = do-                            let fromPreloadKeys = S.intersection request preloadedKeys-                            let fromServiceKeys = S.difference request preloadedKeys-                            !fromPreload <- if S.null fromPreloadKeys then return M.empty else fmap (M.filterWithKey (\k -> \_ -> S.member k fromPreloadKeys)) $ waitForResult stateIORef-                            !fromService <- if S.null fromServiceKeys then return M.empty else service fromServiceKeys-                            return $ M.union fromService fromPreload-  _ <- fork updatePreloaded-  return (plService, stop)+        let fromPreloadKeys = S.intersection request preloadedKeys+        let fromServiceKeys = S.difference request preloadedKeys+        !fromPreload <-+          if S.null fromPreloadKeys+            then pure M.empty+            else M.filterWithKey (\k _ -> S.member k fromPreloadKeys) <$> waitForResult stateIORef readyVar+        !fromService <-+          if S.null fromServiceKeys+            then pure M.empty+            else service fromServiceKeys+        pure $ M.union fromService fromPreload+  void $ liftIO $ forkIO updatePreloaded+  pure (plService, stop)+  where+    raceWait :: MVar () -> Int -> IO Bool+    raceWait stopVar delayUs = do+      race_ (readMVar stopVar) (threadDelay delayUs)+      not <$> tryPutMVar stopVar ()  -- | Preloads a single parameter-less call.-preloadingCall :: forall m n b . (MonadIO m, MonadIO n, MonadBaseControl IO m, MonadBaseControl IO n)-               => MToIO m                     -- ^ Get an IO of the call for the caching.-               -> Int                         -- ^ Amount of time between refreshes.-               -> m b                         -- ^ The call to perform preloading of.-               -> n (m b, () -> n ())+preloadingCall+  :: forall m n b.+     (MonadUnliftIO m, MonadIO n)+  => MToIO m+  -- ^ Get an 'IO' of the call for the caching.+  -> Int+  -- ^ Amount of time between refreshes.+  -> m b+  -- ^ The call to perform preloading of.+  -> n (m b, () -> n ()) preloadingCall toIO timeBetweenRefreshes call = do   let multiGetService = basicToMultiGet (\_ -> call) :: MultiGetService m () b   let options = PreloadedOptions (S.singleton ()) timeBetweenRefreshes toIO   (preloading, stop) <- preloadingService options multiGetService   let preloadingBasicService = multiGetToBasic preloading-  let preloadingCallResult = fmap (fromJust) $ preloadingBasicService ()-  return (preloadingCallResult, stop)+  let preloadingCallResult = fromJust <$> preloadingBasicService ()+  pure (preloadingCallResult, stop)
src/Glue/Retry.hs view
@@ -1,56 +1,72 @@-{-# LANGUAGE DeriveDataTypeable  #-} {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE ScopedTypeVariables #-}  -- | Module containing retry functionality, allowing the construction of services that attempt multiple times in case of transient failure.-module Glue.Retry(-    RetryOptions+module Glue.Retry+  ( RetryOptions (..)   , defaultRetryOptions   , retryingService-  , retryAllowed-  , retryInitialWaitTimeMs-  , maximumRetries-  , retryWaitTimeMultiplier-) where+  ) where -import           Control.Concurrent.Lifted-import           Control.Exception.Lifted-import           Control.Monad.Trans.Control+import           Control.Monad       (when) import           Glue.Types+import           UnliftIO            (MonadUnliftIO, SomeAsyncException,+                                      SomeException, fromException)+import           UnliftIO.Concurrent (threadDelay)+import           UnliftIO.Exception  (catch, throwIO)  -- | Options for determining behaviour of retrying services.-data RetryOptions a = RetryOptions {-  retryAllowed            :: a -> Bool,   -- ^ Predicate for determining if we can retry a call, can be used to prevent retries on non-idempotent operations.-  retryInitialWaitTimeMs  :: Int,         -- ^ Amount of time to wait after the first failure.-  maximumRetries          :: Int,         -- ^ The upper bound on how many attempts to make when invoking the service.-  retryWaitTimeMultiplier :: Double       -- ^ How much to multiply 'retryInitialWaitTimeMs' by for each number of times the service has retried.-}+data RetryOptions a = RetryOptions+  { retryAllowed            :: a -> Bool+  -- ^ Predicate for determining if we can retry a call, can be used to prevent retries on non-idempotent operations.+  , retryInitialWaitTimeMs  :: Int+  -- ^ Amount of time (in milliseconds) to wait after the first failure.+  , maximumRetries          :: Int+  -- ^ The upper bound on how many retry attempts to make when invoking the service.+  , retryWaitTimeMultiplier :: Double+  -- ^ How much to multiply 'retryInitialWaitTimeMs' by for each subsequent retry.+  }  -- | Defaulted options for retrying 3 times with no wait time. defaultRetryOptions :: RetryOptions a-defaultRetryOptions = RetryOptions {-    retryAllowed            = (\_ -> True)-  , retryInitialWaitTimeMs  = 0-  , maximumRetries          = 3-  , retryWaitTimeMultiplier = 0-  }+defaultRetryOptions =+  RetryOptions+    { retryAllowed            = const True+    , retryInitialWaitTimeMs  = 0+    , maximumRetries          = 3+    , retryWaitTimeMultiplier = 0+    }  possibleAsyncException :: SomeException -> Maybe SomeAsyncException possibleAsyncException = fromException  -- | Retries a call to a service multiple times, potentially backing off wait times between subsequent calls.--- | Asynchronous exceptions don't result in a retry, they are immediately rethrown.-retryingService :: (MonadBaseControl IO m)-                => RetryOptions a             -- ^ Instance of 'RetryOptions' to configure the retry functionality.-                -> BasicService m a b         -- ^ The service to perform retries of.-                -> BasicService m a b+-- Asynchronous exceptions don't result in a retry; they are immediately rethrown.+retryingService+  :: forall m a b.+     MonadUnliftIO m+  => RetryOptions a+  -- ^ Instance of t'RetryOptions' to configure the retry functionality.+  -> BasicService m a b+  -- ^ The service to perform retries of.+  -> BasicService m a b retryingService options service =-  let catchHandler rc r e         = case possibleAsyncException e of-                                                                    Just ae -> throw ae-                                                                    Nothing -> wait (rc + 1) >> attempt (rc + 1) r-      attempt retryCount request  = if (retryAllowed options) request && maxRetries > retryCount-                                    then catch (service request) (catchHandler retryCount request)-                                    else service request-      maxRetries                  = maximumRetries options-      wait retryCount             = threadDelay $ round $ fromIntegral (retryInitialWaitTimeMs options) * ((retryWaitTimeMultiplier options) ^ retryCount)+  let catchHandler rc r e =+        case possibleAsyncException e of+          Just ae -> throwIO ae+          Nothing -> wait rc >> attempt (rc + 1) r+      attempt retryCount request =+        if retryAllowed options request && maxRetries > retryCount+          then catch (service request) (catchHandler retryCount request)+          else service request+      maxRetries = maximumRetries options+      initialUs  = retryInitialWaitTimeMs options * 1000+      multiplier =+        if retryWaitTimeMultiplier options <= 0+          then 1.0+          else retryWaitTimeMultiplier options+      wait :: Int -> m ()+      wait retryCount =+        when (initialUs > 0) $+          threadDelay $ round $ fromIntegral initialUs * (multiplier ^ retryCount)   in  attempt 0
+ src/Glue/Shutdown.hs view
@@ -0,0 +1,128 @@+{-# LANGUAGE FlexibleContexts    #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Module supporting graceful service shutdown, rejecting new requests with either+-- a fallback value or t'ServiceShuttingDownException', waiting for in-flight requests+-- to drain within a configurable grace period, and cancelling any remaining calls if+-- the grace period expires.+module Glue.Shutdown+  ( ShutdownOptions (..)+  , ServiceShuttingDownException (..)+  , ShutdownHandle (..)+  , defaultShutdownOptions+  , gracefulShutdown+  ) where++import           Glue.Types+import           UnliftIO           (Exception, MonadIO, MonadUnliftIO)+import           UnliftIO.Async     (race)+import           UnliftIO.Exception (finally, throwIO)+import           UnliftIO.STM       (atomically, modifyTVar', newTVarIO,+                                     readTVar, retrySTM, writeTVar)+import           UnliftIO.Timeout   (timeout)++-- | Options for configuring graceful service shutdown.+data ShutdownOptions b = ShutdownOptions+  { shutdownFallback      :: Maybe b+  -- ^ Optional default value returned for rejected requests after shutdown is initiated.+  -- If 'Nothing', t'ServiceShuttingDownException' is thrown instead.+  , shutdownGracePeriodMs :: Int+  -- ^ Maximum time in milliseconds to wait for existing in-flight requests to complete before cancelling them.+  , shutdownDescription   :: String+  -- ^ Description attached to t'ServiceShuttingDownException'.+  }+  deriving (Eq, Show)++-- | Default shutdown options: throws t'ServiceShuttingDownException' on rejected calls and waits up to 5 seconds for in-flight calls.+defaultShutdownOptions :: ShutdownOptions b+defaultShutdownOptions =+  ShutdownOptions+    { shutdownFallback      = Nothing+    , shutdownGracePeriodMs = 5000+    , shutdownDescription   = "Service is shutting down."+    }++-- | Exception thrown when a service is shutting down and no 'shutdownFallback' is configured.+newtype ServiceShuttingDownException = ServiceShuttingDownException String+  deriving (Eq, Show)++instance Exception ServiceShuttingDownException++-- | Handle used to control and inspect a service wrapped with 'gracefulShutdown'.+data ShutdownHandle m = ShutdownHandle+  { initiateShutdown :: m Bool+  -- ^ Initiates shutdown: immediately rejects new requests, waits up to 'shutdownGracePeriodMs'+  -- for in-flight requests to finish, and cancels any remaining calls if the grace period expires.+  -- Returns 'True' if all in-flight calls drained cleanly within the grace period, or 'False' if forced cancellation occurred.+  , isShuttingDown   :: m Bool+  -- ^ Returns 'True' if shutdown has been initiated.+  , inFlightCount    :: m Int+  -- ^ Returns the current number of in-flight requests executing the service.+  }++-- | Wraps a service with graceful shutdown support.+gracefulShutdown+  :: forall m n a b.+     (MonadUnliftIO m, MonadIO n)+  => ShutdownOptions b+  -- ^ Options configuring fallback behaviour and grace period.+  -> BasicService m a b+  -- ^ Service to wrap.+  -> n (BasicService m a b, ShutdownHandle m)+gracefulShutdown options service = do+  shuttingDownVar <- newTVarIO False+  forceCancelVar  <- newTVarIO False+  inFlightVar     <- newTVarIO (0 :: Int)++  let rejectRequest :: m b+      rejectRequest =+        case shutdownFallback options of+          Just fallback -> pure fallback+          Nothing       -> throwIO $ ServiceShuttingDownException (shutdownDescription options)++      waitForForceCancel :: m ()+      waitForForceCancel = atomically $ do+        cancelled <- readTVar forceCancelVar+        if cancelled then pure () else retrySTM++      waitDrained :: m ()+      waitDrained = atomically $ do+        active <- readTVar inFlightVar+        if active <= 0 then pure () else retrySTM++      wrappedService :: BasicService m a b+      wrappedService request = do+        accepted <- atomically $ do+          shuttingDown <- readTVar shuttingDownVar+          if shuttingDown+            then pure False+            else do+              modifyTVar' inFlightVar (+ 1)+              pure True+        if not accepted+          then rejectRequest+          else do+            res <-+              race waitForForceCancel (service request)+                `finally` atomically (modifyTVar' inFlightVar (subtract 1))+            case res of+              Right val -> pure val+              Left ()   -> rejectRequest++      handle =+        ShutdownHandle+          { initiateShutdown = do+              atomically $ writeTVar shuttingDownVar True+              let graceUs = max 0 (shutdownGracePeriodMs options) * 1000+              drained <- timeout graceUs waitDrained+              case drained of+                Just () -> pure True+                Nothing -> do+                  atomically $ writeTVar forceCancelVar True+                  waitDrained+                  pure False+          , isShuttingDown = atomically $ readTVar shuttingDownVar+          , inFlightCount  = atomically $ readTVar inFlightVar+          }++  pure (wrappedService, handle)
src/Glue/Switching.hs view
@@ -1,26 +1,28 @@ {-# LANGUAGE BangPatterns        #-}-{-# LANGUAGE DeriveDataTypeable  #-} {-# LANGUAGE FlexibleContexts    #-}-{-# LANGUAGE RecordWildCards     #-} {-# LANGUAGE ScopedTypeVariables #-}  -- | Module containing a switchable service, allowing the possibility of changing--- | a service "live".-module Glue.Switching(-    switchingService-) where+-- a service "live".+module Glue.Switching+  ( switchingService+  ) where -import           Control.Monad.Base-import           Data.IORef.Lifted import           Glue.Types+import           UnliftIO       (MonadIO)+import           UnliftIO.IORef (newIORef, readIORef, writeIORef)  -- | Provides a switchable service.-switchingService :: (MonadBase IO m, MonadBase IO n) => MultiGetService m a b  -- ^ The service to initialise the switching service with.-                 -> n (MultiGetService m a b, (MultiGetService m a b) -> n ())+-- Works for both 'BasicService' and 'MultiGetService' (since 'MultiGetService' is a 'BasicService').+switchingService+  :: (MonadIO m, MonadIO n)+  => BasicService m a b+  -- ^ The service to initialise the switching service with.+  -> n (BasicService m a b, BasicService m a b -> n ()) switchingService service = do   !serviceRef <- newIORef service   let switching rs = do-                        serviceToUse <- readIORef serviceRef-                        serviceToUse rs+        serviceToUse <- readIORef serviceRef+        serviceToUse rs   let switchingUpdate newService = writeIORef serviceRef newService-  return (switching, switchingUpdate)+  pure (switching, switchingUpdate)
src/Glue/Timeout.hs view
@@ -1,45 +1,50 @@-{-# LANGUAGE DeriveDataTypeable  #-} {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE ScopedTypeVariables #-}  -- | Module supporting adding timeouts to a given service.-module Glue.Timeout(-    TimeoutOptions-  , TimeoutException(..)+module Glue.Timeout+  ( TimeoutOptions (..)+  , TimeoutException (..)   , defaultTimeoutOptions   , addTimeout-  , timeoutDescription-  , timeoutLimitMs-) where+  ) where -import           Control.Concurrent.Lifted-import           Control.Exception.Lifted-import           Control.Monad.Trans.Control-import           Data.Typeable import           Glue.Types+import           UnliftIO           (Exception, MonadUnliftIO)+import           UnliftIO.Exception (throwIO)+import           UnliftIO.Timeout   (timeout)  -- | Options for determining behaviour of services with a timeout.-data TimeoutOptions = TimeoutOptions {-    timeoutDescription :: String       -- ^ Description added to the 'TimeoutException' thrown when the timeout is exceeded.-  , timeoutLimitMs     :: Int          -- ^ Timeout in milliseconds.-}+data TimeoutOptions = TimeoutOptions+  { timeoutDescription :: String+  -- ^ Description added to the t'TimeoutException' thrown when the timeout is exceeded.+  , timeoutLimitMs     :: Int+  -- ^ Timeout in milliseconds.+  }+  deriving (Eq, Show) --- | Default instance of 'TimeoutOptions' with a timeout of 30 seconds.+-- | Default instance of t'TimeoutOptions' with a timeout of 30 seconds. defaultTimeoutOptions :: TimeoutOptions-defaultTimeoutOptions = TimeoutOptions { timeoutDescription = "Service call timed out.", timeoutLimitMs = 30000 }+defaultTimeoutOptions =+  TimeoutOptions+    { timeoutDescription = "Service call timed out."+    , timeoutLimitMs     = 30000+    }  -- | Exception thrown when the timeout is exceeded.-data TimeoutException = TimeoutException String deriving (Eq, Show, Typeable)+newtype TimeoutException = TimeoutException String+  deriving (Eq, Show)+ instance Exception TimeoutException  -- | Function for producing services protected with a timeout.-addTimeout :: (MonadBaseControl IO m)-           => TimeoutOptions        -- ^ Options to configure the timeout.-           -> BasicService m a b    -- ^ Service to protect with a timeout.-           -> BasicService m a b-addTimeout options service = (\request -> do-  currentThreadId <- myThreadId-  timeoutThreadId <- fork $ do-                              threadDelay (1000 * timeoutLimitMs options)-                              throwTo currentThreadId (TimeoutException $ timeoutDescription options)-  finally (service request) (killThread timeoutThreadId))+addTimeout+  :: MonadUnliftIO m+  => TimeoutOptions+  -- ^ Options to configure the timeout.+  -> BasicService m a b+  -- ^ Service to protect with a timeout.+  -> BasicService m a b+addTimeout options service request = do+  maybeResult <- timeout (1000 * timeoutLimitMs options) (service request)+  maybe (throwIO $ TimeoutException $ timeoutDescription options) pure maybeResult
test/Glue/BatcherSpec.hs view
@@ -1,10 +1,8 @@-{-# LANGUAGE DeriveDataTypeable  #-}-{-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} -module Glue.BatcherSpec where+module Glue.BatcherSpec (spec) where -import           Control.Concurrent.Async+import           Control.Concurrent.Async (mapConcurrently) import qualified Data.HashMap.Strict      as M import qualified Data.HashSet             as S import           Glue.Batcher@@ -15,12 +13,19 @@ spec :: Spec spec = do   describe "batchingService" $ do-    it "Requests should receive their full results" $ do+    it "Multi-get requests should receive their full results" $       property $ \(requests :: [[Int]]) -> do-        let requestsAsSets = fmap (S.fromList) requests-        let serviceFunctionality rs = M.fromList $ fmap (\r -> (r, r * 2)) $ S.toList rs-        let service = (return . serviceFunctionality) :: MultiGetService IO Int Int-        (_, multiService) <- batchingService defaultBatchingOptions service+        let requestsAsSets = fmap S.fromList requests+            serviceFunctionality rs = M.fromList $ fmap (\r -> (r, r * 2)) $ S.toList rs+            service = (pure . serviceFunctionality) :: MultiGetService IO Int Int+        (_, multiService) <- batchingService (BatchingOptions 2) service         let expectedResults = fmap serviceFunctionality requestsAsSets-        (mapConcurrently multiService requestsAsSets) `shouldReturn` expectedResults+        mapConcurrently multiService requestsAsSets `shouldReturn` expectedResults +    it "Single-key requests should receive their individual lookup results" $+      property $ \(requests :: [Int]) -> do+        let serviceFunctionality rs = M.fromList $ fmap (\r -> (r, r * 2)) $ S.toList rs+            service = (pure . serviceFunctionality) :: MultiGetService IO Int Int+        (singleService, _) <- batchingService (BatchingOptions 2) service+        let expectedResults = fmap (\r -> Just (r * 2)) requests+        mapConcurrently singleService requests `shouldReturn` expectedResults
test/Glue/CachingSpec.hs view
@@ -1,22 +1,20 @@-{-# LANGUAGE DeriveDataTypeable  #-}-{-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} -module Glue.CachingSpec where+module Glue.CachingSpec (spec) where -import           Control.Exception.Base    hiding (throw, throwIO)-import           Control.Exception.Lifted import qualified Data.HashMap.Strict       as M import qualified Data.HashSet              as S import           Data.IORef-import           Data.Typeable import           Glue.Caching import           Glue.Types import           Test.Hspec import           Test.QuickCheck import           Test.QuickCheck.Instances ()+import           UnliftIO.Exception        (Exception, throwIO) -data CachingTestException = CachingTestException Int deriving (Eq, Show, Typeable)+newtype CachingTestException = CachingTestException Int+  deriving (Eq, Show)+ instance Exception CachingTestException  type TestResp = MultiGetResponse Int Int@@ -24,35 +22,35 @@ spec :: Spec spec = do   describe "cacheWithBasic" $ do-    it "For a second request, the value comes from the cache" $ do+    it "For a second request, the value comes from the cache" $       property $ \(request :: Int, result :: Int) -> do         ref <- newIORef (0 :: Int)         let service req = do-                            if req == request then return () else throwIO (CachingTestException (-1))-                            callCount <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))-                            if callCount > 1 then throwIO (CachingTestException callCount) else return result+              if req == request then pure () else throwIO (CachingTestException (-1))+              callCount <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+              if callCount > 1 then throwIO (CachingTestException callCount) else pure result         cache <- newIORef M.empty-        let lookupWith r = fmap (M.lookup r) $ readIORef cache+        let lookupWith r = M.lookup r <$> readIORef cache         let insertWith req resp = atomicModifyIORef' cache (\c -> (M.insert req resp c, ()))         let cachedService = cacheWithBasic lookupWith insertWith service-        (cachedService request) `shouldReturn` result-        (cachedService request) `shouldReturn` result+        cachedService request `shouldReturn` result+        cachedService request `shouldReturn` result   describe "cacheWithMulti" $ do-    it "For a second request, the value comes from the cache" $ do+    it "For a second request, the value comes from the cache" $       property $ \(result :: TestResp) -> do         let request = S.fromList $ M.keys result         ref <- newIORef (0 :: Int)         let service req = do-                            if req == request then return () else throwIO (CachingTestException (-1))-                            callCount <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))-                            if callCount > 1 then throwIO (CachingTestException callCount) else return result+              if req == request then pure () else throwIO (CachingTestException (-1))+              callCount <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+              if callCount > 1 then throwIO (CachingTestException callCount) else pure result         cache <- newIORef M.empty-        let lookupWith rs = fmap (M.filterWithKey (\k -> \_ -> S.member k rs)) $ readIORef cache+        let lookupWith rs = M.filterWithKey (\k _ -> S.member k rs) <$> readIORef cache         let insertWith resp = atomicModifyIORef' cache (\c -> (M.union resp c, ()))         let cachedService = cacheWithMulti lookupWith insertWith service-        (cachedService request) `shouldReturn` result-        (cachedService request) `shouldReturn` result-    it "Merges the cached values as appropriate from subsequent requests" $ do+        cachedService request `shouldReturn` result+        cachedService request `shouldReturn` result+    it "Merges the cached values as appropriate from subsequent requests" $       property $ \(first :: TestResp, second :: TestResp, both :: TestResp) -> do         let uniqueFirst = M.difference first (M.union second both)         let uniqueSecond = M.difference second (M.union first both)@@ -64,15 +62,15 @@         let secondRequest = S.fromList $ M.keys secondResult         ref <- newIORef (0 :: Int)         let service req = do-                            callCount <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))-                            if callCount > 2 then throwIO (CachingTestException callCount) else return $ M.filterWithKey (\k -> \_ -> S.member k req) uniqueAll+              callCount <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+              if callCount > 2+                then throwIO (CachingTestException callCount)+                else pure $ M.filterWithKey (\k _ -> S.member k req) uniqueAll         cache <- newIORef M.empty-        let lookupWith rs = fmap (M.filterWithKey (\k -> \_ -> S.member k rs)) $ readIORef cache+        let lookupWith rs = M.filterWithKey (\k _ -> S.member k rs) <$> readIORef cache         let insertWith resp = atomicModifyIORef' cache (\c -> (M.union resp c, ()))         let cachedService = cacheWithMulti lookupWith insertWith service-        (cachedService firstRequest) `shouldReturn` firstResult-        (cachedService secondRequest) `shouldReturn` secondResult-        (cachedService firstRequest) `shouldReturn` firstResult-        (cachedService secondRequest) `shouldReturn` secondResult--+        cachedService firstRequest `shouldReturn` firstResult+        cachedService secondRequest `shouldReturn` secondResult+        cachedService firstRequest `shouldReturn` firstResult+        cachedService secondRequest `shouldReturn` secondResult
test/Glue/CircuitBreakerSpec.hs view
@@ -1,47 +1,58 @@-{-# LANGUAGE DeriveDataTypeable  #-}-{-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} -module Glue.CircuitBreakerSpec where+module Glue.CircuitBreakerSpec (spec) where -import           Control.Exception.Base   hiding (throw, throwIO, try)-import           Control.Exception.Lifted import           Data.IORef-import           Data.Typeable import           Glue.CircuitBreaker import           Test.Hspec import           Test.QuickCheck+import           UnliftIO.Exception  (Exception, throwIO, try) -data CircuitBreakerTestException = CircuitBreakerTestException deriving (Eq, Show, Typeable)+data CircuitBreakerTestException = CircuitBreakerTestException+  deriving (Eq, Show)+ instance Exception CircuitBreakerTestException  requests :: [Int]-requests = [1..10]+requests = [1 .. 10]  spec :: Spec spec = do   describe "circuitBreaker" $ do-    it "Multiple failures prevent subsequent calls" $ do+    it "Multiple failures prevent subsequent calls" $       property $ \failureMax -> do-        let positiveFailureMax      = (abs failureMax) `mod` 5-        let options                 = defaultCircuitBreakerOptions { maxBreakerFailures = positiveFailureMax }-        ref                         <- newIORef (0 :: Int)-        let service _               = atomicModifyIORef' ref (\c -> (c + 1, ())) >> throwIO CircuitBreakerTestException :: IO Int-        (s, circuitBreakerService)  <- circuitBreaker options service-        results                     <- traverse (\req -> try $ try $ circuitBreakerService req) requests :: IO [Either CircuitBreakerException (Either CircuitBreakerTestException Int)]-        let expectedResults         = (replicate (positiveFailureMax + 1) (Right $ Left $ CircuitBreakerTestException)) ++ (replicate (10 - positiveFailureMax - 1) (Left $ CircuitBreakerException "Circuit breaker open."))+        let positiveFailureMax     = abs failureMax `mod` 5+            options                = defaultCircuitBreakerOptions { maxBreakerFailures = positiveFailureMax }+        ref                        <- newIORef (0 :: Int)+        let service _              = atomicModifyIORef' ref (\c -> (c + 1, ())) >> throwIO CircuitBreakerTestException :: IO Int+        (s, circuitBreakerService) <- circuitBreaker options service+        results                    <- traverse (try . try . circuitBreakerService) requests :: IO [Either CircuitBreakerException (Either CircuitBreakerTestException Int)]+        let expectedResults        = replicate (positiveFailureMax + 1) (Right $ Left CircuitBreakerTestException)+                                  ++ replicate (10 - positiveFailureMax - 1) (Left $ CircuitBreakerException "Circuit breaker open.")         results `shouldBe` expectedResults-        (isCircuitBreakerOpen s) `shouldReturn` True-        (isCircuitBreakerClosed s) `shouldReturn` False-        (readIORef ref) `shouldReturn` (positiveFailureMax + 1)-    it "Successful calls pass straight through" $ do+        isCircuitBreakerOpen s `shouldReturn` True+        isCircuitBreakerClosed s `shouldReturn` False+        readIORef ref `shouldReturn` (positiveFailureMax + 1)++    it "Successful calls pass straight through" $       property $ \(failureMax :: Int, requestIDs :: [Int]) -> do-        let positiveFailureMax      = abs failureMax-        let options                 = defaultCircuitBreakerOptions { maxBreakerFailures = positiveFailureMax }-        let service req             = return $ req + 1-        (s, circuitBreakerService)  <- circuitBreaker options service-        results                     <- traverse (\req -> circuitBreakerService req) requestIDs-        let expectedResults         = fmap (+ 1) requestIDs+        let positiveFailureMax     = abs failureMax+            options                = defaultCircuitBreakerOptions { maxBreakerFailures = positiveFailureMax }+            service req            = pure $ req + 1+        (s, circuitBreakerService) <- circuitBreaker options service+        results                    <- traverse circuitBreakerService requestIDs+        let expectedResults        = fmap (+ 1) requestIDs         results `shouldBe` expectedResults-        (isCircuitBreakerOpen s) `shouldReturn` False-        (isCircuitBreakerClosed s) `shouldReturn` True+        isCircuitBreakerOpen s `shouldReturn` False+        isCircuitBreakerClosed s `shouldReturn` True++    it "combineCircuitBreakerStates reports open if any breaker is open" $ do+      let opts = defaultCircuitBreakerOptions { maxBreakerFailures = 0 }+      (s1, svc1) <- circuitBreaker opts (\(_ :: Int) -> throwIO CircuitBreakerTestException :: IO Int)+      (s2, _)    <- circuitBreaker opts (\(x :: Int) -> pure (x + 1) :: IO Int)+      let combined = combineCircuitBreakerStates [s1, s2]+      isCircuitBreakerOpen combined `shouldReturn` False+      isCircuitBreakerClosed combined `shouldReturn` True+      _ <- try (svc1 1) :: IO (Either CircuitBreakerTestException Int)+      isCircuitBreakerOpen combined `shouldReturn` True+      isCircuitBreakerClosed combined `shouldReturn` False
+ test/Glue/ConcurrencyLimitSpec.hs view
@@ -0,0 +1,65 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Glue.ConcurrencyLimitSpec (spec) where++import           Control.Concurrent       (threadDelay)+import           Control.Concurrent.Async (async, mapConcurrently, wait)+import           Data.IORef+import           Glue.ConcurrencyLimit+import           Test.Hspec+import           UnliftIO.Exception       (Exception, throwIO, try)++data ConcurrencyTestException = ConcurrencyTestException+  deriving (Eq, Show)++instance Exception ConcurrencyTestException++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"+            }+          service (req :: Int) = do+            threadDelay (60 * 1000)+            pure req+      limited <- limitConcurrency opts service+      a1 <- async (try (limited 1) :: IO (Either ConcurrencyLimitExceededException Int))+      threadDelay (10 * 1000)+      a2 <- async (try (limited 2) :: IO (Either ConcurrencyLimitExceededException Int))+      threadDelay (10 * 1000)+      -- 3rd request should be rejected immediately since 1 is in-flight and 1 is queued+      r3 <- try (limited 3) :: IO (Either ConcurrencyLimitExceededException Int)+      r1 <- wait a1+      r2 <- wait a2+      r3 `shouldBe` Left (ConcurrencyLimitExceededException "Busy")+      r1 `shouldBe` Right 1+      r2 `shouldBe` Right 2++    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
test/Glue/DogpileProtectionSpec.hs view
@@ -1,47 +1,46 @@-{-# LANGUAGE DeriveDataTypeable  #-}-{-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} -module Glue.DogpileProtectionSpec where+module Glue.DogpileProtectionSpec (spec) where -import           Control.Concurrent-import           Control.Concurrent.Async-import           Control.Exception.Base   hiding (throw, throwIO)-import           Control.Exception.Lifted+import           Control.Concurrent       (threadDelay)+import           Control.Concurrent.Async (async, wait) import           Data.IORef-import           Data.Traversable-import           Data.Typeable import           Glue.DogpileProtection-import           Prelude                  hiding (sequence) import           Test.Hspec+import           UnliftIO.Exception       (Exception, throwIO) -data DogpileProtectionTestException = DogpileProtectionTestException deriving (Eq, Show, Typeable)+data DogpileProtectionTestException = DogpileProtectionTestException+  deriving (Eq, Show)+ instance Exception DogpileProtectionTestException  requests :: [Int]-requests = take 10 $ repeat 1+requests = replicate 10 1  delayTime :: Int-delayTime = 1000 * 1000+delayTime = 50 * 1000  spec :: Spec spec = do   describe "dogpileProtect" $ do     it "With multiple calls to a slow service only one actually gets through" $ do       counter <- newIORef (0 :: Int)-      let service request = atomicModifyIORef' counter (\n -> (n + 1, ())) >> threadDelay delayTime >> return (request * 2)+      let service request = do+            atomicModifyIORef' counter (\n -> (n + 1, ()))+            threadDelay delayTime+            pure (request * 2)       protectedService <- dogpileProtect service       asyncResults <- traverse (async . protectedService) requests-      let results = traverse wait asyncResults-      results `shouldReturn` (fmap (*2) requests)-      (readIORef counter) `shouldReturn` 1+      traverse wait asyncResults `shouldReturn` fmap (* 2) requests+      readIORef counter `shouldReturn` 1+     it "With multiple calls to a slow failing service only one actually gets through" $ do       counter <- newIORef (0 :: Int)-      let service _ = atomicModifyIORef' counter (\n -> (n + 1, ())) >> threadDelay delayTime >> throwIO DogpileProtectionTestException :: IO Int+      let service _ = do+            atomicModifyIORef' counter (\n -> (n + 1, ()))+            threadDelay delayTime+            throwIO DogpileProtectionTestException :: IO Int       protectedService <- dogpileProtect service       asyncResults <- traverse (async . protectedService) requests-      let results = traverse wait asyncResults-      results `shouldThrow` (== DogpileProtectionTestException)-      (readIORef counter) `shouldReturn` 1--+      traverse wait asyncResults `shouldThrow` (== DogpileProtectionTestException)+      readIORef counter `shouldReturn` 1
test/Glue/FailoverSpec.hs view
@@ -1,28 +1,26 @@-{-# LANGUAGE DeriveDataTypeable #-}-{-# LANGUAGE FlexibleContexts   #-}-{-# LANGUAGE OverloadedStrings  #-}+{-# LANGUAGE FlexibleContexts #-} -module Glue.FailoverSpec where+module Glue.FailoverSpec (spec) where -import           Control.Exception.Base   hiding (throw, throwIO)-import           Control.Exception.Lifted-import           Data.Typeable import           Glue.Failover import           Test.Hspec import           Test.QuickCheck+import           UnliftIO.Exception (Exception, throwIO) -data FailoverTestException = FailoverTestException deriving (Eq, Show, Typeable)+data FailoverTestException = FailoverTestException+  deriving (Eq, Show)+ instance Exception FailoverTestException  spec :: Spec spec = do   describe "failover" $ do-    it "Failover handles potentially multiple errors" $ do+    it "Failover handles potentially multiple errors" $       property $ \(request, failureMax) ->-        let positiveFailureMax  = (abs failureMax) `mod` 10-            service req         = if req >= 10 then return (req + 100) else throwIO FailoverTestException-            options             = defaultFailoverOptions { transformFailoverRequest = (+1), maxFailovers = positiveFailureMax }-            failOverService     = failover options service-            successCase         = (failOverService request) `shouldReturn` ((if request <= 10 then 10 else request) + 100)-            failureCase         = (failOverService request) `shouldThrow` (== FailoverTestException)-        in if request + positiveFailureMax >= 10 then successCase else failureCase+        let positiveFailureMax = abs failureMax `mod` 10+            service req        = if req >= 10 then pure (req + 100) else throwIO FailoverTestException+            options            = defaultFailoverOptions { transformFailoverRequest = (+ 1), maxFailovers = positiveFailureMax }+            failOverService    = failover options service+            successCase        = failOverService request `shouldReturn` ((if request <= 10 then 10 else request) + 100)+            failureCase        = failOverService request `shouldThrow` (== FailoverTestException)+        in  if request + positiveFailureMax >= 10 then successCase else failureCase
+ test/Glue/HedgingSpec.hs view
@@ -0,0 +1,42 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Glue.HedgingSpec (spec) where++import           Control.Concurrent (threadDelay)+import           Data.IORef+import           Glue.Hedging+import           Test.Hspec+import           UnliftIO.Exception (Exception, throwIO)++data HedgingTestException = HedgingTestException+  deriving (Eq, Show)++instance Exception HedgingTestException++spec :: Spec+spec = do+  describe "hedgingService" $ do+    it "Completes with the fast hedged call when the primary call is slow" $ do+      ref <- newIORef (0 :: Int)+      let service (req :: Int) = do+            attemptNum <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+            if attemptNum == 1+              then threadDelay (500 * 1000) >> pure (req + 100)+              else pure (req * 2)+          hedged = hedgingService (HedgingOptions { hedgingCount = 2, hedgingDelayMs = 5 }) service+      hedged 21 `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+              else pure (req * 3)+          hedged = hedgingService (HedgingOptions { hedgingCount = 2, hedgingDelayMs = 0 }) service+      hedged 10 `shouldReturn` 30++    it "Rethrows the exception if all hedged attempts fail" $ do+      let service (_ :: Int) = throwIO HedgingTestException :: IO Int+          hedged = hedgingService (HedgingOptions { hedgingCount = 3, hedgingDelayMs = 0 }) service+      hedged 1 `shouldThrow` (== HedgingTestException)
+ test/Glue/LoadBalancerSpec.hs view
@@ -0,0 +1,46 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Glue.LoadBalancerSpec (spec) where++import           Control.Concurrent.Async (mapConcurrently)+import           Data.IORef+import           Data.List.NonEmpty       (NonEmpty (..))+import           Glue.LoadBalancer+import           Glue.Types+import           Test.Hspec+import           Test.QuickCheck++spec :: Spec+spec = do+  describe "roundRobinLoadBalancer" $ do+    it "Throws NoServicesAvailableException when given an empty list" $ do+      lb <- roundRobinLoadBalancer ([] :: [BasicService IO Int Int])+      lb 42 `shouldThrow` (== NoServicesAvailableException)++    it "Distributes requests evenly in round-robin order" $+      property $ \(reqs :: [Int]) -> do+        ref0 <- newIORef (0 :: Int)+        ref1 <- newIORef (0 :: Int)+        ref2 <- newIORef (0 :: Int)+        let mkSvc tag ref req = do+              atomicModifyIORef' ref (\c -> (c + 1, ()))+              pure (tag, req * 2)+        lb <- roundRobinLoadBalancerNE+          (  mkSvc (0 :: Int) ref0+          :| [mkSvc 1 ref1, mkSvc 2 ref2]+          )+        results <- traverse lb reqs+        let expectedTags = zipWith (\i r -> (i `mod` 3, r * 2)) [0 ..] reqs+        results `shouldBe` expectedTags++    it "Handles concurrent requests across all backends" $ do+      ref0 <- newIORef (0 :: Int)+      ref1 <- newIORef (0 :: Int)+      let svc ref req = atomicModifyIORef' ref (\c -> (c + 1, ())) >> pure (req + 1)+      lb <- roundRobinLoadBalancer [svc ref0, svc ref1]+      results <- mapConcurrently lb ([1 .. 20] :: [Int])+      results `shouldBe` [2 .. 21]+      c0 <- readIORef ref0+      c1 <- readIORef ref1+      c0 `shouldBe` 10+      c1 `shouldBe` 10
test/Glue/PreloadSpec.hs view
@@ -1,18 +1,15 @@-{-# LANGUAGE DeriveDataTypeable  #-}-{-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} -module Glue.PreloadSpec where+module Glue.PreloadSpec (spec) where -import           Control.Exception.Base import qualified Data.HashMap.Strict       as M import qualified Data.HashSet              as S-import           Data.Typeable import           Glue.Preload import           Glue.Types import           Test.Hspec import           Test.QuickCheck import           Test.QuickCheck.Instances ()+import           UnliftIO.Exception        (Exception, finally, throwIO)  serviceFunctionality :: MultiGetRequest Int -> MultiGetResponse Int Int serviceFunctionality rs = M.fromList $ fmap (\r -> (r, r * 2)) $ S.toList rs@@ -20,41 +17,44 @@ failingService :: MultiGetRequest Int -> IO (MultiGetResponse Int Int) failingService _ = throwIO PreloadTestException -successfullCall :: IO Int-successfullCall = return 12+successfulCall :: IO Int+successfulCall = pure 12  failingCall :: IO Int failingCall = throwIO PreloadTestException -data PreloadTestException = PreloadTestException deriving (Eq, Show, Typeable)+data PreloadTestException = PreloadTestException+  deriving (Eq, Show)+ instance Exception PreloadTestException  spec :: Spec spec = do   describe "preloadingService" $ do-    it "Requests should work the same regardless of whether or not parts are preloaded" $ do+    it "Requests should work the same regardless of whether or not parts are preloaded" $       property $ \(preload :: S.HashSet Int, nonPreload :: S.HashSet Int) -> do-        let service = (return . serviceFunctionality) :: MultiGetService IO Int Int+        let service = (pure . serviceFunctionality) :: MultiGetService IO Int Int         (preloadedService, disable) <- preloadingService (defaultPreloadedOptions preload id) service         let expectedResults = serviceFunctionality (S.union preload nonPreload)-        actualResults <- preloadedService (S.union preload nonPreload)-        disable ()+        actualResults <- preloadedService (S.union preload nonPreload) `finally` disable ()         actualResults `shouldBe` expectedResults-    it "Exceptions should propagate" $ do++    it "Exceptions should propagate" $       property $ \(preload :: S.HashSet Int, nonPreload :: S.HashSet Int) -> do-        (preloadedService, _) <- preloadingService (defaultPreloadedOptions preload id) failingService+        (preloadedService, disable) <- preloadingService (defaultPreloadedOptions preload id) failingService         let expectedResults = serviceFunctionality (S.union preload nonPreload)-        let goodPath = preloadedService (S.union preload nonPreload) `shouldReturn` expectedResults-        let badPath = preloadedService (S.union preload nonPreload) `shouldThrow` (== PreloadTestException)-        if (S.null preload && S.null nonPreload) then goodPath else badPath+            goodPath = preloadedService (S.union preload nonPreload) `shouldReturn` expectedResults+            badPath  = preloadedService (S.union preload nonPreload) `shouldThrow` (== PreloadTestException)+        (if S.null preload && S.null nonPreload then goodPath else badPath) `finally` disable ()+   describe "preloadingCall" $ do-    it "Requests should work" $ do+    it "Requests should work" $       property $ do-        (preloadedCall, disable) <- preloadingCall id 1000 successfullCall-        actualResults <- preloadedCall-        disable ()+        (preloadedCall, disable) <- preloadingCall id 1000 successfulCall+        actualResults <- preloadedCall `finally` disable ()         actualResults `shouldBe` 12-    it "Exceptions should propagate" $ do++    it "Exceptions should propagate" $       property $ do-        (preloadedCall, _) <- preloadingCall id 1000 failingCall-        preloadedCall `shouldThrow` (== PreloadTestException)+        (preloadedCall, disable) <- preloadingCall id 1000 failingCall+        (preloadedCall `shouldThrow` (== PreloadTestException)) `finally` disable ()
test/Glue/RetrySpec.hs view
@@ -1,54 +1,53 @@-{-# LANGUAGE DeriveDataTypeable #-}-{-# LANGUAGE OverloadedStrings  #-}--module Glue.RetrySpec where+module Glue.RetrySpec (spec) where -import           Control.Concurrent-import           Control.Exception.Base   hiding (throw, throwIO, throwTo)-import           Control.Exception.Lifted hiding (throwTo)-import           Control.Monad.IO.Class+import           Control.Concurrent     (forkIO, myThreadId, threadDelay)+import           Control.Exception.Base (AsyncException (UserInterrupt),+                                         throwTo)+import           Control.Monad.IO.Class (liftIO) import           Data.IORef-import           Data.Typeable import           Glue.Retry import           Test.Hspec import           Test.QuickCheck+import           UnliftIO.Exception     (Exception, throwIO) -newtype SmallInt = SmallInt Int deriving (Eq, Show)+newtype SmallInt = SmallInt Int+  deriving (Eq, Show)  instance Arbitrary SmallInt where   arbitrary = sized $ \s -> do-                              n <- choose (0, s `min` 10)-                              return $ SmallInt n+    n <- choose (0, s `min` 10)+    pure $ SmallInt n -data RetryTestException = RetryTestException deriving (Eq, Show, Typeable)+data RetryTestException = RetryTestException+  deriving (Eq, Show)+ instance Exception RetryTestException  spec :: Spec spec = do   describe "retryingService" $ do-    it "Attempts a service call multiple times" $ do-      property $ \(request, (SmallInt failures), (SmallInt retries)) ->-        do-          ref <- liftIO $ newIORef 0-          let service req   = do-                                counter <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))-                                if counter > failures then return req else throwIO RetryTestException-          let options       = defaultRetryOptions { maximumRetries = retries }-          let retryService  = retryingService options service-          let successCase   = (retryService request) `shouldReturn` (request :: Int)-          let failureCase   = (retryService request) `shouldThrow` (== RetryTestException)-          if retries >= failures then successCase else failureCase-    it "Asynchronous exceptions are rethrown" $ do-      property $ \(request, retries) ->-        do-          ref <- liftIO $ newIORef (0 :: Int)-          let service req   = do-                                atomicModifyIORef' ref (\c -> (c + 1, ()))-                                threadId <- myThreadId-                                _ <- forkIO (threadDelay 10000 >> throwTo threadId UserInterrupt)-                                threadDelay 1000000-                                return (req * 2 :: Int)-          let options       = defaultRetryOptions { maximumRetries = retries }-          let retryService  = retryingService options service-          (retryService request) `shouldThrow` (== UserInterrupt)-          (readIORef ref) `shouldReturn` 1+    it "Attempts a service call multiple times" $+      property $ \(request, SmallInt failures, SmallInt retries) -> do+        ref <- liftIO $ newIORef 0+        let service req = do+              counter <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+              if counter > failures then pure req else throwIO RetryTestException+            options      = defaultRetryOptions { maximumRetries = retries }+            retryService = retryingService options service+            successCase  = retryService request `shouldReturn` (request :: Int)+            failureCase  = retryService request `shouldThrow` (== RetryTestException)+        if retries >= failures then successCase else failureCase++    it "Asynchronous exceptions are rethrown" $+      property $ \(request, retries) -> do+        ref <- liftIO $ newIORef (0 :: Int)+        let service req = do+              atomicModifyIORef' ref (\c -> (c + 1, ()))+              threadId <- myThreadId+              _ <- forkIO (threadDelay 2000 >> throwTo threadId UserInterrupt)+              threadDelay 1000000+              pure (req * 2 :: Int)+            options      = defaultRetryOptions { maximumRetries = retries }+            retryService = retryingService options service+        retryService request `shouldThrow` (== UserInterrupt)+        readIORef ref `shouldReturn` 1
+ test/Glue/ShutdownSpec.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Glue.ShutdownSpec (spec) where++import           Control.Concurrent       (threadDelay)+import           Control.Concurrent.Async (async, wait)+import           Glue.Shutdown+import           Test.Hspec++spec :: Spec+spec = do+  describe "gracefulShutdown" $ do+    it "Allows in-flight requests to drain cleanly within the grace period and rejects new requests" $ do+      let service (req :: Int) = do+            threadDelay (30 * 1000)+            pure (req * 2)+      (wrapped, handle) <- gracefulShutdown (defaultShutdownOptions { shutdownGracePeriodMs = 500 }) service+      isShuttingDown handle `shouldReturn` False+      inFlight <- async (wrapped 21)+      threadDelay (5 * 1000)+      inFlightCount handle `shouldReturn` 1+      drainedCleanly <- initiateShutdown handle+      drainedCleanly `shouldBe` True+      isShuttingDown handle `shouldReturn` True+      wait inFlight `shouldReturn` 42+      wrapped 99 `shouldThrow` (== ServiceShuttingDownException "Service is shutting down.")++    it "Returns shutdownFallback for rejected calls when configured" $ do+      let opts = defaultShutdownOptions { shutdownFallback = Just (-1 :: Int), shutdownGracePeriodMs = 100 }+      (wrapped, handle) <- gracefulShutdown opts (\x -> pure (x * 2))+      wrapped 10 `shouldReturn` 20+      _ <- initiateShutdown handle+      wrapped 10 `shouldReturn` (-1)++    it "Cancels stuck in-flight requests when grace period expires" $ do+      let opts = defaultShutdownOptions { shutdownFallback = Just (-999 :: Int), shutdownGracePeriodMs = 20 }+          slowService (_ :: Int) = threadDelay (500 * 1000) >> pure 100+      (wrapped, handle) <- gracefulShutdown opts slowService+      inFlight <- async (wrapped 1)+      threadDelay (5 * 1000)+      drainedCleanly <- initiateShutdown handle+      drainedCleanly `shouldBe` False+      wait inFlight `shouldReturn` (-999)
test/Glue/SwitchingSpec.hs view
@@ -1,8 +1,6 @@-{-# LANGUAGE DeriveDataTypeable  #-}-{-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} -module Glue.SwitchingSpec where+module Glue.SwitchingSpec (spec) where  import           Glue.Switching import           Glue.Types@@ -13,15 +11,15 @@ spec :: Spec spec = do   describe "switchingService" $ do-    it "Requests should be handled by the service used" $ do+    it "Requests should be handled by the service used" $       property $ \(firstResult :: MultiGetResponse Int Int, secondResult :: MultiGetResponse Int Int, request :: MultiGetRequest Int) -> do-        let first _ = return firstResult-        let second _ = return secondResult+        let first _  = pure firstResult+            second _ = pure secondResult         (switchedService, serviceUpdate) <- switchingService first-        actualFirstResult <- switchedService request+        actualFirstResult   <- switchedService request         expectedFirstResult <- first request         serviceUpdate second-        actualSecondResult <- switchedService request+        actualSecondResult   <- switchedService request         expectedSecondResult <- second request         actualFirstResult `shouldBe` expectedFirstResult         actualSecondResult `shouldBe` expectedSecondResult
test/Glue/TimeoutSpec.hs view
@@ -1,9 +1,6 @@-{-# LANGUAGE DeriveDataTypeable #-}-{-# LANGUAGE OverloadedStrings  #-}--module Glue.TimeoutSpec where+module Glue.TimeoutSpec (spec) where -import           Control.Concurrent+import           Control.Concurrent (threadDelay) import           Glue.Timeout import           Test.Hspec import           Test.QuickCheck@@ -14,10 +11,10 @@ spec :: Spec spec = do   describe "addTimeout" $ do-    it "Services taking too long should fail with a timeout" $ do+    it "Services taking too long should fail with a timeout" $       property $ \request ->-        let service req         = (if req > 0 then return () else threadDelay (100 * 1000)) >> return (req + 100)-            timeoutService      = addTimeout timeoutOptions service-            successCase         = (timeoutService request) `shouldReturn` (request + 100)-            failureCase         = (timeoutService request) `shouldThrow` (== TimeoutException(timeoutDescription timeoutOptions))-        in if request > (0 :: Int) then successCase else failureCase+        let service req    = (if req > 0 then pure () else threadDelay (50 * 1000)) >> pure (req + 100)+            timeoutService = addTimeout timeoutOptions service+            successCase    = timeoutService request `shouldReturn` (request + 100)+            failureCase    = timeoutService request `shouldThrow` (== TimeoutException (timeoutDescription timeoutOptions))+        in  if request > (0 :: Int) then successCase else failureCase