packages feed

glue-core 0.7.0.1 → 0.8.0.0

raw patch · 26 files changed

+2377/−338 lines, 26 filesdep +bytestringdep +containersdep +randomdep −timedep ~glue-commonPVP ok

version bump matches the API change (PVP)

Dependencies added: bytestring, containers, random

Dependencies removed: time

Dependency ranges changed: glue-common

API changes (from Hackage documentation)

- Glue.Batcher: instance GHC.Classes.Eq Glue.Batcher.BatchingOptions
- Glue.Batcher: instance GHC.Show.Show Glue.Batcher.BatchingOptions
- Glue.Batcher: newtype BatchingOptions
- Glue.CircuitBreaker: [maxBreakerFailures] :: CircuitBreakerOptions -> Int
- Glue.CircuitBreaker: [resetTimeoutSecs] :: CircuitBreakerOptions -> Int
- Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.BreakerStatus
- Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.CircuitBreakerOptions
- Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.BreakerStatus
- Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.CircuitBreakerOptions
- Glue.DogpileProtection: instance GHC.Classes.Eq Glue.DogpileProtection.DogpileOptions
- Glue.DogpileProtection: instance GHC.Show.Show Glue.DogpileProtection.DogpileOptions
- Glue.DogpileProtection: newtype DogpileOptions
- Glue.Hedging: instance GHC.Classes.Eq Glue.Hedging.HedgingOptions
- Glue.Hedging: instance GHC.Show.Show Glue.Hedging.HedgingOptions
- Glue.Retry: [retryInitialWaitTimeMs] :: RetryOptions a -> Int
- Glue.Retry: [retryWaitTimeMultiplier] :: RetryOptions a -> Double
- Glue.Shutdown: instance GHC.Classes.Eq b => GHC.Classes.Eq (Glue.Shutdown.ShutdownOptions b)
- Glue.Shutdown: instance GHC.Show.Show b => GHC.Show.Show (Glue.Shutdown.ShutdownOptions b)
- Glue.Timeout: instance GHC.Classes.Eq Glue.Timeout.TimeoutOptions
- Glue.Timeout: instance GHC.Show.Show Glue.Timeout.TimeoutOptions
+ Glue.Batcher: [batchClock] :: BatchingOptions (m :: Type -> Type) -> Clock m
+ Glue.Batcher: data BatchingOptions (m :: Type -> Type)
+ Glue.CircuitBreaker: AdmittedClosed :: Admission
+ Glue.CircuitBreaker: AdmittedTrial :: Admission
+ Glue.CircuitBreaker: BreakerClosed :: BreakerPhase
+ Glue.CircuitBreaker: BreakerHalfOpen :: BreakerPhase
+ Glue.CircuitBreaker: BreakerOpen :: BreakerPhase
+ Glue.CircuitBreaker: BreakerStore :: m (Admission, Maybe BreakerTransition) -> (Admission -> Settlement -> m (Maybe BreakerTransition)) -> m BreakerPhase -> BreakerStore (m :: Type -> Type)
+ Glue.CircuitBreaker: BreakerTransition :: BreakerPhase -> BreakerPhase -> BreakerTransition
+ Glue.CircuitBreaker: Refused :: Admission
+ Glue.CircuitBreaker: SettledAbandoned :: Settlement
+ Glue.CircuitBreaker: SettledAnswered :: Settlement
+ Glue.CircuitBreaker: SettledFailed :: Settlement
+ Glue.CircuitBreaker: [admitCall] :: BreakerStore (m :: Type -> Type) -> m (Admission, Maybe BreakerTransition)
+ Glue.CircuitBreaker: [breakerClock] :: CircuitBreakerOptions (m :: Type -> Type) failure -> Clock m
+ Glue.CircuitBreaker: [breakerFailureThreshold] :: CircuitBreakerOptions (m :: Type -> Type) failure -> Int
+ Glue.CircuitBreaker: [breakerFailureWindowMs] :: CircuitBreakerOptions (m :: Type -> Type) failure -> Maybe Int
+ Glue.CircuitBreaker: [breakerOpenDurationMs] :: CircuitBreakerOptions (m :: Type -> Type) failure -> Int
+ Glue.CircuitBreaker: [countsAsFailure] :: CircuitBreakerOptions (m :: Type -> Type) failure -> failure -> Bool
+ Glue.CircuitBreaker: [onBreakerTransition] :: CircuitBreakerOptions (m :: Type -> Type) failure -> BreakerTransition -> m ()
+ Glue.CircuitBreaker: [readPhase] :: BreakerStore (m :: Type -> Type) -> m BreakerPhase
+ Glue.CircuitBreaker: [settleCall] :: BreakerStore (m :: Type -> Type) -> Admission -> Settlement -> m (Maybe BreakerTransition)
+ Glue.CircuitBreaker: [transitionFrom] :: BreakerTransition -> BreakerPhase
+ Glue.CircuitBreaker: [transitionTo] :: BreakerTransition -> BreakerPhase
+ Glue.CircuitBreaker: circuitBreakerEither :: (MonadUnliftIO m, MonadIO n) => CircuitBreakerOptions m e -> e -> EitherService m a e b -> n (CircuitBreakerState m, EitherService m a e b)
+ Glue.CircuitBreaker: circuitBreakerEitherWithStore :: MonadUnliftIO m => CircuitBreakerOptions m e -> BreakerStore m -> e -> EitherService m a e b -> EitherService m a e b
+ Glue.CircuitBreaker: circuitBreakerPhases :: Applicative m => CircuitBreakerState m -> m [BreakerPhase]
+ Glue.CircuitBreaker: circuitBreakerWithStore :: MonadUnliftIO m => CircuitBreakerOptions m SomeException -> BreakerStore m -> BasicService m a b -> BasicService m a b
+ Glue.CircuitBreaker: data Admission
+ Glue.CircuitBreaker: data BreakerPhase
+ Glue.CircuitBreaker: data BreakerStore (m :: Type -> Type)
+ Glue.CircuitBreaker: data BreakerTransition
+ Glue.CircuitBreaker: data Settlement
+ Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.Admission
+ Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.BreakerPhase
+ Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.BreakerTransition
+ Glue.CircuitBreaker: instance GHC.Classes.Eq Glue.CircuitBreaker.Settlement
+ Glue.CircuitBreaker: instance GHC.Classes.Ord Glue.CircuitBreaker.BreakerPhase
+ Glue.CircuitBreaker: instance GHC.Enum.Bounded Glue.CircuitBreaker.BreakerPhase
+ Glue.CircuitBreaker: instance GHC.Enum.Enum Glue.CircuitBreaker.BreakerPhase
+ Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.Admission
+ Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.BreakerPhase
+ Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.BreakerTransition
+ Glue.CircuitBreaker: instance GHC.Show.Show Glue.CircuitBreaker.Settlement
+ Glue.CircuitBreaker: newInMemoryBreakerStore :: forall (m :: Type -> Type) n failure. (MonadIO m, MonadIO n) => CircuitBreakerOptions m failure -> n (BreakerStore m)
+ Glue.ConcurrencyLimit: AdmissionLease :: m () -> AdmissionLease (m :: Type -> Type)
+ Glue.ConcurrencyLimit: AdmissionStore :: (k -> m (Maybe (AdmissionLease m))) -> (k -> m Int) -> m Int -> AdmissionStore (m :: Type -> Type) k
+ Glue.ConcurrencyLimit: Saturated :: Saturated
+ Glue.ConcurrencyLimit: [claimSlot] :: AdmissionStore (m :: Type -> Type) k -> k -> m (Maybe (AdmissionLease m))
+ Glue.ConcurrencyLimit: [releaseLease] :: AdmissionLease (m :: Type -> Type) -> m ()
+ Glue.ConcurrencyLimit: [slotsInUse] :: AdmissionStore (m :: Type -> Type) k -> k -> m Int
+ Glue.ConcurrencyLimit: [trackedKeys] :: AdmissionStore (m :: Type -> Type) k -> m Int
+ Glue.ConcurrencyLimit: data AdmissionStore (m :: Type -> Type) k
+ Glue.ConcurrencyLimit: data Saturated
+ Glue.ConcurrencyLimit: instance GHC.Classes.Eq Glue.ConcurrencyLimit.Saturated
+ Glue.ConcurrencyLimit: instance GHC.Show.Show Glue.ConcurrencyLimit.Saturated
+ Glue.ConcurrencyLimit: limitConcurrencyBy :: (MonadUnliftIO m, MonadIO n, Hashable k) => (a -> k) -> (k -> Int) -> BasicService m a b -> n (BasicService m a (Either Saturated b))
+ Glue.ConcurrencyLimit: limitConcurrencyByEither :: (MonadUnliftIO m, MonadIO n, Hashable k) => (a -> k) -> (k -> Int) -> e -> EitherService m a e b -> n (EitherService m a e b)
+ Glue.ConcurrencyLimit: limitConcurrencyByWithStore :: MonadUnliftIO m => AdmissionStore m k -> (a -> k) -> BasicService m a b -> BasicService m a (Either Saturated b)
+ Glue.ConcurrencyLimit: limitConcurrencyEither :: (MonadUnliftIO m, MonadIO n) => ConcurrencyLimitOptions -> e -> EitherService m a e b -> n (EitherService m a e b)
+ Glue.ConcurrencyLimit: newInMemoryAdmissionStore :: forall k (m :: Type -> Type) n. (Hashable k, MonadIO m, MonadIO n) => (k -> Int) -> n (AdmissionStore m k)
+ Glue.ConcurrencyLimit: newtype AdmissionLease (m :: Type -> Type)
+ Glue.ConcurrencyLimit: withSlot :: MonadUnliftIO m => AdmissionStore m k -> k -> m r -> m (Either Saturated r)
+ Glue.DogpileProtection: [dogpileClock] :: DogpileOptions (m :: Type -> Type) -> Clock m
+ Glue.DogpileProtection: data DogpileOptions (m :: Type -> Type)
+ Glue.Failover: FailoverAcrossOptions :: (failure -> Bool) -> ReportedFailure -> FailoverAcrossOptions failure
+ Glue.Failover: ReportFirstFailure :: ReportedFailure
+ Glue.Failover: ReportLastFailure :: ReportedFailure
+ Glue.Failover: [advanceOn] :: FailoverAcrossOptions failure -> failure -> Bool
+ Glue.Failover: [reportedFailure] :: FailoverAcrossOptions failure -> ReportedFailure
+ Glue.Failover: data FailoverAcrossOptions failure
+ Glue.Failover: data ReportedFailure
+ Glue.Failover: defaultFailoverAcrossOptions :: FailoverAcrossOptions failure
+ Glue.Failover: failoverAcross :: MonadUnliftIO m => FailoverAcrossOptions SomeException -> NonEmpty (BasicService m a b) -> BasicService m a b
+ Glue.Failover: failoverAcrossEither :: Monad m => FailoverAcrossOptions e -> NonEmpty (EitherService m a e b) -> EitherService m a e b
+ Glue.Failover: failoverAcrossIndexed :: MonadUnliftIO m => FailoverAcrossOptions SomeException -> NonEmpty (BasicService m a b) -> BasicService m a (Int, b)
+ Glue.Failover: failoverAcrossIndexedEither :: Monad m => FailoverAcrossOptions e -> NonEmpty (EitherService m a e b) -> EitherService m a e (Int, b)
+ Glue.Failover: instance GHC.Classes.Eq Glue.Failover.ReportedFailure
+ Glue.Failover: instance GHC.Show.Show Glue.Failover.ReportedFailure
+ Glue.Hedging: AttemptAnswered :: r -> AttemptOutcome failure r
+ Glue.Hedging: AttemptCancelled :: AttemptOutcome failure r
+ Glue.Hedging: AttemptCrashed :: SomeException -> AttemptOutcome failure r
+ Glue.Hedging: AttemptFailed :: failure -> AttemptOutcome failure r
+ Glue.Hedging: HedgeOptions :: Int -> (Int -> m Bool) -> (Int -> AttemptOutcome failure r -> m ()) -> (Int -> m ()) -> Clock m -> HedgeOptions (m :: Type -> Type) failure r
+ Glue.Hedging: [admitLaunch] :: HedgeOptions (m :: Type -> Type) failure r -> Int -> m Bool
+ Glue.Hedging: [hedgeClock] :: HedgeOptions (m :: Type -> Type) failure r -> Clock m
+ Glue.Hedging: [hedgeDelayMs] :: HedgeOptions (m :: Type -> Type) failure r -> Int
+ Glue.Hedging: [hedgingClock] :: HedgingOptions (m :: Type -> Type) -> Clock m
+ Glue.Hedging: [onAttemptEnded] :: HedgeOptions (m :: Type -> Type) failure r -> Int -> AttemptOutcome failure r -> m ()
+ Glue.Hedging: [onHedgeWon] :: HedgeOptions (m :: Type -> Type) failure r -> Int -> m ()
+ Glue.Hedging: data AttemptOutcome failure r
+ Glue.Hedging: data HedgeOptions (m :: Type -> Type) failure r
+ Glue.Hedging: defaultHedgeOptions :: forall (m :: Type -> Type) failure r. MonadUnliftIO m => HedgeOptions m failure r
+ Glue.Hedging: hedgeAcross :: MonadUnliftIO m => HedgeOptions m SomeException b -> NonEmpty (BasicService m a b) -> BasicService m a b
+ Glue.Hedging: hedgeAcrossEither :: MonadUnliftIO m => HedgeOptions m e (Either e b) -> NonEmpty (EitherService m a e b) -> EitherService m a e b
+ Glue.LoadBalancer: NegativeWeight :: Int -> Weight -> WeightError
+ Glue.LoadBalancer: Salt :: Word64 -> Word64 -> Salt
+ Glue.LoadBalancer: WeightsDoNotSumToTotal :: Int -> WeightError
+ Glue.LoadBalancer: bucketOf :: Salt -> ByteString -> Int
+ Glue.LoadBalancer: chooseIndex :: Salt -> ByteString -> NonEmpty Weight -> Int
+ Glue.LoadBalancer: data Salt
+ Glue.LoadBalancer: data WeightError
+ Glue.LoadBalancer: instance GHC.Classes.Eq Glue.LoadBalancer.Salt
+ Glue.LoadBalancer: instance GHC.Classes.Eq Glue.LoadBalancer.WeightError
+ Glue.LoadBalancer: instance GHC.Show.Show Glue.LoadBalancer.Salt
+ Glue.LoadBalancer: instance GHC.Show.Show Glue.LoadBalancer.WeightError
+ Glue.LoadBalancer: saltFromBytes :: ByteString -> Salt
+ Glue.LoadBalancer: sipHash24 :: Word64 -> Word64 -> ByteString -> Word64
+ Glue.LoadBalancer: totalWeight :: Int
+ Glue.LoadBalancer: type Weight = Int
+ Glue.LoadBalancer: validateWeights :: NonEmpty Weight -> Either WeightError ()
+ Glue.LoadBalancer: weightedBy :: (a -> ByteString) -> Salt -> NonEmpty (Weight, BasicService m a b) -> Either WeightError (BasicService m a b)
+ Glue.Periodic: PeriodicHandle :: m Bool -> m Bool -> m Int -> PeriodicHandle (m :: Type -> Type)
+ Glue.Periodic: PeriodicOptions :: Int -> Bool -> Int -> (Int -> m Int) -> (SomeException -> m ()) -> Int -> Clock m -> PeriodicOptions (m :: Type -> Type)
+ Glue.Periodic: [onPassFailed] :: PeriodicOptions (m :: Type -> Type) -> SomeException -> m ()
+ Glue.Periodic: [passesCompleted] :: PeriodicHandle (m :: Type -> Type) -> m Int
+ Glue.Periodic: [periodJitterMs] :: PeriodicOptions (m :: Type -> Type) -> Int
+ Glue.Periodic: [periodMs] :: PeriodicOptions (m :: Type -> Type) -> Int
+ Glue.Periodic: [periodicClock] :: PeriodicOptions (m :: Type -> Type) -> Clock m
+ Glue.Periodic: [periodicGracePeriodMs] :: PeriodicOptions (m :: Type -> Type) -> Int
+ Glue.Periodic: [periodicRandom] :: PeriodicOptions (m :: Type -> Type) -> Int -> m Int
+ Glue.Periodic: [runImmediately] :: PeriodicOptions (m :: Type -> Type) -> Bool
+ Glue.Periodic: [runNow] :: PeriodicHandle (m :: Type -> Type) -> m Bool
+ Glue.Periodic: [stopPeriodic] :: PeriodicHandle (m :: Type -> Type) -> m Bool
+ Glue.Periodic: data PeriodicHandle (m :: Type -> Type)
+ Glue.Periodic: data PeriodicOptions (m :: Type -> Type)
+ Glue.Periodic: defaultPeriodicOptions :: forall (m :: Type -> Type). MonadUnliftIO m => PeriodicOptions m
+ Glue.Periodic: periodically :: MonadUnliftIO m => PeriodicOptions m -> m () -> m (PeriodicHandle m)
+ Glue.Preload: [preloadingClock] :: PreloadedOptions (m :: Type -> Type) a b -> Clock m
+ Glue.Race: AttemptAnswered :: r -> AttemptOutcome failure r
+ Glue.Race: AttemptCancelled :: AttemptOutcome failure r
+ Glue.Race: AttemptCrashed :: SomeException -> AttemptOutcome failure r
+ Glue.Race: AttemptEnded :: Int -> AttemptOutcome failure r -> RaceEvent failure r
+ Glue.Race: AttemptFailed :: failure -> AttemptOutcome failure r
+ Glue.Race: NothingRunning :: RaceEvent failure r
+ Glue.Race: RaceTimedOut :: RaceEvent failure r
+ Glue.Race: abandon :: MonadUnliftIO m => Race m failure r -> Int -> m (AttemptOutcome failure r)
+ Glue.Race: abandonAll :: MonadUnliftIO m => Race m failure r -> m [(Int, AttemptOutcome failure r)]
+ Glue.Race: awaitNext :: MonadUnliftIO m => Race m failure r -> Maybe Int -> m (RaceEvent failure r)
+ Glue.Race: data AttemptOutcome failure r
+ Glue.Race: data Race (m :: Type -> Type) failure r
+ Glue.Race: data RaceEvent failure r
+ Glue.Race: finishedAttempts :: MonadUnliftIO m => Race m failure r -> m [Int]
+ Glue.Race: instance (GHC.Show.Show r, GHC.Show.Show failure) => GHC.Show.Show (Glue.Race.AttemptOutcome failure r)
+ Glue.Race: instance (GHC.Show.Show r, GHC.Show.Show failure) => GHC.Show.Show (Glue.Race.RaceEvent failure r)
+ Glue.Race: launch :: MonadUnliftIO m => Race m failure r -> m r -> m Int
+ Glue.Race: withRace :: MonadUnliftIO m => FailureMode m r failure -> Clock m -> (Race m failure r -> m x) -> m x
+ Glue.Retry: ConstantBackoff :: Int -> Backoff
+ Glue.Retry: EqualJitter :: Jitter
+ Glue.Retry: ExponentialBackoff :: Int -> Double -> Backoff
+ Glue.Retry: FullJitter :: Jitter
+ Glue.Retry: LinearBackoff :: Int -> Int -> Backoff
+ Glue.Retry: NoJitter :: Jitter
+ Glue.Retry: [onRetry] :: RetryOptions (m :: Type -> Type) a failure -> Int -> failure -> Int -> m ()
+ Glue.Retry: [retryBackoff] :: RetryOptions (m :: Type -> Type) a failure -> Backoff
+ Glue.Retry: [retryClock] :: RetryOptions (m :: Type -> Type) a failure -> Clock m
+ Glue.Retry: [retryJitter] :: RetryOptions (m :: Type -> Type) a failure -> Jitter
+ Glue.Retry: [retryMaxDelayMs] :: RetryOptions (m :: Type -> Type) a failure -> Maybe Int
+ Glue.Retry: [retryOn] :: RetryOptions (m :: Type -> Type) a failure -> failure -> Bool
+ Glue.Retry: [retryRandom] :: RetryOptions (m :: Type -> Type) a failure -> Int -> m Int
+ Glue.Retry: [retryRemainingMs] :: RetryOptions (m :: Type -> Type) a failure -> m (Maybe Int)
+ Glue.Retry: backoffDelayMs :: Backoff -> Int -> Int
+ Glue.Retry: data Backoff
+ Glue.Retry: data Jitter
+ Glue.Retry: instance GHC.Classes.Eq Glue.Retry.Backoff
+ Glue.Retry: instance GHC.Classes.Eq Glue.Retry.Jitter
+ Glue.Retry: instance GHC.Show.Show Glue.Retry.Backoff
+ Glue.Retry: instance GHC.Show.Show Glue.Retry.Jitter
+ Glue.Retry: retryingServiceEither :: Monad m => RetryOptions m a e -> EitherService m a e b -> EitherService m a e b
+ Glue.Shutdown: [shutdownClock] :: ShutdownOptions (m :: Type -> Type) b -> Clock m
+ Glue.Shutdown: gracefulShutdownEither :: (MonadUnliftIO m, MonadIO n) => ShutdownOptions m (Either e b) -> e -> EitherService m a e b -> n (EitherService m a e b, ShutdownHandle m)
+ Glue.Timeout: [timeoutClock] :: TimeoutOptions (m :: Type -> Type) -> Clock m
+ Glue.Timeout: addTimeoutEither :: MonadUnliftIO m => TimeoutOptions m -> e -> EitherService m a e b -> EitherService m a e b
- Glue.Batcher: BatchingOptions :: Int -> BatchingOptions
+ Glue.Batcher: BatchingOptions :: Int -> Clock m -> BatchingOptions (m :: Type -> Type)
- Glue.Batcher: [batchWindowMs] :: BatchingOptions -> Int
+ Glue.Batcher: [batchWindowMs] :: BatchingOptions (m :: Type -> Type) -> Int
- 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.Batcher: batchingService :: (Hashable a, MonadUnliftIO m, MonadIO n) => BatchingOptions m -> MultiGetService m a b -> n (BasicService m a (Maybe b), MultiGetService m a b)
- Glue.Batcher: defaultBatchingOptions :: BatchingOptions
+ Glue.Batcher: defaultBatchingOptions :: forall (m :: Type -> Type). MonadUnliftIO m => BatchingOptions m
- Glue.CircuitBreaker: CircuitBreakerOptions :: Int -> Int -> String -> CircuitBreakerOptions
+ Glue.CircuitBreaker: CircuitBreakerOptions :: Int -> Maybe Int -> Int -> String -> (failure -> Bool) -> (BreakerTransition -> m ()) -> Clock m -> CircuitBreakerOptions (m :: Type -> Type) failure
- Glue.CircuitBreaker: [breakerDescription] :: CircuitBreakerOptions -> String
+ Glue.CircuitBreaker: [breakerDescription] :: CircuitBreakerOptions (m :: Type -> Type) failure -> String
- Glue.CircuitBreaker: circuitBreaker :: (MonadUnliftIO m, MonadIO n) => CircuitBreakerOptions -> BasicService m a b -> n (CircuitBreakerState, BasicService m a b)
+ Glue.CircuitBreaker: circuitBreaker :: (MonadUnliftIO m, MonadIO n) => CircuitBreakerOptions m SomeException -> BasicService m a b -> n (CircuitBreakerState m, BasicService m a b)
- Glue.CircuitBreaker: combineCircuitBreakerStates :: Foldable t => t CircuitBreakerState -> CircuitBreakerState
+ Glue.CircuitBreaker: combineCircuitBreakerStates :: forall t (m :: Type -> Type). Foldable t => t (CircuitBreakerState m) -> CircuitBreakerState m
- Glue.CircuitBreaker: data CircuitBreakerOptions
+ Glue.CircuitBreaker: data CircuitBreakerOptions (m :: Type -> Type) failure
- Glue.CircuitBreaker: data CircuitBreakerState
+ Glue.CircuitBreaker: data CircuitBreakerState (m :: Type -> Type)
- Glue.CircuitBreaker: defaultCircuitBreakerOptions :: CircuitBreakerOptions
+ Glue.CircuitBreaker: defaultCircuitBreakerOptions :: forall (m :: Type -> Type) failure. MonadUnliftIO m => CircuitBreakerOptions m failure
- Glue.CircuitBreaker: isCircuitBreakerClosed :: MonadIO m => CircuitBreakerState -> m Bool
+ Glue.CircuitBreaker: isCircuitBreakerClosed :: Applicative m => CircuitBreakerState m -> m Bool
- Glue.CircuitBreaker: isCircuitBreakerOpen :: MonadIO m => CircuitBreakerState -> m Bool
+ Glue.CircuitBreaker: isCircuitBreakerOpen :: Applicative m => CircuitBreakerState m -> m Bool
- Glue.DogpileProtection: DogpileOptions :: Int -> DogpileOptions
+ Glue.DogpileProtection: DogpileOptions :: Int -> Clock m -> DogpileOptions (m :: Type -> Type)
- Glue.DogpileProtection: [dogpileCacheTimeMs] :: DogpileOptions -> Int
+ Glue.DogpileProtection: [dogpileCacheTimeMs] :: DogpileOptions (m :: Type -> Type) -> Int
- Glue.DogpileProtection: defaultDogpileOptions :: DogpileOptions
+ Glue.DogpileProtection: defaultDogpileOptions :: forall (m :: Type -> Type). MonadUnliftIO m => DogpileOptions m
- Glue.DogpileProtection: dogpileProtectWith :: (MonadUnliftIO m, MonadIO n, Hashable a) => DogpileOptions -> BasicService m a b -> n (BasicService m a b)
+ Glue.DogpileProtection: dogpileProtectWith :: (MonadUnliftIO m, MonadIO n, Hashable a) => DogpileOptions m -> BasicService m a b -> n (BasicService m a b)
- Glue.Hedging: HedgingOptions :: Int -> Int -> HedgingOptions
+ Glue.Hedging: HedgingOptions :: Int -> Int -> Clock m -> HedgingOptions (m :: Type -> Type)
- Glue.Hedging: [hedgingCount] :: HedgingOptions -> Int
+ Glue.Hedging: [hedgingCount] :: HedgingOptions (m :: Type -> Type) -> Int
- Glue.Hedging: [hedgingDelayMs] :: HedgingOptions -> Int
+ Glue.Hedging: [hedgingDelayMs] :: HedgingOptions (m :: Type -> Type) -> Int
- Glue.Hedging: data HedgingOptions
+ Glue.Hedging: data HedgingOptions (m :: Type -> Type)
- Glue.Hedging: defaultHedgingOptions :: HedgingOptions
+ Glue.Hedging: defaultHedgingOptions :: forall (m :: Type -> Type). MonadUnliftIO m => HedgingOptions m
- Glue.Hedging: hedgingService :: MonadUnliftIO m => HedgingOptions -> BasicService m a b -> BasicService m a b
+ Glue.Hedging: hedgingService :: MonadUnliftIO m => HedgingOptions m -> BasicService m a b -> BasicService m a b
- Glue.Preload: PreloadedOptions :: HashSet a -> Int -> MToIO m -> PreloadedOptions (m :: Type -> Type) a b
+ Glue.Preload: PreloadedOptions :: HashSet a -> Int -> MToIO m -> Clock m -> PreloadedOptions (m :: Type -> Type) a b
- Glue.Preload: defaultPreloadedOptions :: forall a (m :: Type -> Type) b. HashSet a -> MToIO m -> PreloadedOptions m a b
+ Glue.Preload: defaultPreloadedOptions :: forall (m :: Type -> Type) a b. MonadUnliftIO m => HashSet a -> MToIO m -> PreloadedOptions m a b
- Glue.Retry: RetryOptions :: (a -> Bool) -> Int -> Int -> Double -> RetryOptions a
+ Glue.Retry: RetryOptions :: (a -> Bool) -> (failure -> Bool) -> Int -> Backoff -> Maybe Int -> Jitter -> (Int -> m Int) -> m (Maybe Int) -> (Int -> failure -> Int -> m ()) -> Clock m -> RetryOptions (m :: Type -> Type) a failure
- Glue.Retry: [maximumRetries] :: RetryOptions a -> Int
+ Glue.Retry: [maximumRetries] :: RetryOptions (m :: Type -> Type) a failure -> Int
- Glue.Retry: [retryAllowed] :: RetryOptions a -> a -> Bool
+ Glue.Retry: [retryAllowed] :: RetryOptions (m :: Type -> Type) a failure -> a -> Bool
- Glue.Retry: data RetryOptions a
+ Glue.Retry: data RetryOptions (m :: Type -> Type) a failure
- Glue.Retry: defaultRetryOptions :: RetryOptions a
+ Glue.Retry: defaultRetryOptions :: forall (m :: Type -> Type) a failure. MonadUnliftIO m => RetryOptions m a failure
- Glue.Retry: retryingService :: MonadUnliftIO m => RetryOptions a -> BasicService m a b -> BasicService m a b
+ Glue.Retry: retryingService :: MonadUnliftIO m => RetryOptions m a SomeException -> BasicService m a b -> BasicService m a b
- Glue.Shutdown: ShutdownOptions :: Maybe b -> Int -> String -> ShutdownOptions b
+ Glue.Shutdown: ShutdownOptions :: Maybe b -> Int -> String -> Clock m -> ShutdownOptions (m :: Type -> Type) b
- Glue.Shutdown: [shutdownDescription] :: ShutdownOptions b -> String
+ Glue.Shutdown: [shutdownDescription] :: ShutdownOptions (m :: Type -> Type) b -> String
- Glue.Shutdown: [shutdownFallback] :: ShutdownOptions b -> Maybe b
+ Glue.Shutdown: [shutdownFallback] :: ShutdownOptions (m :: Type -> Type) b -> Maybe b
- Glue.Shutdown: [shutdownGracePeriodMs] :: ShutdownOptions b -> Int
+ Glue.Shutdown: [shutdownGracePeriodMs] :: ShutdownOptions (m :: Type -> Type) b -> Int
- Glue.Shutdown: data ShutdownOptions b
+ Glue.Shutdown: data ShutdownOptions (m :: Type -> Type) b
- Glue.Shutdown: defaultShutdownOptions :: ShutdownOptions b
+ Glue.Shutdown: defaultShutdownOptions :: forall (m :: Type -> Type) b. MonadUnliftIO m => ShutdownOptions m 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: gracefulShutdown :: (MonadUnliftIO m, MonadIO n) => ShutdownOptions m b -> BasicService m a b -> n (BasicService m a b, ShutdownHandle m)
- Glue.Timeout: TimeoutOptions :: String -> Int -> TimeoutOptions
+ Glue.Timeout: TimeoutOptions :: String -> Int -> Clock m -> TimeoutOptions (m :: Type -> Type)
- Glue.Timeout: [timeoutDescription] :: TimeoutOptions -> String
+ Glue.Timeout: [timeoutDescription] :: TimeoutOptions (m :: Type -> Type) -> String
- Glue.Timeout: [timeoutLimitMs] :: TimeoutOptions -> Int
+ Glue.Timeout: [timeoutLimitMs] :: TimeoutOptions (m :: Type -> Type) -> Int
- Glue.Timeout: addTimeout :: MonadUnliftIO m => TimeoutOptions -> BasicService m a b -> BasicService m a b
+ Glue.Timeout: addTimeout :: MonadUnliftIO m => TimeoutOptions m -> BasicService m a b -> BasicService m a b
- Glue.Timeout: data TimeoutOptions
+ Glue.Timeout: data TimeoutOptions (m :: Type -> Type)
- Glue.Timeout: defaultTimeoutOptions :: TimeoutOptions
+ Glue.Timeout: defaultTimeoutOptions :: forall (m :: Type -> Type). MonadUnliftIO m => TimeoutOptions m

Files

+ CHANGELOG.md view
@@ -0,0 +1,74 @@+# Changelog for glue-core++## 0.8.0.0++### Breaking changes++- Options records for time-dependent combinators take the monad as a type parameter and carry a+  `Clock m` defaulting to `systemClock`: `TimeoutOptions m`, `RetryOptions m a`,+  `HedgingOptions m`, `BatchingOptions m`, `DogpileOptions m`, `ShutdownOptions m b` and+  `PreloadedOptions` (new `preloadingClock` field). Code that updates fields of the defaults keeps+  compiling; when an update replaces every field that mentions `m` (for example only the clock),+  annotate the default, e.g. `(defaultTimeoutOptions :: TimeoutOptions IO) { timeoutClock = c }` (#13).+- `Glue.CircuitBreaker` counting is fixed and its semantics match the documentation (#5):+  - Calls cancelled by an asynchronous exception (timeouts, losing hedges, killed threads) no+    longer count as failures; the exception is still rethrown.+  - `maxBreakerFailures` is replaced by `breakerFailureThreshold`, which opens on that failure+    (the default 3 opens on the third; previously the breaker opened on failure `max + 1`).+  - Failures are consecutive by default (a call that answers resets the count; previously the+    count never reset), or counted within `breakerFailureWindowMs` when set.+  - `resetTimeoutSecs` is replaced by `breakerOpenDurationMs`, how long the breaker stays open,+    with millisecond resolution on the monotonic clock rather than wall-clock seconds.+- `CircuitBreakerOptions` and `CircuitBreakerState` take the monad (and options the failure type)+  as type parameters.+- `RetryOptions m a failure`: `retryInitialWaitTimeMs` and `retryWaitTimeMultiplier` are replaced+  by `retryBackoff` (`ConstantBackoff`, `LinearBackoff` or `ExponentialBackoff`) (#7).++### Additions++- Circuit breaker failure classification with `countsAsFailure`, a phase observer+  `onBreakerTransition`, `circuitBreakerPhases`, and a pluggable `BreakerStore` (admit / settle /+  read phase, with an atomic half-open trial) so state can be shared between instances, with+  `newInMemoryBreakerStore` as the default (#9).+- `circuitBreakerEither` and `circuitBreakerEitherWithStore` for services returning failures as+  values (#6).+- Retry decided by the failure (`retryOn`), with a delay ceiling (`retryMaxDelayMs`), full or equal+  jitter from an injectable random source (`retryJitter`, `retryRandom`), a deadline after which no+  retry starts (`retryRemainingMs`), an attempt observer (`onRetry`) and `retryingServiceEither` (#7).+- `failoverAcross` tries an ordered list of distinct backends, with `advanceOn` deciding which+  failures move on and which stop the chain, a choice of reporting the first or last failure, an+  indexed variant reporting which backend answered, and `Either` variants (#8).+- `Glue.Race`: a low-level race of attempts (`withRace`, `launch`, `awaitNext` with an optional+  timeout, `abandon`, `abandonAll`) for callers driving their own launch decisions. Simultaneous+  endings are reported earliest index first, and abandoning waits until the attempt has finished,+  reporting how it really ended (#10).+- `hedgeAcross` hedges across distinct backends on top of `Glue.Race`: `admitLaunch` lets the+  caller decline a backup without failing the primary, `onAttemptEnded` reports every attempt+  (including cancelled losers), an early failure launches the next backend at once, and+  `hedgeAcrossEither` supports failures as values. `hedgingService` is now built on it (#10).+- `weightedBy`: a deterministic weighted load balancer. Weights are basis points validated up+  front (`Either WeightError`), and each request's backend is chosen from a caller-supplied stable+  key by SipHash-2-4 under a `Salt`, so a key reaches the same backend on every instance and across+  restarts and different salts split independently. `bucketOf` and `chooseIndex` expose the choice+  without calling a backend (#11).+- Keyed concurrency limits: `limitConcurrencyBy` keeps one limit per key and refuses a full key+  at once with `Saturated` as a value, releasing on every exit path (including cancellation) and+  dropping idle keys. `withSlot` holds a slot for work that outlives the service call, and an+  `AdmissionStore` (in-memory by default) is the extension point for limits shared across+  instances. `limitConcurrencyEither` and `limitConcurrencyByEither` return refusals as the+  caller's failure value (#12).+- `Glue.Periodic`: `periodically` runs a background pass every `periodMs` measured from the end of+  the previous pass (optionally immediately and with jitter), reports failed passes without ending+  the loop, and returns a handle with `runNow` (queued behind a running pass) and a graceful+  `stopPeriodic` (#14).+- `addTimeoutEither` and `gracefulShutdownEither` return their refusal as a value for services+  whose failures are values (#6).++## 0.7.0.1++- PVP upper bounds on all dependencies.++## 0.7.0++- Modernised for GHC 9.6 to 9.12, `MonadUnliftIO` throughout, new combinators (hedging,+  concurrency limits, load balancing, graceful shutdown) and a Nix flake.
glue-core.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               glue-core-version:            0.7.0.1+version:            0.8.0.0 synopsis:           Make better services and clients. description:        Combinator library to enhance the general functionality of services and clients. license:            BSD-3-Clause@@ -9,6 +9,7 @@ maintainer:         github@futurenotfound.com category:           Network build-type:         Simple+extra-doc-files:    CHANGELOG.md tested-with:        GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3, GHC == 9.12.3  source-repository head@@ -39,16 +40,20 @@     Glue.Failover     Glue.Hedging     Glue.LoadBalancer+    Glue.Periodic     Glue.Preload+    Glue.Race     Glue.Retry     Glue.Shutdown     Glue.Switching     Glue.Timeout   build-depends:     , base                  >= 4.14 && < 5-    , glue-common           ^>= 0.7.0+    , bytestring            >= 0.10 && < 0.13+    , containers            >= 0.6  && < 0.8+    , glue-common           ^>= 0.8.0     , hashable              >= 1.3  && < 1.6-    , time                  >= 1.9  && < 1.15+    , random                >= 1.2  && < 1.4     , unliftio              >= 0.2  && < 0.3     , unordered-containers  >= 0.2  && < 0.3 @@ -66,11 +71,13 @@     Glue.FailoverSpec     Glue.HedgingSpec     Glue.LoadBalancerSpec+    Glue.PeriodicSpec     Glue.PreloadSpec     Glue.RetrySpec     Glue.ShutdownSpec     Glue.SwitchingSpec     Glue.TimeoutSpec+    Glue.WeightedLoadBalancerSpec     Spec   build-tool-depends: hspec-discover:hspec-discover   ghc-options:@@ -79,13 +86,14 @@     -with-rtsopts=-N   build-depends:     , async                 >= 2.2  && < 2.3+    , bytestring            >= 0.10 && < 0.13+    , containers            >= 0.6  && < 0.8     , base                  >= 4.14 && < 5-    , glue-common           ^>= 0.7.0+    , glue-common           ^>= 0.8.0     , glue-core     , hashable              >= 1.3  && < 1.6     , hspec                 >= 2.7  && < 2.12     , QuickCheck            >= 2.14 && < 2.16     , quickcheck-instances  >= 0.3  && < 0.4-    , time                  >= 1.9  && < 1.15     , unliftio              >= 0.2  && < 0.3     , unordered-containers  >= 0.2  && < 0.3
src/Glue/Batcher.hs view
@@ -13,24 +13,27 @@ import           Data.Hashable       (Hashable) import qualified Data.HashMap.Strict as M import qualified Data.HashSet        as S+import           Glue.Clock import           Glue.Types import           UnliftIO            (MonadIO, MonadUnliftIO, SomeException)-import           UnliftIO.Concurrent (forkIO, threadDelay)+import           UnliftIO.Concurrent (forkIO) import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef) import           UnliftIO.MVar       (newEmptyMVar, putMVar)  -- | Options for configuring the batching.-newtype BatchingOptions = BatchingOptions+data BatchingOptions m = BatchingOptions   { batchWindowMs :: Int   -- ^ Window in milliseconds over which to batch.+  , batchClock    :: Clock m+  -- ^ Clock used to wait out the batching window.   }-  deriving (Eq, Show)  -- | Default instance of t'BatchingOptions' with a batching window of 10ms.-defaultBatchingOptions :: BatchingOptions+defaultBatchingOptions :: MonadUnliftIO m => BatchingOptions m defaultBatchingOptions =   BatchingOptions     { batchWindowMs = 10+    , batchClock    = systemClock     }  -- | Cumulative request batch of all the outstanding calls.@@ -55,14 +58,14 @@   result <- makeCall service requests   traverse_ (applyToPending result) pendings -startBatch :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> m ()+startBatch :: (Hashable a, MonadUnliftIO m) => BatchingOptions m -> MultiGetService m a b -> IORef (RequestBatch a b) -> m () startBatch options service ref =   void $ forkIO $ do-    threadDelay (1000 * batchWindowMs options)+    clockSleep (batchClock options) (1000 * batchWindowMs options)     pendings <- atomicModifyIORef' ref (\p -> (emptyBatch, p))     processCalls service pendings -addPending :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> PendingRequest a b -> m ()+addPending :: (Hashable a, MonadUnliftIO m) => BatchingOptions m -> MultiGetService m a b -> IORef (RequestBatch a b) -> PendingRequest a b -> m () addPending options service ref p@(SingleRequest request _) =   join $     atomicModifyIORef'@@ -74,14 +77,14 @@       ref       (\(RequestBatch ps rs) -> (RequestBatch (p : ps) (S.union rs requests), if null ps then startBatch options service ref else pure ())) -singleService :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> BasicService m a (Maybe b)+singleService :: (Hashable a, MonadUnliftIO m) => BatchingOptions m -> 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 :: (Hashable a, MonadUnliftIO m) => BatchingOptions -> MultiGetService m a b -> IORef (RequestBatch a b) -> MultiGetService m a b+multiService :: (Hashable a, MonadUnliftIO m) => BatchingOptions m -> 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@@ -91,7 +94,7 @@ -- | Function for constructing a batching service. batchingService   :: (Hashable a, MonadUnliftIO m, MonadIO n)-  => BatchingOptions+  => BatchingOptions m   -- ^ Options to configure the batched service.   -> MultiGetService m a b   -- ^ Service around which to batch requests.
src/Glue/CircuitBreaker.hs view
@@ -6,53 +6,186 @@ -- 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.+--+-- The breaker is closed until 'breakerFailureThreshold' counted failures have occurred (consecutive+-- failures by default, or failures within 'breakerFailureWindowMs'), then open for+-- 'breakerOpenDurationMs', refusing calls. After that the next call is admitted as the single+-- half-open trial: if it answers the breaker closes, if it fails the breaker opens again.+--+-- Only synchronous failures accepted by 'countsAsFailure' count. A call cancelled by an+-- asynchronous exception (a timeout, a losing hedge, a killed request thread) counts for nothing,+-- and the exception is rethrown. module Glue.CircuitBreaker   ( CircuitBreakerOptions (..)+  , defaultCircuitBreakerOptions+  , BreakerPhase (..)+  , BreakerTransition (..)+  , Admission (..)+  , Settlement (..)+  , BreakerStore (..)+  , newInMemoryBreakerStore   , CircuitBreakerState   , CircuitBreakerException (..)   , combineCircuitBreakerStates+  , circuitBreakerPhases   , isCircuitBreakerOpen   , isCircuitBreakerClosed-  , defaultCircuitBreakerOptions   , circuitBreaker+  , circuitBreakerEither+  , circuitBreakerWithStore+  , circuitBreakerEitherWithStore   ) where -import           Data.Time.Clock.POSIX (getPOSIXTime)+import           Data.Foldable      (traverse_)+import           Data.Int           (Int64)+import           Glue.Clock+import           Glue.Failure import           Glue.Types-import           UnliftIO              (Exception, MonadIO, MonadUnliftIO,-                                        liftIO)-import           UnliftIO.Exception    (bracketOnError, throwIO)-import           UnliftIO.IORef        (IORef, atomicModifyIORef', newIORef,-                                        readIORef, writeIORef)+import           UnliftIO           (Exception, MonadIO, MonadUnliftIO,+                                     SomeException)+import           UnliftIO.Exception (mask, onException, throwIO)+import           UnliftIO.IORef     (atomicModifyIORef', newIORef, readIORef)  -- | 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+data CircuitBreakerOptions m failure = CircuitBreakerOptions+  { breakerFailureThreshold :: Int+  -- ^ The circuit opens on this many counted failures, so @3@ opens on the third (minimum 1).+  , breakerFailureWindowMs  :: Maybe Int+  -- ^ 'Nothing' counts consecutive failures, a call that answers resetting the count.+  -- @'Just' w@ counts the failures within the last @w@ milliseconds instead.+  , breakerOpenDurationMs   :: Int+  -- ^ How long (in milliseconds) the circuit stays open before a single trial call is admitted.+  , breakerDescription      :: String   -- ^ Description that is attached to the failure so as to identify the particular circuit.+  , countsAsFailure         :: failure -> Bool+  -- ^ Whether a failure says something about the backend's health. Failures that don't count+  -- (a caller's own bad request, say) are treated as the backend having answered.+  , onBreakerTransition     :: BreakerTransition -> m ()+  -- ^ Called after each change of phase, for metrics and alerts.+  , breakerClock            :: Clock m+  -- ^ Clock used for the failure window and the open duration.   }-  deriving (Eq, Show) --- | Defaulted options for the circuit breaker with 3 failures over 60 seconds.-defaultCircuitBreakerOptions :: CircuitBreakerOptions+-- | Defaulted options for the circuit breaker: opens on the 3rd consecutive failure, for 60 seconds,+-- counting every failure.+defaultCircuitBreakerOptions :: MonadUnliftIO m => CircuitBreakerOptions m failure defaultCircuitBreakerOptions =   CircuitBreakerOptions-    { maxBreakerFailures = 3-    , resetTimeoutSecs   = 60-    , breakerDescription = "Circuit breaker open."+    { breakerFailureThreshold = 3+    , breakerFailureWindowMs  = Nothing+    , breakerOpenDurationMs   = 60000+    , breakerDescription      = "Circuit breaker open."+    , countsAsFailure         = const True+    , onBreakerTransition     = const (pure ())+    , breakerClock            = systemClock     } --- | Status indicating if the circuit is open.-data BreakerStatus-  = BreakerClosed Int-  | BreakerOpen Int+-- | The phase a circuit breaker is in.+data BreakerPhase+  = BreakerClosed+  -- ^ Calls pass through.+  | BreakerOpen+  -- ^ Calls are refused.+  | BreakerHalfOpen+  -- ^ A single trial call is in flight; other calls are refused.+  deriving (Eq, Ord, Show, Enum, Bounded)++-- | A change of phase.+data BreakerTransition = BreakerTransition+  { transitionFrom :: BreakerPhase+  , transitionTo   :: BreakerPhase+  }   deriving (Eq, Show) +-- | The store's decision about a call.+data Admission+  = AdmittedClosed+  -- ^ The circuit is closed and the call may proceed.+  | AdmittedTrial+  -- ^ The call is the single half-open trial.+  | Refused+  -- ^ The circuit is open (or a trial is in flight) and the call must not be made.+  deriving (Eq, Show)++-- | How an admitted call ended, as far as the breaker is concerned.+data Settlement+  = SettledAnswered+  -- ^ The backend answered: a success, or a failure that 'countsAsFailure' rejected.+  | SettledFailed+  -- ^ A counted failure.+  | SettledAbandoned+  -- ^ The call was cancelled (or threw outside the failure mode) and says nothing about health.+  deriving (Eq, Show)++-- | The state behind a circuit breaker, abstracted so that it can be shared between instances of a+-- horizontally scaled service (a database row updated with one conditional statement using the+-- database clock, say). Implementations must make 'admitCall' atomic so only one caller is ever+-- given 'AdmittedTrial' for a given half-open period.+data BreakerStore m = BreakerStore+  { admitCall  :: m (Admission, Maybe BreakerTransition)+  -- ^ Decide whether a call may proceed, reporting any phase change this caused.+  , settleCall :: Admission -> Settlement -> m (Maybe BreakerTransition)+  -- ^ Record how an admitted call ended, reporting any phase change this caused.+  , readPhase  :: m BreakerPhase+  -- ^ The current phase, for display.+  }++data MemoryState+  = MemoryClosed [Int64]+  -- ^ Times of the counted failures still in play, newest first.+  | MemoryOpen Int64+  -- ^ Open until the given time.+  | MemoryHalfOpen++-- | The in-memory t'BreakerStore' used by 'circuitBreaker', configured from the threshold, window,+-- open duration and clock in the options.+newInMemoryBreakerStore :: (MonadIO m, MonadIO n) => CircuitBreakerOptions m failure -> n (BreakerStore m)+newInMemoryBreakerStore options = do+  ref <- newIORef (MemoryClosed [])+  let clock       = breakerClock options+      threshold   = max 1 (breakerFailureThreshold options)+      openUs      = fromIntegral (max 0 (breakerOpenDurationMs options)) * 1000+      windowUs    = fmap (\w -> fromIntegral (max 0 w) * 1000) (breakerFailureWindowMs options)+      inWindow now = maybe id (\w -> takeWhile (> now - w)) windowUs+      transition from to = Just (BreakerTransition from to)+      admit now state = case state of+        MemoryClosed _ -> (state, (AdmittedClosed, Nothing))+        MemoryOpen until'+          | now >= until' -> (MemoryHalfOpen, (AdmittedTrial, transition BreakerOpen BreakerHalfOpen))+          | otherwise     -> (state, (Refused, Nothing))+        MemoryHalfOpen -> (state, (Refused, Nothing))+      settle now AdmittedClosed settlement (MemoryClosed failures) = case settlement of+        SettledAnswered+          | Just _ <- windowUs -> (MemoryClosed (inWindow now failures), Nothing)+          | otherwise          -> (MemoryClosed [], Nothing)+        SettledFailed ->+          let failures' = now : inWindow now failures+          in  if length failures' >= threshold+                then (MemoryOpen (now + openUs), transition BreakerClosed BreakerOpen)+                else (MemoryClosed failures', Nothing)+        SettledAbandoned -> (MemoryClosed failures, Nothing)+      settle now AdmittedTrial settlement MemoryHalfOpen = case settlement of+        SettledAnswered  -> (MemoryClosed [], transition BreakerHalfOpen BreakerClosed)+        SettledFailed    -> (MemoryOpen (now + openUs), transition BreakerHalfOpen BreakerOpen)+        -- The trial told us nothing, so the next call may try again straight away.+        SettledAbandoned -> (MemoryOpen now, transition BreakerHalfOpen BreakerOpen)+      settle _ _ _ state = (state, Nothing)+      phaseOf (MemoryClosed _) = BreakerClosed+      phaseOf (MemoryOpen _)   = BreakerOpen+      phaseOf MemoryHalfOpen   = BreakerHalfOpen+  pure+    BreakerStore+      { admitCall  = do+          now <- clockNow clock+          atomicModifyIORef' ref (admit now)+      , settleCall = \admission settlement -> do+          now <- clockNow clock+          atomicModifyIORef' ref (settle now admission settlement)+      , readPhase  = phaseOf <$> readIORef ref+      }+ -- | Representation of the state the circuit breaker is currently in.-newtype CircuitBreakerState = CircuitBreakerState [IORef BreakerStatus]+newtype CircuitBreakerState m = CircuitBreakerState [m BreakerPhase]  -- | Exception thrown when the circuit is open. newtype CircuitBreakerException = CircuitBreakerException String@@ -61,69 +194,97 @@ instance Exception CircuitBreakerException  -- | Combines multiple states together.-combineCircuitBreakerStates :: Foldable t => t CircuitBreakerState -> CircuitBreakerState-combineCircuitBreakerStates = CircuitBreakerState . foldMap (\(CircuitBreakerState refs) -> refs)+combineCircuitBreakerStates :: Foldable t => t (CircuitBreakerState m) -> CircuitBreakerState m+combineCircuitBreakerStates = CircuitBreakerState . foldMap (\(CircuitBreakerState phases) -> phases) --- | Determines if a specific status is open.-isStatusOpen :: BreakerStatus -> Bool-isStatusOpen (BreakerOpen _)   = True-isStatusOpen (BreakerClosed _) = False+-- | The phases of each breaker in the state.+circuitBreakerPhases :: Applicative m => CircuitBreakerState m -> m [BreakerPhase]+circuitBreakerPhases (CircuitBreakerState phases) = sequenceA phases --- | Determines if a specific status is closed.-isStatusClosed :: BreakerStatus -> Bool-isStatusClosed (BreakerOpen _)   = False-isStatusClosed (BreakerClosed _) = True+-- | Determines if a circuit breaker is open (or half-open) in any of its breakers.+isCircuitBreakerOpen :: Applicative m => CircuitBreakerState m -> m Bool+isCircuitBreakerOpen state = any (/= BreakerClosed) <$> circuitBreakerPhases state --- | Determines if a circuit breaker is open.-isCircuitBreakerOpen :: MonadIO m => CircuitBreakerState -> m Bool-isCircuitBreakerOpen (CircuitBreakerState states) =-  or <$> traverse (fmap isStatusOpen . readIORef) states+-- | Determines if a circuit breaker is closed in all of its breakers.+isCircuitBreakerClosed :: Applicative m => CircuitBreakerState m -> m Bool+isCircuitBreakerClosed state = all (== BreakerClosed) <$> circuitBreakerPhases state --- | Determines if a circuit breaker is closed.-isCircuitBreakerClosed :: MonadIO m => CircuitBreakerState -> m Bool-isCircuitBreakerClosed (CircuitBreakerState states) =-  and <$> traverse (fmap isStatusClosed . readIORef) states+breakerVia+  :: MonadUnliftIO m+  => FailureMode m r failure+  -> m r+  -> CircuitBreakerOptions m failure+  -> BreakerStore m+  -> BasicService m a r+  -> BasicService m a r+breakerVia mode refuse options store service request =+  mask $ \restore -> do+    let notify = traverse_ (onBreakerTransition options)+    (admission, admitted) <- admitCall store+    case admission of+      Refused -> restore (notify admitted >> refuse)+      _ -> do+        outcome <-+          restore (notify admitted >> observeCall mode (service request))+            `onException` (settleCall store admission SettledAbandoned >>= notify)+        let settlement = either (\f -> if countsAsFailure options f then SettledFailed else SettledAnswered) (const SettledAnswered) outcome+        settled <- settleCall store admission settlement+        restore $ do+          notify settled+          either (reraise mode) pure outcome  -- | Circuit breaking services can be constructed with this function. circuitBreaker   :: (MonadUnliftIO m, MonadIO n)-  => CircuitBreakerOptions+  => CircuitBreakerOptions m SomeException   -- ^ Options for specifying the circuit breaker behaviour.   -> BasicService m a b   -- ^ Service to protect with the circuit breaker.-  -> n (CircuitBreakerState, BasicService m a b)+  -> n (CircuitBreakerState m, 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)+  store <- newInMemoryBreakerStore options+  pure (CircuitBreakerState [readPhase store], circuitBreakerWithStore options store service)++-- | Variant of 'circuitBreaker' using the given store, for state shared between instances.+-- The threshold, window and open duration in the options are the store's concern and unused here.+circuitBreakerWithStore+  :: MonadUnliftIO m+  => CircuitBreakerOptions m SomeException+  -- ^ Options for specifying the circuit breaker behaviour.+  -> BreakerStore m+  -- ^ Store holding the breaker's state.+  -> BasicService m a b+  -- ^ Service to protect with the circuit breaker.+  -> BasicService m a b+circuitBreakerWithStore options =+  breakerVia exceptionFailures (throwIO $ CircuitBreakerException $ breakerDescription options) options++-- | Variant of 'circuitBreaker' for services returning failures as values: @Left@ results are+-- classified by 'countsAsFailure', and a refused call returns the given failure instead of throwing.+circuitBreakerEither+  :: (MonadUnliftIO m, MonadIO n)+  => CircuitBreakerOptions m e+  -- ^ Options for specifying the circuit breaker behaviour ('breakerDescription' is unused).+  -> e+  -- ^ Failure returned when the circuit is open.+  -> EitherService m a e b+  -- ^ Service to protect with the circuit breaker.+  -> n (CircuitBreakerState m, EitherService m a e b)+circuitBreakerEither options onBreakerOpen service = do+  store <- newInMemoryBreakerStore options+  pure (CircuitBreakerState [readPhase store], circuitBreakerEitherWithStore options store onBreakerOpen service)++-- | Variant of 'circuitBreakerEither' using the given store.+circuitBreakerEitherWithStore+  :: MonadUnliftIO m+  => CircuitBreakerOptions m e+  -- ^ Options for specifying the circuit breaker behaviour.+  -> BreakerStore m+  -- ^ Store holding the breaker's state.+  -> e+  -- ^ Failure returned when the circuit is open.+  -> EitherService m a e b+  -- ^ Service to protect with the circuit breaker.+  -> EitherService m a e b+circuitBreakerEitherWithStore options store onBreakerOpen =+  breakerVia valueFailures (pure (Left onBreakerOpen)) options store
src/Glue/ConcurrencyLimit.hs view
@@ -3,18 +3,39 @@  -- | Module supporting concurrent request constraints (bulkhead pattern), -- limiting the number of in-flight and optionally queued requests.+--+-- 'limitConcurrency' caps one wrapped service. 'limitConcurrencyBy' keeps one limit per key (a+-- backend or target, say) and refuses a full key at once with v'Saturated' as a value, so a full+-- guard can feed a fallback without exception plumbing and is never mistaken for a backend failure.+-- A slot covers the wrapped call only; 'withSlot' holds one for work that outlives the call, such+-- as a streamed response consumed later. module Glue.ConcurrencyLimit   ( ConcurrencyLimitOptions (..)   , ConcurrencyLimitExceededException (..)   , defaultConcurrencyLimitOptions   , limitConcurrency+  , limitConcurrencyEither+    -- * Keyed limits+  , Saturated (..)+  , AdmissionLease (..)+  , AdmissionStore (..)+  , newInMemoryAdmissionStore+  , withSlot+  , limitConcurrencyBy+  , limitConcurrencyByWithStore+  , limitConcurrencyByEither   ) where +import           Data.Foldable       (traverse_)+import           Data.Hashable       (Hashable)+import qualified Data.HashMap.Strict as M+import           Glue.Failure import           Glue.Types-import           UnliftIO           (Exception, MonadIO, MonadUnliftIO)-import           UnliftIO.Exception (bracketOnError, finally, throwIO)-import           UnliftIO.STM       (atomically, modifyTVar', newTVarIO,-                                     readTVar, retrySTM, writeTVar)+import           UnliftIO            (Exception, MonadIO, MonadUnliftIO)+import           UnliftIO.Exception  (bracket, bracketOnError, finally,+                                      throwIO)+import           UnliftIO.STM        (atomically, modifyTVar', newTVarIO,+                                      readTVar, retrySTM, writeTVar)  -- | Options for configuring concurrent request limits. data ConcurrencyLimitOptions = ConcurrencyLimitOptions@@ -42,15 +63,13 @@  instance Exception ConcurrencyLimitExceededException --- | Wraps a service with a concurrency limit restricting in-flight and queued requests.-limitConcurrency+limitVia   :: (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+  => m r+  -> ConcurrencyLimitOptions+  -> BasicService m a r+  -> n (BasicService m a r)+limitVia refuse options service = do   let maxActive = max 1 (maxInFlight options)   inFlightVar <- newTVarIO (0 :: Int)   queuedVar   <- newTVarIO (0 :: Int)@@ -77,11 +96,126 @@                 writeTVar queuedVar (queued + 1)                 pure (Right False)         case admission of-          Left () ->-            throwIO $ ConcurrencyLimitExceededException (concurrencyLimitDescription options)+          Left () -> refuse           Right True ->             finally (service request) releaseInFlight           Right False -> do             bracketOnError (pure ()) (\_ -> releaseQueued) (\_ -> waitForSlot)             finally (service request) releaseInFlight   pure limitedService++-- | Wraps a service with a concurrency limit restricting in-flight and queued requests.+limitConcurrency+  :: (MonadUnliftIO m, MonadIO n)+  => ConcurrencyLimitOptions+  -- ^ Options configuring the concurrency and queue bounds.+  -> BasicService m a b+  -- ^ Service to protect.+  -> n (BasicService m a b)+limitConcurrency options =+  limitVia (throwIO $ ConcurrencyLimitExceededException (concurrencyLimitDescription options)) options++-- | Variant of 'limitConcurrency' for services returning failures as values, returning the given+-- failure when both limits are reached instead of throwing.+limitConcurrencyEither+  :: (MonadUnliftIO m, MonadIO n)+  => ConcurrencyLimitOptions+  -- ^ Options configuring the concurrency and queue bounds ('concurrencyLimitDescription' is unused).+  -> e+  -- ^ Failure returned when the limits are reached.+  -> EitherService m a e b+  -- ^ Service to protect.+  -> n (EitherService m a e b)+limitConcurrencyEither options onSaturated = limitVia (pure (Left onSaturated)) options++-- | Refusal by a keyed limit whose slots are all in use.+data Saturated = Saturated+  deriving (Eq, Show)++-- | A claimed slot, released exactly once by 'withSlot'.+newtype AdmissionLease m = AdmissionLease+  { releaseLease :: m ()+  }++-- | Where keyed slots are counted. The in-memory store is the default; an implementation backed by+-- shared storage (claim, renew and release of expiring leases, say) gives limits shared across+-- instances, renewing its own leases for as long as they are held.+data AdmissionStore m k = AdmissionStore+  { claimSlot   :: k -> m (Maybe (AdmissionLease m))+  -- ^ Claims a slot for the key, or 'Nothing' if its limit is reached. Must be atomic.+  , slotsInUse  :: k -> m Int+  -- ^ Slots currently held for the key.+  , trackedKeys :: m Int+  -- ^ Number of keys with slots held. Keys with nothing in flight are not kept.+  }++-- | An in-memory store with the given limit per key (a limit of zero or less refuses every call).+newInMemoryAdmissionStore :: (Hashable k, MonadIO m, MonadIO n) => (k -> Int) -> n (AdmissionStore m k)+newInMemoryAdmissionStore limitFor = do+  countsVar <- newTVarIO M.empty+  let release key = atomically $ modifyTVar' countsVar (M.update (\n -> if n <= 1 then Nothing else Just (n - 1)) key)+      claim key = atomically $ do+        counts <- readTVar countsVar+        let current = M.findWithDefault 0 key counts+        if current < limitFor key+          then do+            writeTVar countsVar (M.insert key (current + 1) counts)+            pure (Just (AdmissionLease (release key)))+          else pure Nothing+  pure+    AdmissionStore+      { claimSlot   = claim+      , slotsInUse  = \key -> atomically $ M.findWithDefault 0 key <$> readTVar countsVar+      , trackedKeys = atomically $ M.size <$> readTVar countsVar+      }++-- | Runs the work holding a slot for the key, or returns v'Saturated' at once if none is free.+-- The slot is released on every exit path, including asynchronous cancellation.+withSlot :: MonadUnliftIO m => AdmissionStore m k -> k -> m r -> m (Either Saturated r)+withSlot store key work =+  bracket (claimSlot store key) (traverse_ releaseLease) $+    maybe (pure (Left Saturated)) (const (Right <$> work))++-- | Wraps a service with one concurrency limit per key, refusing a call whose key is full with+-- v'Saturated' instead of queueing it.+limitConcurrencyBy+  :: (MonadUnliftIO m, MonadIO n, Hashable k)+  => (a -> k)+  -- ^ The key a request is limited under.+  -> (k -> Int)+  -- ^ The limit for each key.+  -> BasicService m a b+  -- ^ Service to protect.+  -> n (BasicService m a (Either Saturated b))+limitConcurrencyBy keyOf limitFor service = do+  store <- newInMemoryAdmissionStore limitFor+  pure (limitConcurrencyByWithStore store keyOf service)++-- | Variant of 'limitConcurrencyBy' counting slots in the given store.+limitConcurrencyByWithStore+  :: MonadUnliftIO m+  => AdmissionStore m k+  -- ^ Store counting the slots.+  -> (a -> k)+  -- ^ The key a request is limited under.+  -> BasicService m a b+  -- ^ Service to protect.+  -> BasicService m a (Either Saturated b)+limitConcurrencyByWithStore store keyOf service request = withSlot store (keyOf request) (service request)++-- | Variant of 'limitConcurrencyBy' for services returning failures as values, returning the given+-- failure when the key is full.+limitConcurrencyByEither+  :: (MonadUnliftIO m, MonadIO n, Hashable k)+  => (a -> k)+  -- ^ The key a request is limited under.+  -> (k -> Int)+  -- ^ The limit for each key.+  -> e+  -- ^ Failure returned when the key is full.+  -> EitherService m a e b+  -- ^ Service to protect.+  -> n (EitherService m a e b)+limitConcurrencyByEither keyOf limitFor onSaturated service = do+  limited <- limitConcurrencyBy keyOf limitFor service+  pure $ fmap (either (const (Left onSaturated)) id) . limited
src/Glue/DogpileProtection.hs view
@@ -13,27 +13,29 @@ import           Control.Monad       (void) import           Data.Hashable       (Hashable) import qualified Data.HashMap.Strict as M+import           Glue.Clock import           Glue.Types-import           UnliftIO            (MonadIO, MonadUnliftIO, SomeException,-                                      liftIO)-import           UnliftIO.Concurrent (forkIO, threadDelay)+import           UnliftIO            (MonadIO, MonadUnliftIO, SomeException)+import           UnliftIO.Concurrent (forkIO) import           UnliftIO.Exception  (onException, throwIO, toException, tryAny) import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef) import           UnliftIO.MVar       (MVar, newEmptyMVar, putMVar, readMVar,                                       tryPutMVar)  -- | Options for configuring dogpile protection.-newtype DogpileOptions = DogpileOptions+data DogpileOptions m = DogpileOptions   { dogpileCacheTimeMs :: Int   -- ^ How long (in milliseconds) to retain a completed result for subsequent callers.+  , dogpileClock       :: Clock m+  -- ^ Clock used to expire retained results.   }-  deriving (Eq, Show)  -- | Default options for dogpile protection, caching completed values for 5 seconds (5000ms).-defaultDogpileOptions :: DogpileOptions+defaultDogpileOptions :: MonadUnliftIO m => DogpileOptions m defaultDogpileOptions =   DogpileOptions     { dogpileCacheTimeMs = 5000+    , dogpileClock       = systemClock     }  data DogpileResult b@@ -51,7 +53,7 @@ dogpileProtectWith   :: forall m n a b.      (MonadUnliftIO m, MonadIO n, Hashable a)-  => DogpileOptions+  => DogpileOptions m   -- ^ Options configuring dogpile protection.   -> BasicService m a b   -- ^ The service to protect.@@ -76,8 +78,8 @@             if ttlMs > 0               then do                 atomicModifyIORef' mapRef (\m -> (M.insert request (CachedValue result) m, ()))-                void $ liftIO $ forkIO $ do-                  threadDelay (ttlMs * 1000)+                void $ forkIO $ do+                  clockSleep (dogpileClock options) (ttlMs * 1000)                   atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))               else                 atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))
src/Glue/Failover.hs view
@@ -1,15 +1,27 @@ {-# LANGUAGE FlexibleContexts    #-} {-# 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 supporting failover: either re-invoking one service with a transformed request each+-- time a failure occurs ('failover'), or trying an ordered list of distinct backends, a primary+-- then its backups, until one answers ('failoverAcross'). module Glue.Failover   ( FailoverOptions (..)   , defaultFailoverOptions   , failover+  , FailoverAcrossOptions (..)+  , ReportedFailure (..)+  , defaultFailoverAcrossOptions+  , failoverAcross+  , failoverAcrossIndexed+  , failoverAcrossEither+  , failoverAcrossIndexedEither   ) where +import           Data.List.NonEmpty (NonEmpty (..))+import           Data.Maybe         (fromMaybe)+import           Glue.Failure import           Glue.Types-import           UnliftIO           (MonadUnliftIO)+import           UnliftIO           (MonadUnliftIO, SomeException) import           UnliftIO.Exception (catchAny)  -- | Options for determining behaviour of failover services.@@ -44,3 +56,93 @@               then invoke               else catchAny invoke afterFail   in  invokeService 0++-- | Which failure is reported when every backend has failed.+data ReportedFailure+  = ReportFirstFailure+  -- ^ The primary's failure.+  | ReportLastFailure+  -- ^ The last backup's failure.+  deriving (Eq, Show)++-- | Options for failing over across distinct backends.+data FailoverAcrossOptions failure = FailoverAcrossOptions+  { advanceOn       :: failure -> Bool+  -- ^ Whether a failure moves on to the next backend. A failure that doesn't (an invalid request,+  -- say, which a backup would only repeat) stops the chain and is reported at once.+  , reportedFailure :: ReportedFailure+  -- ^ Which failure is reported when every backend has failed.+  }++-- | Defaults that advance on every failure and report the last one.+defaultFailoverAcrossOptions :: FailoverAcrossOptions failure+defaultFailoverAcrossOptions =+  FailoverAcrossOptions+    { advanceOn       = const True+    , reportedFailure = ReportLastFailure+    }++failoverAcrossVia+  :: Monad m+  => FailureMode m r failure+  -> FailoverAcrossOptions failure+  -> NonEmpty (BasicService m a r)+  -> BasicService m a (Int, r)+failoverAcrossVia mode options (primary :| backups) request = go 0 Nothing primary backups+  where+    report index failure = (,) index <$> reraise mode failure+    go index firstFailure service rest = do+      outcome <- observeCall mode (service request)+      case outcome of+        Right result -> pure (index, result)+        Left failure+          | not (advanceOn options failure) -> report index failure+          | otherwise -> case rest of+              next : rest' -> go (index + 1) (Just (fromMaybe (index, failure) firstFailure)) next rest'+              [] -> case (reportedFailure options, firstFailure) of+                (ReportFirstFailure, Just (firstIndex, first)) -> report firstIndex first+                _                                              -> report index failure++-- | Creates a service that tries each backend in order and returns the first success.+-- Asynchronous exceptions always propagate and never advance to the next backend.+failoverAcross+  :: MonadUnliftIO m+  => FailoverAcrossOptions SomeException+  -- ^ Options deciding which failures advance and which failure is reported.+  -> NonEmpty (BasicService m a b)+  -- ^ The backends, primary first.+  -> BasicService m a b+failoverAcross options services = fmap snd . failoverAcrossIndexed options services++-- | Variant of 'failoverAcross' that also reports the index of the backend that answered,+-- for tracing and billing.+failoverAcrossIndexed+  :: MonadUnliftIO m+  => FailoverAcrossOptions SomeException+  -- ^ Options deciding which failures advance and which failure is reported.+  -> NonEmpty (BasicService m a b)+  -- ^ The backends, primary first.+  -> BasicService m a (Int, b)+failoverAcrossIndexed = failoverAcrossVia exceptionFailures++-- | Variant of 'failoverAcross' for services returning failures as values: @Left@ results are+-- judged by 'advanceOn'. Exceptions are not caught and do not advance.+failoverAcrossEither+  :: Monad m+  => FailoverAcrossOptions e+  -- ^ Options deciding which failures advance and which failure is reported.+  -> NonEmpty (EitherService m a e b)+  -- ^ The backends, primary first.+  -> EitherService m a e b+failoverAcrossEither options services = fmap (fmap snd) . failoverAcrossIndexedEither options services++-- | Variant of 'failoverAcrossEither' that also reports the index of the backend that answered.+failoverAcrossIndexedEither+  :: Monad m+  => FailoverAcrossOptions e+  -- ^ Options deciding which failures advance and which failure is reported.+  -> NonEmpty (EitherService m a e b)+  -- ^ The backends, primary first.+  -> EitherService m a e (Int, b)+failoverAcrossIndexedEither options services =+  fmap (\(index, result) -> (,) index <$> result) . failoverAcrossVia valueFailures options services
src/Glue/Hedging.hs view
@@ -1,81 +1,176 @@ {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE ScopedTypeVariables #-} --- | Module supporting request hedging (duplication), dispatching multiple concurrent--- requests (optionally staggered) and completing with the first successful result.+-- | Module supporting request hedging: dispatching further attempts if earlier ones haven't+-- answered within a delay, and completing with the first successful result.+--+-- 'hedgeAcross' races distinct backends, a primary then its backups, leaving the caller in control+-- of whether each backup may launch and informed of how every attempt ended. 'hedgingService'+-- duplicates the same call to one replicated backend. Both are built on "Glue.Race", which callers+-- with their own launch policy can drive directly. module Glue.Hedging-  ( HedgingOptions (..)+  ( HedgeOptions (..)+  , defaultHedgeOptions+  , hedgeAcross+  , hedgeAcrossEither+  , HedgingOptions (..)   , defaultHedgingOptions   , hedgingService+  , AttemptOutcome (..)   ) where -import           Control.Monad       (when)-import           Data.Maybe          (fromMaybe)+import           Data.Foldable      (traverse_)+import           Data.List.NonEmpty (NonEmpty (..))+import           Data.Maybe         (fromMaybe)+import           Glue.Clock+import           Glue.Failure+import           Glue.Race 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)+import           UnliftIO           (MonadUnliftIO, SomeException)+import           UnliftIO.Exception (throwIO) +-- | Options for hedging across distinct backends.+data HedgeOptions m failure r = HedgeOptions+  { hedgeDelayMs   :: Int+  -- ^ How long (in milliseconds) to wait for an attempt to end before launching the next backend.+  -- A failed attempt launches the next backend straight away.+  , admitLaunch    :: Int -> m Bool+  -- ^ Asked before launching the backend with this index (from 1; the primary always launches).+  -- Declining skips that backend without failing the attempts already running; the next backend+  -- is considered after a further delay.+  , onAttemptEnded :: Int -> AttemptOutcome failure r -> m ()+  -- ^ Told how each launched attempt ended, including losers that were cancelled, once each has+  -- actually finished.+  , onHedgeWon     :: Int -> m ()+  -- ^ Told which attempt's answer was returned.+  , hedgeClock     :: Clock m+  -- ^ Clock used for the delays.+  }++-- | Defaults launching each backend 100ms after the previous one with no further control.+defaultHedgeOptions :: MonadUnliftIO m => HedgeOptions m failure r+defaultHedgeOptions =+  HedgeOptions+    { hedgeDelayMs   = 100+    , admitLaunch    = const (pure True)+    , onAttemptEnded = \_ _ -> pure ()+    , onHedgeWon     = const (pure ())+    , hedgeClock     = systemClock+    }++-- | Thrown if a hedge ends with no outcome to report, which the race guarantees cannot happen.+noOutcome :: MonadUnliftIO m => m a+noOutcome = throwIO (userError "Glue.Hedging: every attempt ended without an outcome")++hedgeVia+  :: forall m a r failure.+     MonadUnliftIO m+  => FailureMode m r failure+  -> HedgeOptions m failure r+  -> NonEmpty (BasicService m a r)+  -> BasicService m a r+hedgeVia mode options (primary :| backups) request =+  withRace mode (hedgeClock options) $ \race -> do+    let finishLosers = abandonAll race >>= traverse_ (uncurry (onAttemptEnded options))++        launchNext :: [(Int, BasicService m a r)] -> m ([(Int, BasicService m a r)], Bool)+        launchNext []                      = pure ([], False)+        launchNext ((index, service) : rest) = do+          admitted <- admitLaunch options index+          if admitted+            then launch race (service request) >> pure (rest, True)+            else pure (rest, False)++        launchUntilRunning pending = do+          (pending', launched) <- launchNext pending+          if launched || null pending' then pure pending' else launchUntilRunning pending'++        loop :: [(Int, BasicService m a r)] -> Maybe failure -> m r+        loop pending firstFailure = do+          event <- awaitNext race (if null pending then Nothing else Just (hedgeDelayMs options))+          case event of+            AttemptEnded index outcome@(AttemptAnswered result) -> do+              onAttemptEnded options index outcome+              finishLosers+              onHedgeWon options index+              pure result+            AttemptEnded index outcome@(AttemptFailed failure) -> do+              onAttemptEnded options index outcome+              (pending', _) <- launchNext pending+              loop pending' (Just (fromMaybe failure firstFailure))+            AttemptEnded index outcome@(AttemptCrashed e) -> do+              onAttemptEnded options index outcome+              finishLosers+              throwIO e+            AttemptEnded index outcome@AttemptCancelled -> do+              onAttemptEnded options index outcome+              loop pending firstFailure+            RaceTimedOut -> do+              (pending', _) <- launchNext pending+              loop pending' firstFailure+            NothingRunning+              | null pending -> maybe noOutcome (reraise mode) firstFailure+              | otherwise    -> launchUntilRunning pending >>= \pending' -> loop pending' firstFailure++    _ <- launch race (primary request)+    loop (zip [1 ..] backups) Nothing++-- | Races distinct backends: the primary is launched at once and each backup after a further+-- 'hedgeDelayMs' if no attempt has ended (or straight away when an attempt fails). The first success is returned and the other attempts are cancelled, waiting until they+-- have finished. Attempts ending together are decided by their order in the list. If every attempt+-- fails, the first failure is rethrown.+hedgeAcross+  :: MonadUnliftIO m+  => HedgeOptions m SomeException b+  -- ^ Options configuring the delay and the caller's control over launches and outcomes.+  -> NonEmpty (BasicService m a b)+  -- ^ The backends, primary first.+  -> BasicService m a b+hedgeAcross = hedgeVia exceptionFailures++-- | Variant of 'hedgeAcross' for services returning failures as values: a @Left@ result is a+-- failed attempt, and the first @Left@ is returned if every attempt fails.+hedgeAcrossEither+  :: MonadUnliftIO m+  => HedgeOptions m e (Either e b)+  -- ^ Options configuring the delay and the caller's control over launches and outcomes.+  -> NonEmpty (EitherService m a e b)+  -- ^ The backends, primary first.+  -> EitherService m a e b+hedgeAcrossEither = hedgeVia valueFailures+ -- | Options for configuring hedged (duplicated) service calls.-data HedgingOptions = HedgingOptions+data HedgingOptions m = 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).+  , hedgingClock   :: Clock m+  -- ^ Clock used for the stagger delay.   }-  deriving (Eq, Show)  -- | Default hedging options that fire 2 concurrent requests with no stagger delay.-defaultHedgingOptions :: HedgingOptions+defaultHedgingOptions :: MonadUnliftIO m => HedgingOptions m defaultHedgingOptions =   HedgingOptions     { hedgingCount   = 2     , hedgingDelayMs = 0+    , hedgingClock   = systemClock     } -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+  :: MonadUnliftIO m+  => HedgingOptions m   -- ^ 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+hedgingService options service+  | hedgingCount options <= 1 = service+  | otherwise =+      hedgeAcross+        defaultHedgeOptions { hedgeDelayMs = max 0 (hedgingDelayMs options), hedgeClock = hedgingClock options }+        (service :| replicate (hedgingCount options - 1) service)
src/Glue/LoadBalancer.hs view
@@ -1,16 +1,32 @@+{-# LANGUAGE BangPatterns        #-} {-# LANGUAGE FlexibleContexts    #-} {-# LANGUAGE ScopedTypeVariables #-} --- | Module providing round-robin load balancing across multiple services.+-- | Module providing load balancing across multiple services: round-robin, or a deterministic+-- weighted split keyed by a stable request key. module Glue.LoadBalancer   ( NoServicesAvailableException (..)   , roundRobinLoadBalancer   , roundRobinLoadBalancerNE+    -- * Weighted, keyed balancing+  , Weight+  , totalWeight+  , WeightError (..)+  , Salt (..)+  , saltFromBytes+  , validateWeights+  , bucketOf+  , chooseIndex+  , weightedBy+  , sipHash24   ) where +import           Data.Bits          (rotateL, shiftL, xor, (.|.))+import qualified Data.ByteString    as BS import           Data.Foldable      (toList) import           Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as NE+import           Data.Word          (Word64) import           Glue.Types import           UnliftIO           (Exception, MonadIO) import           UnliftIO.Exception (throwIO)@@ -49,3 +65,104 @@   case toList services of     []     -> pure $ const $ throwIO NoServicesAvailableException     (x:xs) -> roundRobinLoadBalancerNE (x :| xs)++-- | A backend's share of traffic in basis points: the weights of a split sum to 'totalWeight'.+type Weight = Int++-- | The sum every set of weights must have: 10000 basis points.+totalWeight :: Int+totalWeight = 10000++-- | Why a set of weights was rejected.+data WeightError+  = NegativeWeight Int Weight+  -- ^ The weight at this index is negative.+  | WeightsDoNotSumToTotal Int+  -- ^ The weights sum to this rather than 'totalWeight'.+  deriving (Eq, Show)++-- | A 128-bit SipHash key. Different salts give independent assignments, so two splits keyed on+-- the same request key never correlate.+data Salt = Salt Word64 Word64+  deriving (Eq, Show)++-- | Derives a salt from arbitrary bytes (a split's name, say) by hashing them under two fixed keys.+saltFromBytes :: BS.ByteString -> Salt+saltFromBytes bytes = Salt (sipHash24 0 0 bytes) (sipHash24 0 1 bytes)++-- | Checks that no weight is negative and that they sum to 'totalWeight'.+validateWeights :: NonEmpty Weight -> Either WeightError ()+validateWeights weights =+  case [(index, w) | (index, w) <- zip [0 ..] (toList weights), w < 0] of+    (index, w) : _ -> Left (NegativeWeight index w)+    []+      | sum weights /= totalWeight -> Left (WeightsDoNotSumToTotal (sum weights))+      | otherwise                  -> Right ()++-- | The bucket, from 0 to 'totalWeight' - 1, that a key falls in for a salt: the key's SipHash-2-4+-- under the salt, modulo 'totalWeight'. Reducing a 64-bit hash this way favours the lowest+-- @2^64 mod 10000 = 1616@ buckets by one value in about @1.8 * 10^15@, a negligible bias.+-- The bucket never depends on the weights.+bucketOf :: Salt -> BS.ByteString -> Int+bucketOf (Salt k0 k1) key = fromIntegral (sipHash24 k0 k1 key `mod` fromIntegral totalWeight)++-- | The index of the backend a key is assigned to: backends own consecutive ranges of buckets in+-- list order, sized by their weights. A key keeps its backend across instances and restarts, and+-- changing weights only moves the keys whose bucket changes owner. Weights should satisfy+-- 'validateWeights'; buckets beyond their sum go to the last backend.+chooseIndex :: Salt -> BS.ByteString -> NonEmpty Weight -> Int+chooseIndex salt key weights = go 0 0 (toList weights)+  where+    bucket = bucketOf salt key+    go index _ [_] = index+    go index start (w : ws)+      | bucket < start + max 0 w = index+      | otherwise                = go (index + 1) (start + max 0 w) ws+    go index _ [] = index++-- | Creates a service splitting traffic between backends by weight, choosing each request's backend+-- from a stable key (a session id, else a request id) so the same key always reaches the same+-- backend. A failure of the chosen backend propagates; it is never redistributed (compose with+-- 'Glue.Failover.failoverAcross' for that).+weightedBy+  :: (a -> BS.ByteString)+  -- ^ The request's stable key.+  -> Salt+  -- ^ The split's salt.+  -> NonEmpty (Weight, BasicService m a b)+  -- ^ Backends with their weights in basis points.+  -> Either WeightError (BasicService m a b)+weightedBy keyOf salt backends = do+  let weights  = fmap fst backends+      services = fmap snd backends+  validateWeights weights+  pure $ \request -> (services NE.!! chooseIndex salt (keyOf request) weights) request++-- | SipHash-2-4 of the bytes under the 128-bit key given as two little-endian words.+sipHash24 :: Word64 -> Word64 -> BS.ByteString -> Word64+sipHash24 k0 k1 message = finish (compress (initial, message))+  where+    initial = (k0 `xor` 0x736f6d6570736575, k1 `xor` 0x646f72616e646f6d, k0 `xor` 0x6c7967656e657261, k1 `xor` 0x7465646279746573)+    len = BS.length message+    compress (!v, bytes)+      | BS.length bytes >= 8 = compress (absorb v (word (BS.take 8 bytes)), BS.drop 8 bytes)+      | otherwise = absorb v (word bytes .|. (fromIntegral (len `mod` 256) `shiftL` 56))+    absorb (v0, v1, v2, v3) m =+      let (v0', v1', v2', v3') = sipRound (sipRound (v0, v1, v2, v3 `xor` m))+      in  (v0' `xor` m, v1', v2', v3')+    finish (v0, v1, v2, v3) =+      let (v0', v1', v2', v3') = iterate sipRound (v0, v1, v2 `xor` 0xff, v3) !! 4+      in  v0' `xor` v1' `xor` v2' `xor` v3'+    word = BS.foldr' (\b acc -> (acc `shiftL` 8) .|. fromIntegral b) 0+    sipRound (v0, v1, v2, v3) =+      let a0 = v0 + v1+          a1 = rotateL v1 13 `xor` a0+          a0' = rotateL a0 32+          a2 = v2 + v3+          a3 = rotateL v3 16 `xor` a2+          b0 = a0' + a3+          b3 = rotateL a3 21 `xor` b0+          b2 = a2 + a1+          b1 = rotateL a1 17 `xor` b2+          b2' = rotateL b2 32+      in  (b0, b1, b2', b3)
+ src/Glue/Periodic.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE FlexibleContexts    #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Module supporting background jobs run periodically (sweeping expired rows, flushing a queue,+-- polling for state) with a graceful stop.+--+-- Passes never overlap: the period is measured from the end of the previous pass. A pass that fails+-- with a synchronous exception is reported and the loop carries on; an asynchronous exception ends+-- the loop.+module Glue.Periodic+  ( PeriodicOptions (..)+  , PeriodicHandle (..)+  , defaultPeriodicOptions+  , periodically+  ) where++import           Control.Monad      (unless, void, when)+import           Data.Maybe         (isJust)+import           Glue.Clock+import           System.Random      (randomRIO)+import           UnliftIO           (MonadUnliftIO, SomeException, liftIO)+import           UnliftIO.Async     (async, cancel)+import           UnliftIO.Exception (finally, tryAny)+import           UnliftIO.STM       (atomically, modifyTVar', newTVarIO,+                                     readTVar, retrySTM, writeTVar)++-- | Options for a periodic job.+data PeriodicOptions m = PeriodicOptions+  { periodMs              :: Int+  -- ^ Time (in milliseconds) from the end of one pass to the start of the next.+  , runImmediately        :: Bool+  -- ^ Whether the first pass runs at once rather than after the first period.+  , periodJitterMs        :: Int+  -- ^ Up to this many milliseconds, chosen at random, are added to each period.+  , periodicRandom        :: Int -> m Int+  -- ^ Source of randomness for jitter: a uniformly random value between zero and the argument inclusive.+  , onPassFailed          :: SomeException -> m ()+  -- ^ Told of each pass that failed with a synchronous exception.+  , periodicGracePeriodMs :: Int+  -- ^ How long (in milliseconds) 'stopPeriodic' lets a running pass finish before cancelling it.+  , periodicClock         :: Clock m+  -- ^ Clock used for the period and grace period.+  }++-- | Defaults: every 60 seconds, first pass after one period, no jitter, failures ignored, and a+-- 5 second grace period.+defaultPeriodicOptions :: MonadUnliftIO m => PeriodicOptions m+defaultPeriodicOptions =+  PeriodicOptions+    { periodMs              = 60000+    , runImmediately        = False+    , periodJitterMs        = 0+    , periodicRandom        = \n -> liftIO $ randomRIO (0, max 0 n)+    , onPassFailed          = const (pure ())+    , periodicGracePeriodMs = 5000+    , periodicClock         = systemClock+    }++-- | Handle used to control a periodic job.+data PeriodicHandle m = PeriodicHandle+  { stopPeriodic    :: m Bool+  -- ^ Stops the job: no new pass starts, and a running pass gets 'periodicGracePeriodMs' to finish+  -- before it is cancelled. Returns 'True' if it stopped without cancelling a pass.+  , runNow          :: m Bool+  -- ^ Triggers a pass, queued behind any pass that is running, and waits for it to complete.+  -- Returns 'False' if the job stopped first.+  , passesCompleted :: m Int+  -- ^ Number of passes that have finished, whether they succeeded or failed.+  }++-- | Starts running the pass periodically in the background.+periodically :: forall m. MonadUnliftIO m => PeriodicOptions m -> m () -> m (PeriodicHandle m)+periodically options pass = do+  stoppingVar  <- newTVarIO False+  requestedVar <- newTVarIO (0 :: Int)+  runningVar   <- newTVarIO False+  completedVar <- newTVarIO (0 :: Int)+  endedVar     <- newTVarIO False+  let clock = periodicClock options++      waitForPeriod :: m ()+      waitForPeriod = do+        jitter <- if periodJitterMs options > 0 then periodicRandom options (periodJitterMs options) else pure 0+        let delayUs = (max 0 (periodMs options) + max 0 (min (periodJitterMs options) jitter)) * 1000+        void $ clockTimeout clock delayUs $ atomically $ do+          stopping  <- readTVar stoppingVar+          requested <- readTVar requestedVar+          unless (stopping || requested > 0) retrySTM++      runPass :: m ()+      runPass = do+        result <- tryAny pass `finally` atomically (writeTVar runningVar False >> modifyTVar' completedVar (+ 1))+        either (void . tryAny . onPassFailed options) pure result++      loop :: Bool -> m ()+      loop first = do+        unless (first && runImmediately options) waitForPeriod+        started <- atomically $ do+          stopping <- readTVar stoppingVar+          if stopping+            then pure False+            else do+              writeTVar runningVar True+              writeTVar requestedVar 0+              pure True+        when started $ runPass >> loop False++  worker <- async (loop True `finally` atomically (writeTVar endedVar True))+  pure+    PeriodicHandle+      { stopPeriodic = do+          atomically $ writeTVar stoppingVar True+          drained <- clockTimeout clock (max 0 (periodicGracePeriodMs options) * 1000) $ atomically $ do+            running <- readTVar runningVar+            when running retrySTM+          cancel worker+          pure (isJust drained)+      , runNow = do+          target <- atomically $ do+            stopping <- readTVar stoppingVar+            if stopping+              then pure Nothing+              else do+                running   <- readTVar runningVar+                completed <- readTVar completedVar+                modifyTVar' requestedVar (+ 1)+                pure (Just (completed + if running then 2 else 1))+          case target of+            Nothing -> pure False+            Just wanted -> atomically $ do+              completed <- readTVar completedVar+              ended     <- readTVar endedVar+              if completed >= wanted+                then pure True+                else if ended then pure False else retrySTM+      , passesCompleted = atomically $ readTVar completedVar+      }
src/Glue/Preload.hs view
@@ -19,11 +19,12 @@ import qualified Data.HashMap.Strict as M import qualified Data.HashSet        as S import           Data.Maybe          (fromJust)+import           Glue.Clock import           Glue.Types import           UnliftIO            (Exception, MonadIO, MonadUnliftIO,                                       SomeException (..), liftIO) import           UnliftIO.Async      (race_)-import           UnliftIO.Concurrent (forkIO, threadDelay)+import           UnliftIO.Concurrent (forkIO) import           UnliftIO.Exception  (throwIO) import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef,                                       readIORef)@@ -37,15 +38,18 @@   -- ^ Amount of time between refreshes.   , preloadingRun           :: MToIO m   -- ^ Get an 'IO' of the response for the caching.+  , preloadingClock         :: Clock m+  -- ^ Clock used to wait between refreshes.   }  -- | 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 :: MonadUnliftIO m => S.HashSet a -> MToIO m -> PreloadedOptions m a b defaultPreloadedOptions toPreload pRun =   PreloadedOptions     { preloadedKeys           = toPreload     , preloadingRefreshTimeMs = 30 * 1000     , preloadingRun           = pRun+    , preloadingClock         = systemClock     }  data PreloadedState a b@@ -105,7 +109,7 @@         continue <- liftIO $ preloadingRun runUpdate         if continue           then do-            stopped <- liftIO $ raceWait stopVar (preloadingRefreshTimeMs * 1000)+            stopped <- liftIO $ raceWait stopVar (preloadingRun (clockSleep preloadingClock (preloadingRefreshTimeMs * 1000)))             if stopped then pure () else updatePreloaded           else pure ()   let plService request = do@@ -123,9 +127,9 @@   void $ liftIO $ forkIO updatePreloaded   pure (plService, stop)   where-    raceWait :: MVar () -> Int -> IO Bool-    raceWait stopVar delayUs = do-      race_ (readMVar stopVar) (threadDelay delayUs)+    raceWait :: MVar () -> IO () -> IO Bool+    raceWait stopVar sleep = do+      race_ (readMVar stopVar) sleep       not <$> tryPutMVar stopVar ()  -- | Preloads a single parameter-less call.@@ -141,7 +145,7 @@   -> n (m b, () -> n ()) preloadingCall toIO timeBetweenRefreshes call = do   let multiGetService = basicToMultiGet (\_ -> call) :: MultiGetService m () b-  let options = PreloadedOptions (S.singleton ()) timeBetweenRefreshes toIO+  let options = (defaultPreloadedOptions (S.singleton ()) toIO) { preloadingRefreshTimeMs = timeBetweenRefreshes }   (preloading, stop) <- preloadingService options multiGetService   let preloadingBasicService = multiGetToBasic preloading   let preloadingCallResult = fromJust <$> preloadingBasicService ()
+ src/Glue/Race.hs view
@@ -0,0 +1,144 @@+{-# LANGUAGE FlexibleContexts    #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Module providing a low-level race of concurrent attempts, for callers that make their own+-- decisions about when to launch another attempt and what to do as each one ends (see+-- 'Glue.Hedging.hedgeAcross' for a ready-made policy built on it).+--+-- Attempts are numbered from 0 in launch order. 'awaitNext' reports each attempt's ending once,+-- earliest index first when several have ended. 'abandon' cancels an attempt and waits until it+-- has actually finished, reporting how it really ended: an attempt that completed just before the+-- cancel reports its result. Leaving 'withRace' abandons every attempt still running.+module Glue.Race+  ( Race+  , AttemptOutcome (..)+  , RaceEvent (..)+  , withRace+  , launch+  , awaitNext+  , abandon+  , abandonAll+  , finishedAttempts+  ) where++import           Control.Monad      (filterM)+import qualified Data.Map.Strict    as M+import           Data.Maybe         (catMaybes, fromMaybe)+import           Glue.Clock+import           Glue.Failure+import           UnliftIO           (Async, AsyncCancelled (..), MonadUnliftIO,+                                     SomeException, fromException)+import           UnliftIO.Async     (asyncWithUnmask, cancel, pollSTM,+                                     waitCatch)+import           UnliftIO.Exception (bracket, mask_)+import           UnliftIO.STM       (TVar, atomically, modifyTVar', newTVarIO,+                                     orElse, readTVar, retrySTM, stateTVar)++-- | How an attempt ended.+data AttemptOutcome failure r+  = AttemptAnswered r+  -- ^ The attempt returned a result.+  | AttemptFailed failure+  -- ^ The attempt failed, as seen by the race's failure mode.+  | AttemptCancelled+  -- ^ The attempt was abandoned before it ended.+  | AttemptCrashed SomeException+  -- ^ The attempt threw outside its failure mode: an asynchronous exception, or any exception+  -- from a service whose failures are values.+  deriving (Show)++-- | What 'awaitNext' observed.+data RaceEvent failure r+  = AttemptEnded Int (AttemptOutcome failure r)+  -- ^ The attempt with this index ended.+  | RaceTimedOut+  -- ^ The limit passed with no attempt ending.+  | NothingRunning+  -- ^ Every launched attempt has already been reported or abandoned.+  deriving (Show)++-- | A race of attempts, each producing an @r@ whose failures are seen through a t'FailureMode'.+data Race m failure r = Race+  { raceMode     :: FailureMode m r failure+  , raceClock    :: Clock m+  , raceRunning  :: TVar (M.Map Int (Async (Either failure r)))+  -- ^ Attempts not yet reported or abandoned.+  , raceEnded    :: TVar (M.Map Int (AttemptOutcome failure r))+  , raceNextId   :: TVar Int+  }++toOutcome :: Either SomeException (Either failure r) -> AttemptOutcome failure r+toOutcome (Right (Right result)) = AttemptAnswered result+toOutcome (Right (Left failure)) = AttemptFailed failure+toOutcome (Left e) = case fromException e of+  Just AsyncCancelled -> AttemptCancelled+  Nothing             -> AttemptCrashed e++-- | Runs a race, abandoning any attempts still running when the body finishes or throws.+withRace :: MonadUnliftIO m => FailureMode m r failure -> Clock m -> (Race m failure r -> m x) -> m x+withRace mode clock = bracket create abandonAll+  where+    create = Race mode clock <$> newTVarIO M.empty <*> newTVarIO M.empty <*> newTVarIO 0++-- | Launches an attempt, returning its index.+launch :: MonadUnliftIO m => Race m failure r -> m r -> m Int+launch race action = mask_ $ do+  attempt <- asyncWithUnmask $ \unmask -> unmask (observeCall (raceMode race) action)+  atomically $ do+    index <- stateTVar (raceNextId race) (\i -> (i, i + 1))+    modifyTVar' (raceRunning race) (M.insert index attempt)+    pure index++-- | Waits for the next attempt to end, for at most the given number of milliseconds if given+-- (zero or less only checks). When several have ended, the lowest index is reported first.+awaitNext :: MonadUnliftIO m => Race m failure r -> Maybe Int -> m (RaceEvent failure r)+awaitNext race limitMs =+  case limitMs of+    Nothing -> atomically next+    Just ms+      | ms <= 0   -> atomically (next `orElse` pure RaceTimedOut)+      | otherwise -> fromMaybe RaceTimedOut <$> clockTimeout (raceClock race) (ms * 1000) (atomically next)+  where+    next = do+      running <- readTVar (raceRunning race)+      if M.null running+        then pure NothingRunning+        else do+          polled <- traverse (\(index, attempt) -> fmap ((,) index) <$> pollSTM attempt) (M.toAscList running)+          case catMaybes polled of+            [] -> retrySTM+            (index, result) : _ -> do+              let outcome = toOutcome result+              modifyTVar' (raceRunning race) (M.delete index)+              modifyTVar' (raceEnded race) (M.insert index outcome)+              pure (AttemptEnded index outcome)++-- | Cancels an attempt and waits until it has finished, returning how it really ended. An+-- attempt already reported returns its reported outcome; an index never launched reports+-- 'AttemptCancelled'.+abandon :: MonadUnliftIO m => Race m failure r -> Int -> m (AttemptOutcome failure r)+abandon race index = do+  (ended, running) <- atomically $ (,) <$> (M.lookup index <$> readTVar (raceEnded race))+                                       <*> (M.lookup index <$> readTVar (raceRunning race))+  case (ended, running) of+    (Just outcome, _)    -> pure outcome+    (Nothing, Nothing)   -> pure AttemptCancelled+    (Nothing, Just attempt) -> do+      cancel attempt+      outcome <- toOutcome <$> waitCatch attempt+      atomically $ do+        modifyTVar' (raceRunning race) (M.delete index)+        modifyTVar' (raceEnded race) (M.insert index outcome)+      pure outcome++-- | Abandons every attempt not yet reported, in index order, returning how each ended.+abandonAll :: MonadUnliftIO m => Race m failure r -> m [(Int, AttemptOutcome failure r)]+abandonAll race = do+  running <- atomically $ M.keys <$> readTVar (raceRunning race)+  traverse (\index -> (,) index <$> abandon race index) running++-- | Indices of attempts that have ended but not yet been reported by 'awaitNext'.+finishedAttempts :: MonadUnliftIO m => Race m failure r -> m [Int]+finishedAttempts race = atomically $ do+  running <- M.toAscList <$> readTVar (raceRunning race)+  fmap fst <$> filterM (fmap (maybe False (const True)) . pollSTM . snd) running
src/Glue/Retry.hs view
@@ -2,71 +2,147 @@ {-# LANGUAGE ScopedTypeVariables #-}  -- | Module containing retry functionality, allowing the construction of services that attempt multiple times in case of transient failure.+--+-- Whether to retry is decided by the failure ('retryOn') as well as the request ('retryAllowed').+-- Waits between attempts follow a 'Backoff', optionally capped and jittered, and a retry whose wait+-- would pass the caller's deadline ('retryRemainingMs') is skipped, returning the last failure.+-- Asynchronous exceptions are never retried. module Glue.Retry   ( RetryOptions (..)+  , Backoff (..)+  , Jitter (..)   , defaultRetryOptions+  , backoffDelayMs   , retryingService+  , retryingServiceEither   ) where -import           Control.Monad       (when)+import           Glue.Clock+import           Glue.Failure import           Glue.Types-import           UnliftIO            (MonadUnliftIO, SomeAsyncException,-                                      SomeException, fromException)-import           UnliftIO.Concurrent (threadDelay)-import           UnliftIO.Exception  (catch, throwIO)+import           System.Random      (randomRIO)+import           UnliftIO           (MonadUnliftIO, SomeException, liftIO) +-- | How the wait before each retry grows.+data Backoff+  = ConstantBackoff Int+  -- ^ The same wait (in milliseconds) before every retry.+  | LinearBackoff Int Int+  -- ^ An initial wait, growing by the second value (both in milliseconds) for each retry.+  | ExponentialBackoff Int Double+  -- ^ An initial wait in milliseconds, multiplied by the second value for each retry.+  deriving (Eq, Show)++-- | Randomisation of the wait, so that many clients don't retry in lockstep.+-- See <https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/>.+data Jitter+  = NoJitter+  -- ^ Wait exactly the backoff.+  | FullJitter+  -- ^ Wait a uniformly random time between zero and the backoff.+  | EqualJitter+  -- ^ Wait half the backoff plus a uniformly random time up to the other half.+  deriving (Eq, Show)+ -- | Options for determining behaviour of retrying services.-data RetryOptions a = RetryOptions-  { retryAllowed            :: a -> Bool+data RetryOptions m a failure = 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+  , retryOn          :: failure -> Bool+  -- ^ Predicate on the failure: retry an unreachable backend or a timeout, say, but not a rejected request.+  , 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.+  , retryBackoff     :: Backoff+  -- ^ How long to wait before each retry.+  , retryMaxDelayMs  :: Maybe Int+  -- ^ Ceiling on the wait before any retry, applied before jitter.+  , retryJitter      :: Jitter+  -- ^ Randomisation applied to the capped wait.+  , retryRandom      :: Int -> m Int+  -- ^ Source of randomness for jitter: a uniformly random value between zero and the argument inclusive.+  , retryRemainingMs :: m (Maybe Int)+  -- ^ Time left (in milliseconds) before the caller's deadline, if there is one. A retry whose wait+  -- would pass it is skipped. 'remainingUntil' builds this from a deadline on a t'Clock'.+  , onRetry          :: Int -> failure -> Int -> m ()+  -- ^ Called before each retry with the retry number (from 1), the failure and the chosen wait in milliseconds.+  , retryClock       :: Clock m+  -- ^ Clock used to wait between attempts.   } --- | Defaulted options for retrying 3 times with no wait time.-defaultRetryOptions :: RetryOptions a+-- | Defaulted options for retrying any failure 3 times with no wait time and no deadline.+defaultRetryOptions :: MonadUnliftIO m => RetryOptions m a failure defaultRetryOptions =   RetryOptions-    { retryAllowed            = const True-    , retryInitialWaitTimeMs  = 0-    , maximumRetries          = 3-    , retryWaitTimeMultiplier = 0+    { retryAllowed     = const True+    , retryOn          = const True+    , maximumRetries   = 3+    , retryBackoff     = ConstantBackoff 0+    , retryMaxDelayMs  = Nothing+    , retryJitter      = NoJitter+    , retryRandom      = \n -> liftIO $ randomRIO (0, max 0 n)+    , retryRemainingMs = pure Nothing+    , onRetry          = \_ _ _ -> pure ()+    , retryClock       = systemClock     } -possibleAsyncException :: SomeException -> Maybe SomeAsyncException-possibleAsyncException = fromException+-- | The un-jittered, uncapped wait in milliseconds before the retry with the given index (from 0).+backoffDelayMs :: Backoff -> Int -> Int+backoffDelayMs (ConstantBackoff delay) _ = max 0 delay+backoffDelayMs (LinearBackoff initial step) n = max 0 (initial + step * n)+backoffDelayMs (ExponentialBackoff initial multiplier) n =+  let factor  = if multiplier <= 0 then 1 else multiplier+      delay   = fromIntegral (max 0 initial) * factor ^ n :: Double+  in  if delay >= fromIntegral (maxBound :: Int) then maxBound else round delay +chooseDelay :: Monad m => RetryOptions m a failure -> Int -> m Int+chooseDelay options n = do+  let capped = maybe id (min . max 0) (retryMaxDelayMs options) (backoffDelayMs (retryBackoff options) n)+  case retryJitter options of+    NoJitter    -> pure capped+    FullJitter  -> clamp capped <$> retryRandom options capped+    EqualJitter -> do+      let half = capped `div` 2+      (+ half) . clamp (capped - half) <$> retryRandom options (capped - half)+  where+    clamp upper = max 0 . min upper++retryVia :: Monad m => FailureMode m r failure -> RetryOptions m a failure -> BasicService m a r -> BasicService m a r+retryVia mode options service request = attempt 0+  where+    attempt retryCount = do+      outcome <- observeCall mode (service request)+      case outcome of+        Right result -> pure result+        Left failure+          | retryCount < maximumRetries options && retryAllowed options request && retryOn options failure -> do+              delay     <- chooseDelay options retryCount+              remaining <- retryRemainingMs options+              if maybe False (\left -> left <= 0 || delay > left) remaining+                then reraise mode failure+                else do+                  onRetry options (retryCount + 1) failure delay+                  clockSleep (retryClock options) (delay * 1000)+                  attempt (retryCount + 1)+          | otherwise -> reraise mode failure+ -- | 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-  :: forall m a b.-     MonadUnliftIO m-  => RetryOptions a+  :: MonadUnliftIO m+  => RetryOptions m a SomeException   -- ^ 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 -> 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+retryingService = retryVia exceptionFailures++-- | Variant of 'retryingService' for services returning failures as values: @Left@ results are+-- judged by 'retryOn', and the last @Left@ is returned once retrying stops. Exceptions are not caught.+retryingServiceEither+  :: Monad m+  => RetryOptions m a e+  -- ^ Instance of t'RetryOptions' to configure the retry functionality.+  -> EitherService m a e b+  -- ^ The service to perform retries of.+  -> EitherService m a e b+retryingServiceEither = retryVia valueFailures
src/Glue/Shutdown.hs view
@@ -11,18 +11,21 @@   , ShutdownHandle (..)   , defaultShutdownOptions   , gracefulShutdown+  , gracefulShutdownEither   ) where +import           Data.Maybe         (fromMaybe)+import           Glue.Clock+import           Glue.Failure 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+data ShutdownOptions m b = ShutdownOptions   { shutdownFallback      :: Maybe b   -- ^ Optional default value returned for rejected requests after shutdown is initiated.   -- If 'Nothing', t'ServiceShuttingDownException' is thrown instead.@@ -30,16 +33,18 @@   -- ^ Maximum time in milliseconds to wait for existing in-flight requests to complete before cancelling them.   , shutdownDescription   :: String   -- ^ Description attached to t'ServiceShuttingDownException'.+  , shutdownClock         :: Clock m+  -- ^ Clock used to measure the grace period.   }-  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 :: MonadUnliftIO m => ShutdownOptions m b defaultShutdownOptions =   ShutdownOptions     { shutdownFallback      = Nothing     , shutdownGracePeriodMs = 5000     , shutdownDescription   = "Service is shutting down."+    , shutdownClock         = systemClock     }  -- | Exception thrown when a service is shutting down and no 'shutdownFallback' is configured.@@ -62,25 +67,42 @@  -- | Wraps a service with graceful shutdown support. gracefulShutdown-  :: forall m n a b.-     (MonadUnliftIO m, MonadIO n)-  => ShutdownOptions b+  :: (MonadUnliftIO m, MonadIO n)+  => ShutdownOptions m 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+gracefulShutdown options =+  shutdownVia (maybe (throwIO $ ServiceShuttingDownException (shutdownDescription options)) pure (shutdownFallback options)) options++-- | Variant of 'gracefulShutdown' for services returning failures as values: rejected calls+-- return 'shutdownFallback' if set, otherwise the given failure, instead of throwing.+gracefulShutdownEither+  :: (MonadUnliftIO m, MonadIO n)+  => ShutdownOptions m (Either e b)+  -- ^ Options configuring fallback behaviour and grace period ('shutdownDescription' is unused).+  -> e+  -- ^ Failure returned for rejected calls when no 'shutdownFallback' is set.+  -> EitherService m a e b+  -- ^ Service to wrap.+  -> n (EitherService m a e b, ShutdownHandle m)+gracefulShutdownEither options onShuttingDown =+  shutdownVia (pure (fromMaybe (Left onShuttingDown) (shutdownFallback options))) options++shutdownVia+  :: forall m n a b.+     (MonadUnliftIO m, MonadIO n)+  => m b+  -> ShutdownOptions m b+  -> BasicService m a b+  -> n (BasicService m a b, ShutdownHandle m)+shutdownVia rejectRequest 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 ()+  let waitForForceCancel :: m ()       waitForForceCancel = atomically $ do         cancelled <- readTVar forceCancelVar         if cancelled then pure () else retrySTM@@ -114,7 +136,7 @@           { initiateShutdown = do               atomically $ writeTVar shuttingDownVar True               let graceUs = max 0 (shutdownGracePeriodMs options) * 1000-              drained <- timeout graceUs waitDrained+              drained <- clockTimeout (shutdownClock options) graceUs waitDrained               case drained of                 Just () -> pure True                 Nothing -> do
src/Glue/Timeout.hs view
@@ -7,28 +7,32 @@   , TimeoutException (..)   , defaultTimeoutOptions   , addTimeout+  , addTimeoutEither   ) where +import           Glue.Clock+import           Glue.Failure 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+data TimeoutOptions m = TimeoutOptions   { timeoutDescription :: String   -- ^ Description added to the t'TimeoutException' thrown when the timeout is exceeded.   , timeoutLimitMs     :: Int   -- ^ Timeout in milliseconds.+  , timeoutClock       :: Clock m+  -- ^ Clock used to measure the timeout.   }-  deriving (Eq, Show)  -- | Default instance of t'TimeoutOptions' with a timeout of 30 seconds.-defaultTimeoutOptions :: TimeoutOptions+defaultTimeoutOptions :: MonadUnliftIO m => TimeoutOptions m defaultTimeoutOptions =   TimeoutOptions     { timeoutDescription = "Service call timed out."     , timeoutLimitMs     = 30000+    , timeoutClock       = systemClock     }  -- | Exception thrown when the timeout is exceeded.@@ -37,14 +41,30 @@  instance Exception TimeoutException +timeoutVia :: Monad m => m r -> TimeoutOptions m -> BasicService m a r -> BasicService m a r+timeoutVia onTimeout options service request = do+  maybeResult <- clockTimeout (timeoutClock options) (1000 * timeoutLimitMs options) (service request)+  maybe onTimeout pure maybeResult+ -- | Function for producing services protected with a timeout. addTimeout   :: MonadUnliftIO m-  => TimeoutOptions+  => TimeoutOptions m   -- ^ 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+addTimeout options = timeoutVia (throwIO $ TimeoutException $ timeoutDescription options) options++-- | Variant of 'addTimeout' for services returning failures as values, returning the given+-- failure when the timeout is exceeded instead of throwing.+addTimeoutEither+  :: MonadUnliftIO m+  => TimeoutOptions m+  -- ^ Options to configure the timeout ('timeoutDescription' is unused).+  -> e+  -- ^ Failure returned when the timeout is exceeded.+  -> EitherService m a e b+  -- ^ Service to protect with a timeout.+  -> EitherService m a e b+addTimeoutEither options onTimeout = timeoutVia (pure (Left onTimeout)) options
test/Glue/BatcherSpec.hs view
@@ -18,7 +18,7 @@         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+        (_, multiService) <- batchingService (defaultBatchingOptions { batchWindowMs = 2 }) service         let expectedResults = fmap serviceFunctionality requestsAsSets         mapConcurrently multiService requestsAsSets `shouldReturn` expectedResults @@ -26,6 +26,6 @@       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+        (singleService, _) <- batchingService (defaultBatchingOptions { batchWindowMs = 2 }) service         let expectedResults = fmap (\r -> Just (r * 2)) requests         mapConcurrently singleService requests `shouldReturn` expectedResults
test/Glue/CircuitBreakerSpec.hs view
@@ -2,52 +2,180 @@  module Glue.CircuitBreakerSpec (spec) where +import           Control.Concurrent.Async (async, cancel, wait)+import           Control.Monad            (forM, replicateM_) import           Data.IORef import           Glue.CircuitBreaker+import           Glue.Clock+import           Glue.Clock.Virtual import           Test.Hspec import           Test.QuickCheck-import           UnliftIO.Exception  (Exception, throwIO, try)+import           UnliftIO.Exception       (Exception, fromException, throwIO,+                                           try)+import           UnliftIO.STM             (atomically, newEmptyTMVarIO,+                                           putTMVar, readTMVar)  data CircuitBreakerTestException = CircuitBreakerTestException   deriving (Eq, Show)  instance Exception CircuitBreakerTestException -requests :: [Int]-requests = [1 .. 10]+newtype BadRequestException = BadRequestException Int+  deriving (Eq, Show) +instance Exception BadRequestException++type Outcome = Either CircuitBreakerException (Either CircuitBreakerTestException Int)++failing :: IORef Int -> Int -> IO Int+failing ref _ = atomicModifyIORef' ref (\c -> (c + 1, ())) >> throwIO CircuitBreakerTestException++callAll :: (Int -> IO Int) -> [Int] -> IO [Outcome]+callAll service = traverse (try . try . service)++options :: VirtualClock -> CircuitBreakerOptions IO failure+options vc = defaultCircuitBreakerOptions { breakerClock = virtualClock vc }++openFailure :: Outcome+openFailure = Left (CircuitBreakerException "Circuit breaker open.")++-- | A scripted outcome for comparing the exception and value shapes.+data Step = Succeed Int | Fail Bool | Wait Int+  deriving (Show)++stepValue :: Step -> Int+stepValue (Succeed n) = n+stepValue (Wait n)    = n+stepValue (Fail _)    = 0++-- | A failure carrying whether it should count against the breaker.+newtype StepFailure = StepFailure Bool+  deriving (Eq, Show)++instance Exception StepFailure++instance Arbitrary Step where+  arbitrary = oneof [Succeed <$> arbitrary, Fail <$> arbitrary, Wait <$> choose (0, 30)]+ spec :: Spec spec = do   describe "circuitBreaker" $ do-    it "Multiple failures prevent subsequent calls" $-      property $ \failureMax -> do-        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+    it "Opens on the failure number given by the threshold" $+      property $ \(threshold' :: Int) -> do+        let threshold = 1 + abs threshold' `mod` 5+        vc  <- newVirtualClock+        ref <- newIORef 0+        (s, service) <- circuitBreaker ((options vc) { breakerFailureThreshold = threshold }) (failing ref)+        results <- callAll service [1 .. 10]+        results `shouldBe` replicate threshold (Right (Left CircuitBreakerTestException)) ++ replicate (10 - threshold) openFailure         isCircuitBreakerOpen s `shouldReturn` True         isCircuitBreakerClosed s `shouldReturn` False-        readIORef ref `shouldReturn` (positiveFailureMax + 1)+        readIORef ref `shouldReturn` threshold      it "Successful calls pass straight through" $-      property $ \(failureMax :: Int, requestIDs :: [Int]) -> do-        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+      property $ \(requestIDs :: [Int]) -> do+        (s, service) <- circuitBreaker defaultCircuitBreakerOptions (\req -> pure (req + 1) :: IO Int)+        traverse service requestIDs `shouldReturn` fmap (+ 1) requestIDs         isCircuitBreakerClosed s `shouldReturn` True +    it "Counts consecutive failures, a success resetting the count" $ do+      vc <- newVirtualClock+      (s, service) <- circuitBreaker (options vc) $ \(req :: Int) ->+        if req > 0 then pure req else throwIO CircuitBreakerTestException+      _ <- callAll service [0, 0, 1, 0, 0, 1, 0, 0]+      isCircuitBreakerClosed s `shouldReturn` True+      _ <- callAll service [0]+      isCircuitBreakerOpen s `shouldReturn` True++    it "With a failure window, only failures within the window count" $ do+      vc  <- newVirtualClock+      ref <- newIORef 0+      (s, service) <- circuitBreaker ((options vc) { breakerFailureWindowMs = Just 1000 }) (failing ref)+      replicateM_ 5 $ do+        _ <- callAll service [1, 1]+        advanceClock vc 1000000+      isCircuitBreakerClosed s `shouldReturn` True+      _ <- callAll service [1, 1]+      advanceClock vc 999000+      _ <- callAll service [1]+      isCircuitBreakerOpen s `shouldReturn` True++    it "A call cancelled by an asynchronous exception does not count as a failure" $ do+      vc      <- newVirtualClock+      started <- newEmptyTMVarIO+      (s, service) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1 }) $ \(req :: Int) -> do+        atomically $ putTMVar started ()+        clockSleep (virtualClock vc) 1000000+        pure req+      call <- async (service 1)+      atomically $ readTMVar started+      cancel call+      isCircuitBreakerClosed s `shouldReturn` True++    it "Stays open for the open duration, then admits a single trial" $ do+      vc   <- newVirtualClock+      gate <- newEmptyTMVarIO+      healthy <- newIORef False+      let service (req :: Int) = do+            ok <- readIORef healthy+            if ok then atomically (readTMVar gate) >> pure req else throwIO CircuitBreakerTestException+      (s, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1, breakerOpenDurationMs = 500 }) service+      _ <- callAll breaker [1]+      advanceClock vc 499000+      callAll breaker [1] `shouldReturn` [openFailure]+      advanceClock vc 1000+      writeIORef healthy True+      trial <- async (breaker 7)+      let waitForTrial = circuitBreakerPhases s >>= \p -> if p == [BreakerHalfOpen] then pure () else waitForTrial+      waitForTrial+      callAll breaker [1] `shouldReturn` [openFailure]+      atomically $ putTMVar gate ()+      wait trial `shouldReturn` 7+      isCircuitBreakerClosed s `shouldReturn` True++    it "A failed trial opens the circuit again for the open duration" $ do+      vc  <- newVirtualClock+      ref <- newIORef 0+      (s, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1, breakerOpenDurationMs = 500 }) (failing ref)+      _ <- callAll breaker [1]+      advanceClock vc 500000+      callAll breaker [1, 1] `shouldReturn` [Right (Left CircuitBreakerTestException), openFailure]+      circuitBreakerPhases s `shouldReturn` [BreakerOpen]+      readIORef ref `shouldReturn` 2++    it "Failures rejected by countsAsFailure do not count" $ do+      vc <- newVirtualClock+      let counts e = maybe True (const False) (fromException e :: Maybe BadRequestException)+      (s, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1, countsAsFailure = counts }) $ \(req :: Int) ->+        throwIO (BadRequestException req) :: IO Int+      results <- forM [1 .. 5] (try . breaker)+      results `shouldBe` fmap (Left . BadRequestException) [1 .. 5]+      isCircuitBreakerClosed s `shouldReturn` True++    it "Reports each transition to the observer" $ do+      vc  <- newVirtualClock+      seen <- newIORef []+      ref <- newIORef 0+      healthy <- newIORef False+      let service (req :: Int) = readIORef healthy >>= \ok -> if ok then pure req else failing ref req+          observe t = atomicModifyIORef' seen (\ts -> (ts ++ [t], ()))+      (_, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 2, breakerOpenDurationMs = 10, onBreakerTransition = observe }) service+      _ <- callAll breaker [1, 1]+      advanceClock vc 10000+      _ <- callAll breaker [1]+      advanceClock vc 10000+      writeIORef healthy True+      _ <- callAll breaker [1]+      readIORef seen `shouldReturn`+        [ BreakerTransition BreakerClosed BreakerOpen+        , BreakerTransition BreakerOpen BreakerHalfOpen+        , BreakerTransition BreakerHalfOpen BreakerOpen+        , BreakerTransition BreakerOpen BreakerHalfOpen+        , BreakerTransition BreakerHalfOpen BreakerClosed+        ]+     it "combineCircuitBreakerStates reports open if any breaker is open" $ do-      let opts = defaultCircuitBreakerOptions { maxBreakerFailures = 0 }+      let opts = (defaultCircuitBreakerOptions :: CircuitBreakerOptions IO e) { breakerFailureThreshold = 1 }       (s1, svc1) <- circuitBreaker opts (\(_ :: Int) -> throwIO CircuitBreakerTestException :: IO Int)       (s2, _)    <- circuitBreaker opts (\(x :: Int) -> pure (x + 1) :: IO Int)       let combined = combineCircuitBreakerStates [s1, s2]@@ -56,3 +184,45 @@       _ <- try (svc1 1) :: IO (Either CircuitBreakerTestException Int)       isCircuitBreakerOpen combined `shouldReturn` True       isCircuitBreakerClosed combined `shouldReturn` False++  describe "circuitBreakerWithStore" $+    it "Never calls the service when the store refuses" $ do+      ref <- newIORef 0+      let store = BreakerStore (pure (Refused, Nothing)) (\_ _ -> pure Nothing) (pure BreakerOpen)+          breaker = circuitBreakerWithStore defaultCircuitBreakerOptions store (failing ref)+      callAll breaker [1, 2] `shouldReturn` [openFailure, openFailure]+      readIORef ref `shouldReturn` 0++  describe "circuitBreakerEither" $ do+    it "Counts Left failures and refuses with the given value" $ do+      vc <- newVirtualClock+      (s, breaker) <- circuitBreakerEither ((options vc) { breakerFailureThreshold = 2 }) "open" $ \(_ :: Int) ->+        pure (Left "down" :: Either String Int)+      traverse breaker [1, 2, 3] `shouldReturn` [Left "down", Left "down", Left "open"]+      isCircuitBreakerOpen s `shouldReturn` True++    it "Behaves like the exception shape for the same outcomes" $+      property $ \(steps :: [Step]) -> do+        vc1 <- newVirtualClock+        vc2 <- newVirtualClock+        let opts vc = (options vc) { breakerFailureThreshold = 2, breakerOpenDurationMs = 20 }+            countsException e = maybe True (\(StepFailure c) -> c) (fromException e)+            asException (Fail c) = throwIO (StepFailure c)+            asException step     = pure (stepValue step)+            asValue (Fail c) = pure (Left (Just c))+            asValue step     = pure (Right (stepValue step))+        (_, exceptional) <- circuitBreaker ((opts vc1) { countsAsFailure = countsException }) asException+        (_, valued)      <- circuitBreakerEither ((opts vc2) { countsAsFailure = or }) Nothing asValue+        let run step = do+              e <- try (try (exceptional step))+              v <- valued step+              case step of+                Wait n -> advanceClock vc1 (n * 1000) >> advanceClock vc2 (n * 1000)+                _      -> pure ()+              let normalised = case e of+                    Left (CircuitBreakerException _) -> Left Nothing+                    Right (Left (StepFailure c))     -> Left (Just c)+                    Right (Right n)                  -> Right n+              pure (normalised, v)+        pairs <- traverse run steps+        fmap fst pairs `shouldBe` fmap snd pairs
test/Glue/ConcurrencyLimitSpec.hs view
@@ -2,18 +2,24 @@  module Glue.ConcurrencyLimitSpec (spec) where -import           Control.Concurrent       (threadDelay)-import           Control.Concurrent.Async (async, mapConcurrently, wait)+import           Control.Concurrent       (threadDelay, yield)+import           Control.Concurrent.Async (async, cancel, mapConcurrently, poll,+                                           wait) import           Data.IORef import           Glue.ConcurrencyLimit import           Test.Hspec import           UnliftIO.Exception       (Exception, throwIO, try)+import           UnliftIO.STM             (atomically, newEmptyTMVarIO,+                                           putTMVar, readTMVar)  data ConcurrencyTestException = ConcurrencyTestException   deriving (Eq, Show)  instance Exception ConcurrencyTestException +waitUntil :: IO Bool -> IO ()+waitUntil condition = condition >>= \done -> if done then pure () else yield >> waitUntil condition+ spec :: Spec spec = do   describe "limitConcurrency" $ do@@ -40,21 +46,25 @@             , maxQueued                   = Just 1             , concurrencyLimitDescription = "Busy"             }-          service (req :: Int) = do-            threadDelay (60 * 1000)+      gate    <- newEmptyTMVarIO+      entered <- newIORef (0 :: Int)+      let service (req :: Int) = do+            atomicModifyIORef' entered (\n -> (n + 1, ()))+            atomically $ readTMVar gate             pure req       limited <- limitConcurrency opts service       a1 <- async (try (limited 1) :: IO (Either ConcurrencyLimitExceededException Int))-      threadDelay (10 * 1000)+      waitUntil ((== 1) <$> readIORef entered)+      -- With 1 in flight, one of these queues and the other is rejected at once.       a2 <- async (try (limited 2) :: IO (Either ConcurrencyLimitExceededException Int))-      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+      a3 <- async (try (limited 3) :: IO (Either ConcurrencyLimitExceededException Int))+      let rejected = fmap (maybe False (either (const False) (either (const True) (const False)))) . poll+      waitUntil ((||) <$> rejected a2 <*> rejected a3)+      atomically $ putTMVar gate ()+      wait a1 `shouldReturn` Right 1+      results <- traverse wait [a2, a3]+      length [() | Left (ConcurrencyLimitExceededException "Busy") <- results] `shouldBe` 1+      length [r | Right r <- results, r `elem` [2, 3]] `shouldBe` 1      it "Releases slot when underlying service throws an exception" $ do       let opts = defaultConcurrencyLimitOptions { maxInFlight = 1, maxQueued = Just 0 }@@ -63,3 +73,96 @@       limited <- limitConcurrency opts failingOrOk       limited 0 `shouldThrow` (== ConcurrencyTestException)       limited 5 `shouldReturn` 15++  describe "limitConcurrencyEither" $+    it "Returns the saturation failure as a value" $ do+      gate    <- newEmptyTMVarIO+      started <- newEmptyTMVarIO+      let opts = defaultConcurrencyLimitOptions { maxInFlight = 1, maxQueued = Just 0 }+      limited <- limitConcurrencyEither opts "busy" $ \(req :: Int) -> do+        atomically $ putTMVar started ()+        atomically $ readTMVar gate+        pure (Right req)+      first <- async (limited 1)+      atomically $ readTMVar started+      limited 2 `shouldReturn` (Left "busy" :: Either String Int)+      atomically $ putTMVar gate ()+      wait first `shouldReturn` Right 1++  describe "limitConcurrencyBy" $ do+    it "Refuses a second call on a key with a limit of one" $ do+      store <- newInMemoryAdmissionStore (const 1)+      gate  <- newEmptyTMVarIO+      let limited = limitConcurrencyByWithStore store (`div` 10) (\(req :: Int) -> atomically (readTMVar gate) >> pure req)+      first <- async (limited 1)+      waitUntil ((== 1) <$> slotsInUse store 0)+      limited 2 `shouldReturn` Left Saturated+      atomically $ putTMVar gate ()+      wait first `shouldReturn` Right 1+      limited 3 `shouldReturn` Right 3++    it "Keeps keys isolated" $ do+      store <- newInMemoryAdmissionStore (const 1)+      gate  <- newEmptyTMVarIO+      let limited = limitConcurrencyByWithStore store (`div` 10) (\(req :: Int) -> atomically (readTMVar gate) >> pure req)+      first <- async (limited 1)+      waitUntil ((== 1) <$> slotsInUse store 0)+      other <- async (limited 11)+      waitUntil ((== 1) <$> slotsInUse store 1)+      atomically $ putTMVar gate ()+      wait first `shouldReturn` Right 1+      wait other `shouldReturn` Right 11++    it "Uses a separate limit per key" $ do+      store <- newInMemoryAdmissionStore (\k -> if k == "wide" then 2 else 1)+      gate  <- newEmptyTMVarIO+      let limited = limitConcurrencyByWithStore store fst (\(_, n :: Int) -> atomically (readTMVar gate) >> pure n)+      a <- async (limited ("wide" :: String, 1))+      b <- async (limited ("wide", 2))+      waitUntil ((== 2) <$> slotsInUse store "wide")+      limited ("wide", 3) `shouldReturn` Left Saturated+      atomically $ putTMVar gate ()+      wait a `shouldReturn` Right 1+      wait b `shouldReturn` Right 2++    it "Releases the slot on exception and on cancellation, leaving no keys behind" $ do+      store <- newInMemoryAdmissionStore (const 1)+      let failing (_ :: Int) = throwIO ConcurrencyTestException :: IO Int+      limitConcurrencyByWithStore store id failing 1 `shouldThrow` (== ConcurrencyTestException)+      slotsInUse store 1 `shouldReturn` 0+      gate <- newEmptyTMVarIO+      blocked <- async (limitConcurrencyByWithStore store id (\(req :: Int) -> atomically (readTMVar gate) >> pure req) 2)+      waitUntil ((== 1) <$> slotsInUse store 2)+      cancel blocked+      slotsInUse store 2 `shouldReturn` 0+      trackedKeys store `shouldReturn` 0++    it "Never exceeds the limit for a key under concurrent load" $ do+      current <- newIORef (0 :: Int)+      peak    <- newIORef (0 :: Int)+      let service (req :: Int) = do+            cur <- atomicModifyIORef' current (\c -> (c + 1, c + 1))+            atomicModifyIORef' peak (\p -> (max p cur, ()))+            yield+            atomicModifyIORef' current (\c -> (c - 1, ()))+            pure req+      limited <- limitConcurrencyBy (const ()) (const 3) service+      results <- mapConcurrently limited [1 .. 200]+      length [r | Right r <- results] `shouldSatisfy` (> 0)+      readIORef peak >>= (`shouldSatisfy` (<= 3))++  describe "withSlot" $+    it "Holds the slot until the work finishes, beyond any service call" $ do+      store <- newInMemoryAdmissionStore (const 1)+      gate  <- newEmptyTMVarIO+      streaming <- async (withSlot store "stream" (atomically (readTMVar gate) >> pure "consumed"))+      waitUntil ((== 1) <$> slotsInUse store ("stream" :: String))+      withSlot store "stream" (pure "second") `shouldReturn` Left Saturated+      atomically $ putTMVar gate ()+      wait streaming `shouldReturn` Right "consumed"+      withSlot store "stream" (pure "second") `shouldReturn` Right "second"++  describe "limitConcurrencyByEither" $+    it "Returns the given failure when the key is full" $ do+      limited <- limitConcurrencyByEither (const ()) (const 0) "full" (\(req :: Int) -> pure (Right req))+      limited 1 `shouldReturn` (Left "full" :: Either String Int)
test/Glue/DogpileProtectionSpec.hs view
@@ -2,12 +2,16 @@  module Glue.DogpileProtectionSpec (spec) where -import           Control.Concurrent       (threadDelay)+import           Control.Concurrent       (yield) import           Control.Concurrent.Async (async, wait) import           Data.IORef+import           Glue.Clock+import           Glue.Clock.Virtual import           Glue.DogpileProtection import           Test.Hspec import           UnliftIO.Exception       (Exception, throwIO)+import           UnliftIO.STM             (atomically, newEmptyTMVarIO,+                                           putTMVar, readTMVar)  data DogpileProtectionTestException = DogpileProtectionTestException   deriving (Eq, Show)@@ -17,30 +21,55 @@ requests :: [Int] requests = replicate 10 1 -delayTime :: Int-delayTime = 50 * 1000+waitForCount :: IORef Int -> Int -> IO ()+waitForCount ref n = readIORef ref >>= \c -> if c >= n then pure () else yield >> waitForCount ref n  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)+      gate    <- newEmptyTMVarIO       let service request = do             atomicModifyIORef' counter (\n -> (n + 1, ()))-            threadDelay delayTime+            atomically $ readTMVar gate             pure (request * 2)       protectedService <- dogpileProtect service       asyncResults <- traverse (async . protectedService) requests+      waitForCount counter 1+      atomically $ putTMVar gate ()       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)+      gate    <- newEmptyTMVarIO       let service _ = do             atomicModifyIORef' counter (\n -> (n + 1, ()))-            threadDelay delayTime+            atomically $ readTMVar gate             throwIO DogpileProtectionTestException :: IO Int       protectedService <- dogpileProtect service       asyncResults <- traverse (async . protectedService) requests+      waitForCount counter 1+      atomically $ putTMVar gate ()       traverse wait asyncResults `shouldThrow` (== DogpileProtectionTestException)       readIORef counter `shouldReturn` 1++  describe "dogpileProtectWith" $+    it "Retains a completed result for exactly the cache time" $ do+      vc      <- newVirtualClock+      counter <- newIORef (0 :: Int)+      let clock   = virtualClock vc :: Clock IO+          options = (defaultDogpileOptions :: DogpileOptions IO) { dogpileCacheTimeMs = 100, dogpileClock = clock }+          service (request :: Int) = atomicModifyIORef' counter (\n -> (n + 1, request * 2))+      protectedService <- dogpileProtectWith options service+      protectedService 1 `shouldReturn` 2+      waitForSleepers vc 1+      protectedService 1 `shouldReturn` 2+      readIORef counter `shouldReturn` 1+      advanceClock vc (99 * 1000)+      protectedService 1 `shouldReturn` 2+      readIORef counter `shouldReturn` 1+      advanceClock vc 1000+      protectedService 1 `shouldReturn` 2+      readIORef counter `shouldReturn` 2
test/Glue/FailoverSpec.hs view
@@ -1,17 +1,41 @@-{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleContexts    #-}+{-# LANGUAGE ScopedTypeVariables #-}  module Glue.FailoverSpec (spec) where +import qualified Control.Exception  as E+import           Data.IORef+import           Data.List.NonEmpty (NonEmpty (..)) import           Glue.Failover import           Test.Hspec import           Test.QuickCheck-import           UnliftIO.Exception (Exception, throwIO)+import           UnliftIO.Exception (Exception, fromException, throwIO, try)  data FailoverTestException = FailoverTestException   deriving (Eq, Show)  instance Exception FailoverTestException +-- | A backend failure, carrying the backend index and whether it should advance.+data BackendException = BackendException Int Bool+  deriving (Eq, Show)++instance Exception BackendException++advances :: E.SomeException -> Bool+advances e = maybe True (\(BackendException _ advance) -> advance) (fromException e)++-- | Backends that record their calls, each succeeding or failing (advancing or not) as scripted.+backends :: IORef [Int] -> NonEmpty (Maybe Bool) -> NonEmpty (Int -> IO Int)+backends calls = fmap backend . indexed+  where+    indexed (x :| xs) = (0, x) :| zip [1 ..] xs+    backend (index, script) req = do+      atomicModifyIORef' calls (\c -> (c ++ [index], ()))+      case script of+        Nothing      -> pure (req + index)+        Just advance -> throwIO (BackendException index advance)+ spec :: Spec spec = do   describe "failover" $ do@@ -24,3 +48,43 @@             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++  describe "failoverAcross" $ do+    it "Tries backends in order and returns the first success with its index" $ do+      calls <- newIORef []+      let services = backends calls (Just True :| [Just True, Nothing, Nothing])+      failoverAcrossIndexed defaultFailoverAcrossOptions services 100 `shouldReturn` (2, 102)+      readIORef calls `shouldReturn` [0, 1, 2]++    it "Stops at a failure that does not advance" $ do+      calls <- newIORef []+      let services = backends calls (Just True :| [Just False, Nothing])+          options  = defaultFailoverAcrossOptions { advanceOn = advances }+      failoverAcross options services 1 `shouldThrow` (== BackendException 1 False)+      readIORef calls `shouldReturn` [0, 1]++    it "Reports the last or first failure when every backend fails" $ do+      calls <- newIORef []+      let services = backends calls (Just True :| [Just True, Just True])+      failoverAcross defaultFailoverAcrossOptions services 1 `shouldThrow` (== BackendException 2 True)+      failoverAcross defaultFailoverAcrossOptions { reportedFailure = ReportFirstFailure } services 1+        `shouldThrow` (== BackendException 0 True)++    it "Propagates an asynchronous exception without advancing" $ do+      calls <- newIORef ([] :: [Int])+      let interrupted _ = atomicModifyIORef' calls (\c -> (c ++ [0], ())) >> E.throwIO E.UserInterrupt :: IO Int+          services = interrupted :| [\req -> atomicModifyIORef' calls (\c -> (c ++ [1], req))]+      failoverAcross defaultFailoverAcrossOptions services 1 `shouldThrow` (== E.UserInterrupt)+      readIORef calls `shouldReturn` [0]++    it "Behaves like the Either shape for the same outcomes" $+      property $ \(script :: [Maybe Bool], first :: Bool) -> do+        calls <- newIORef []+        let scripted = Just True :| script+            options :: FailoverAcrossOptions e+            options = defaultFailoverAcrossOptions { reportedFailure = if first then ReportFirstFailure else ReportLastFailure }+            valued = fmap (\(index, s) req -> pure (maybe (Right (req + index)) (\advance -> Left (index, advance)) s :: Either (Int, Bool) Int))+                          ((0, Just True) :| zip [1 ..] script)+        viaException <- try (failoverAcrossIndexed options { advanceOn = advances } (backends calls scripted) 10)+        viaValue     <- failoverAcrossIndexedEither options { advanceOn = snd } valued 10+        either (\(BackendException i a) -> Left (i, a)) Right viaException `shouldBe` viaValue
test/Glue/HedgingSpec.hs view
@@ -2,41 +2,189 @@  module Glue.HedgingSpec (spec) where -import           Control.Concurrent (threadDelay)+import           Control.Concurrent       (yield)+import           Control.Concurrent.Async (async, wait) import           Data.IORef+import           Data.List.NonEmpty       (NonEmpty (..))+import           Glue.Clock+import           Glue.Clock.Virtual+import           Glue.Failure import           Glue.Hedging+import           Glue.Race import           Test.Hspec-import           UnliftIO.Exception (Exception, throwIO)+import           UnliftIO.Exception       (Exception, SomeException, finally,+                                           fromException, throwIO)+import           UnliftIO.STM             (atomically, newEmptyTMVarIO,+                                           putTMVar, readTMVar) -data HedgingTestException = HedgingTestException+data HedgingTestException = HedgingTestException Int   deriving (Eq, Show)  instance Exception HedgingTestException +-- | How an attempt ended, without the payload, for comparing reports.+data Ended = Answered Int | Failed Int | Cancelled | Crashed+  deriving (Eq, Show)++summarise :: AttemptOutcome SomeException Int -> Ended+summarise (AttemptAnswered n)  = Answered n+summarise (AttemptFailed e)    = maybe Crashed (\(HedgingTestException n) -> Failed n) (fromException e)+summarise AttemptCancelled     = Cancelled+summarise (AttemptCrashed _)   = Crashed++-- | Options on a virtual clock that record launches, attempt endings and the winner.+recording :: VirtualClock -> (Int -> IO Bool) -> IO (HedgeOptions IO SomeException Int, IORef [Int], IORef [(Int, Ended)], IORef [Int])+recording vc admit = do+  launches <- newIORef []+  endings  <- newIORef []+  winners  <- newIORef []+  let record ref x = atomicModifyIORef' ref (\xs -> (xs ++ [x], ()))+      options = (defaultHedgeOptions :: HedgeOptions IO SomeException Int)+        { hedgeDelayMs   = 100+        , admitLaunch    = \i -> record launches i >> admit i+        , onAttemptEnded = \i o -> record endings (i, summarise o)+        , onHedgeWon     = record winners+        , hedgeClock     = virtualClock vc+        }+  pure (options, launches, endings, winners)+ spec :: Spec spec = do+  describe "hedgeAcross" $ do+    it "Returns the primary's answer without launching a backup inside the delay" $ do+      vc <- newVirtualClock+      gate <- newEmptyTMVarIO+      (options, launches, endings, winners) <- recording vc (const (pure True))+      call <- async $ hedgeAcross options ((\x -> atomically (readTMVar gate) >> pure x) :| [pure . negate]) 5+      waitForSleepers vc 1+      atomically $ putTMVar gate ()+      wait call `shouldReturn` 5+      readIORef launches `shouldReturn` []+      readIORef endings `shouldReturn` [(0, Answered 5)]+      readIORef winners `shouldReturn` [0]++    it "Launches the backup after the delay and cancels the slow primary" $ do+      vc <- newVirtualClock+      closed <- newIORef False+      (options, launches, endings, winners) <- recording vc (const (pure True))+      let primary x = (clockSleep (virtualClock vc) 500000 >> pure x) `finally` writeIORef closed True+      call <- async $ hedgeAcross options (primary :| [pure . negate]) 5+      waitForSleepers vc 2+      advanceClock vc 100000+      wait call `shouldReturn` (-5)+      readIORef closed `shouldReturn` True+      readIORef launches `shouldReturn` [1]+      readIORef endings `shouldReturn` [(1, Answered (-5)), (0, Cancelled)]+      readIORef winners `shouldReturn` [1]++    it "Keeps waiting on the primary when the backup's launch is declined" $ do+      vc <- newVirtualClock+      (options, launches, endings, _) <- recording vc (const (pure False))+      let primary x = clockSleep (virtualClock vc) 500000 >> pure x+      call <- async $ hedgeAcross options (primary :| [pure . negate]) 5+      waitForSleepers vc 2+      advanceClock vc 100000+      let waitForDecision = readIORef launches >>= \l -> if null l then yield >> waitForDecision else pure ()+      waitForDecision+      advanceClock vc 400000+      wait call `shouldReturn` 5+      readIORef launches `shouldReturn` [1]+      readIORef endings `shouldReturn` [(0, Answered 5)]++    it "Launches the backup immediately when the primary fails early" $ do+      vc <- newVirtualClock+      (options, launches, endings, _) <- recording vc (const (pure True))+      hedgeAcross options ((\_ -> throwIO (HedgingTestException 0)) :| [pure . negate]) 5 `shouldReturn` (-5)+      readIORef launches `shouldReturn` [1]+      readIORef endings `shouldReturn` [(0, Failed 0), (1, Answered (-5))]++    it "Rethrows the first failure when every attempt fails" $ do+      vc <- newVirtualClock+      (options, _, endings, winners) <- recording vc (const (pure True))+      let failing n _ = throwIO (HedgingTestException n) :: IO Int+      hedgeAcross options (failing 0 :| [failing 1, failing 2]) (5 :: Int) `shouldThrow` (== HedgingTestException 0)+      readIORef endings `shouldReturn` [(0, Failed 0), (1, Failed 1), (2, Failed 2)]+      readIORef winners `shouldReturn` []++    it "Rethrows the primary's failure when the backup is declined" $ do+      vc <- newVirtualClock+      (options, _, _, _) <- recording vc (const (pure False))+      hedgeAcross options ((\_ -> throwIO (HedgingTestException 0)) :| [pure . negate]) 5+        `shouldThrow` (== HedgingTestException 0)++  describe "hedgeAcrossEither" $+    it "Treats Left as a failed attempt and returns the first Left if all fail" $ do+      vc <- newVirtualClock+      let options = (defaultHedgeOptions :: HedgeOptions IO String (Either String Int)) { hedgeClock = virtualClock vc }+      hedgeAcrossEither options ((\_ -> pure (Left "primary")) :| [pure . Right]) 3 `shouldReturn` Right 3+      hedgeAcrossEither options ((\_ -> pure (Left "primary")) :| [\_ -> pure (Left "backup")]) (3 :: Int)+        `shouldReturn` (Left "primary" :: Either String Int)++  describe "Glue.Race" $ do+    it "Reports attempts that ended together in index order" $ do+      result <- withRace exceptionFailures systemClock $ \race -> do+        _ <- launch race (pure (1 :: Int))+        _ <- launch race (pure 2)+        let settle = finishedAttempts race >>= \f -> if length f == 2 then pure () else yield >> settle+        settle+        first  <- awaitNext race Nothing+        second <- awaitNext race Nothing+        none   <- awaitNext race Nothing+        pure (fmap eventText [first, second, none])+      result `shouldBe` ["0 answered 1", "1 answered 2", "nothing running"]++    it "Abandoning an attempt that already completed reports its result" $ do+      result <- withRace exceptionFailures systemClock $ \race -> do+        index <- launch race (pure (7 :: Int))+        let settle = finishedAttempts race >>= \f -> if f == [index] then pure () else yield >> settle+        settle+        summarise <$> abandon race index+      result `shouldBe` Answered 7++    it "Times out when no attempt ends within the limit" $ do+      vc <- newVirtualClock+      gate <- newEmptyTMVarIO+      let clock = virtualClock vc :: Clock IO+      waiting <- async $ withRace exceptionFailures clock $ \race -> do+        _ <- launch race (atomically (readTMVar gate) >> pure (1 :: Int))+        eventText <$> awaitNext race (Just 50)+      waitForSleepers vc 1+      advanceClock vc 50000+      wait waiting `shouldReturn` "timed out"+      atomically $ putTMVar gate ()+   describe "hedgingService" $ do     it "Completes with the fast hedged call when the primary call is slow" $ do+      vc  <- newVirtualClock       ref <- newIORef (0 :: Int)-      let service (req :: Int) = do+      let clock = virtualClock vc :: Clock IO+          service (req :: Int) = do             attemptNum <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))             if attemptNum == 1-              then threadDelay (500 * 1000) >> pure (req + 100)+              then clockSleep clock (500 * 1000) >> pure (req + 100)               else pure (req * 2)-          hedged = hedgingService (HedgingOptions { hedgingCount = 2, hedgingDelayMs = 5 }) service-      hedged 21 `shouldReturn` 42+          hedged = hedgingService ((defaultHedgingOptions :: HedgingOptions IO) { hedgingCount = 2, hedgingDelayMs = 5, hedgingClock = clock }) service+      call <- async (hedged 21)+      waitForSleepers vc 2+      advanceClock vc 5000+      wait call `shouldReturn` 42      it "Succeeds if one attempt fails immediately and another succeeds" $ do       ref <- newIORef (0 :: Int)       let service (req :: Int) = do             attemptNum <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))             if attemptNum == 1-              then throwIO HedgingTestException+              then throwIO (HedgingTestException 1)               else pure (req * 3)-          hedged = hedgingService (HedgingOptions { hedgingCount = 2, hedgingDelayMs = 0 }) service+          hedged = hedgingService (defaultHedgingOptions { hedgingCount = 2, hedgingDelayMs = 0 }) service       hedged 10 `shouldReturn` 30      it "Rethrows the exception if all hedged attempts fail" $ do-      let service (_ :: Int) = throwIO HedgingTestException :: IO Int-          hedged = hedgingService (HedgingOptions { hedgingCount = 3, hedgingDelayMs = 0 }) service-      hedged 1 `shouldThrow` (== HedgingTestException)+      let service (_ :: Int) = throwIO (HedgingTestException 1) :: IO Int+          hedged = hedgingService (defaultHedgingOptions { hedgingCount = 3, hedgingDelayMs = 0 }) service+      hedged 1 `shouldThrow` (== HedgingTestException 1)+  where+    eventText (AttemptEnded i (AttemptAnswered n)) = show i ++ " answered " ++ show (n :: Int)+    eventText (AttemptEnded i _)                   = show i ++ " ended"+    eventText RaceTimedOut                         = "timed out"+    eventText NothingRunning                       = "nothing running"
+ test/Glue/PeriodicSpec.hs view
@@ -0,0 +1,134 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Glue.PeriodicSpec (spec) where++import           Control.Concurrent       (yield)+import           Control.Concurrent.Async (async, wait)+import           Data.IORef+import           Glue.Clock+import           Glue.Clock.Virtual+import           Glue.Periodic+import           Test.Hspec+import           UnliftIO.Exception       (Exception, SomeException,+                                           fromException, throwIO)+import           UnliftIO.STM             (atomically, newEmptyTMVarIO,+                                           putTMVar, readTMVar)++data PassFailed = PassFailed+  deriving (Eq, Show)++instance Exception PassFailed++waitUntil :: IO Bool -> IO ()+waitUntil condition = condition >>= \done -> if done then pure () else yield >> waitUntil condition++options :: VirtualClock -> PeriodicOptions IO+options vc = defaultPeriodicOptions { periodMs = 100, periodicGracePeriodMs = 50, periodicClock = virtualClock vc }++spec :: Spec+spec = do+  describe "periodically" $ do+    it "Runs a pass every period, measured from the end of the previous pass" $ do+      vc     <- newVirtualClock+      starts <- newIORef []+      let pass = do+            now <- virtualNow vc+            atomicModifyIORef' starts (\s -> (s ++ [now], ()))+            clockSleep (virtualClock vc) 30000+      handle <- periodically (options vc) pass+      waitForSleepers vc 1+      advanceClock vc 100000+      waitForSleepers vc 1+      advanceClock vc 30000+      waitUntil ((== 1) <$> passesCompleted handle)+      waitForSleepers vc 1+      advanceClock vc 100000+      waitForSleepers vc 1+      advanceClock vc 30000+      waitUntil ((== 2) <$> passesCompleted handle)+      readIORef starts `shouldReturn` [100000, 230000]+      stopPeriodic handle `shouldReturn` True++    it "Runs the first pass immediately when asked" $ do+      vc <- newVirtualClock+      handle <- periodically (options vc) { runImmediately = True } (pure ())+      waitUntil ((== 1) <$> passesCompleted handle)+      virtualNow vc `shouldReturn` 0+      stopPeriodic handle `shouldReturn` True++    it "Adds jitter from the random source to the period" $ do+      vc <- newVirtualClock+      handle <- periodically (options vc) { periodJitterMs = 40, periodicRandom = \n -> pure (n `div` 2) } (pure ())+      waitForSleepers vc 1+      advanceClock vc 119000+      passesCompleted handle `shouldReturn` 0+      advanceClock vc 1000+      waitUntil ((== 1) <$> passesCompleted handle)+      stopPeriodic handle `shouldReturn` True++    it "Reports a failed pass and keeps running" $ do+      vc       <- newVirtualClock+      failures <- newIORef (0 :: Int)+      calls    <- newIORef (0 :: Int)+      let pass = do+            n <- atomicModifyIORef' calls (\c -> (c + 1, c + 1))+            if n == 1 then throwIO PassFailed else pure ()+          onFailed (e :: SomeException) = case fromException e of+            Just PassFailed -> atomicModifyIORef' failures (\f -> (f + 1, ()))+            Nothing         -> pure ()+      handle <- periodically (options vc) { onPassFailed = onFailed } pass+      runNow handle `shouldReturn` True+      runNow handle `shouldReturn` True+      readIORef failures `shouldReturn` 1+      readIORef calls `shouldReturn` 2+      passesCompleted handle `shouldReturn` 2+      stopPeriodic handle `shouldReturn` True++    it "runNow queues behind a running pass" $ do+      vc    <- newVirtualClock+      gate  <- newEmptyTMVarIO+      calls <- newIORef (0 :: Int)+      let pass = do+            n <- atomicModifyIORef' calls (\c -> (c + 1, c + 1))+            if n == 1 then atomically (readTMVar gate) else pure ()+      handle <- periodically (options vc) { runImmediately = True } pass+      waitUntil ((== 1) <$> readIORef calls)+      queued <- async (runNow handle)+      atomically $ putTMVar gate ()+      wait queued `shouldReturn` True+      readIORef calls `shouldReturn` 2+      stopPeriodic handle `shouldReturn` True++    it "Stopping lets a running pass finish within the grace period" $ do+      vc       <- newVirtualClock+      gate     <- newEmptyTMVarIO+      started  <- newEmptyTMVarIO+      finished <- newIORef False+      let pass = do+            atomically $ putTMVar started ()+            atomically $ readTMVar gate+            writeIORef finished True+      handle <- periodically (options vc) { runImmediately = True } pass+      atomically $ readTMVar started+      stopping <- async (stopPeriodic handle)+      waitForSleepers vc 1+      atomically $ putTMVar gate ()+      wait stopping `shouldReturn` True+      readIORef finished `shouldReturn` True++    it "Stopping cancels a pass that outlasts the grace period" $ do+      vc        <- newVirtualClock+      started   <- newEmptyTMVarIO+      finished  <- newIORef False+      let pass = do+            atomically $ putTMVar started ()+            clockSleep (virtualClock vc) 1000000+            writeIORef finished True+      handle <- periodically (options vc) { runImmediately = True } pass+      atomically $ readTMVar started+      stopping <- async (stopPeriodic handle)+      waitForSleepers vc 2+      advanceClock vc 50000+      wait stopping `shouldReturn` False+      readIORef finished `shouldReturn` False+      runNow handle `shouldReturn` False
test/Glue/RetrySpec.hs view
@@ -1,14 +1,19 @@+{-# LANGUAGE ScopedTypeVariables #-}+ module Glue.RetrySpec (spec) where -import           Control.Concurrent     (forkIO, myThreadId, threadDelay)-import           Control.Exception.Base (AsyncException (UserInterrupt),-                                         throwTo)+import           Control.Concurrent       (throwTo)+import           Control.Concurrent.Async (async, asyncThreadId, waitCatch)+import           Control.Exception.Base   (AsyncException (UserInterrupt)) import           Control.Monad.IO.Class (liftIO) import           Data.IORef+import           Glue.Clock import           Glue.Retry import           Test.Hspec import           Test.QuickCheck-import           UnliftIO.Exception     (Exception, throwIO)+import           UnliftIO.Exception     (Exception, fromException, throwIO, try)+import           UnliftIO.STM           (atomically, newEmptyTMVarIO, putTMVar,+                                         readTMVar)  newtype SmallInt = SmallInt Int   deriving (Eq, Show)@@ -23,31 +28,162 @@  instance Exception RetryTestException +newtype NotRetryableException = NotRetryableException Int+  deriving (Eq, Show)++instance Exception NotRetryableException++-- | A clock that records each sleep (in milliseconds) instead of sleeping.+recordingClock :: IORef [Int] -> Clock IO+recordingClock ref =+  Clock+    { clockNow     = pure 0+    , clockSleep   = \us -> atomicModifyIORef' ref (\s -> (s ++ [us `div` 1000], ()))+    , clockTimeout = \_ action -> Just <$> action+    }++-- | A service failing the given number of times before succeeding, counting calls.+failingTimes :: IORef Int -> Int -> Int -> IO Int+failingTimes ref failures req = do+  counter <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+  if counter > failures then pure req else throwIO RetryTestException++withSleeps :: (IORef [Int] -> IO ()) -> IO [Int]+withSleeps body = do+  sleeps <- newIORef []+  body sleeps+  readIORef sleeps++alwaysFailing :: IO (Int -> IO Int)+alwaysFailing = do+  ref <- newIORef 0+  pure (failingTimes ref maxBound)+ spec :: Spec spec = do   describe "retryingService" $ do     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+        let options      = defaultRetryOptions { maximumRetries = retries }+            retryService = retryingService options (failingTimes ref failures)             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)+        ref     <- liftIO $ newIORef (0 :: Int)+        started <- newEmptyTMVarIO         let service req = do               atomicModifyIORef' ref (\c -> (c + 1, ()))-              threadId <- myThreadId-              _ <- forkIO (threadDelay 2000 >> throwTo threadId UserInterrupt)-              threadDelay 1000000+              atomically $ putTMVar started ()+              never <- newEmptyTMVarIO+              atomically $ readTMVar never :: IO ()               pure (req * 2 :: Int)             options      = defaultRetryOptions { maximumRetries = retries }             retryService = retryingService options service-        retryService request `shouldThrow` (== UserInterrupt)+        call <- async (retryService request)+        atomically $ readTMVar started+        throwTo (asyncThreadId call) UserInterrupt+        result <- waitCatch call+        either fromException (const Nothing) result `shouldBe` Just UserInterrupt         readIORef ref `shouldReturn` 1++    it "Does not retry a failure rejected by retryOn" $ do+      ref <- newIORef (0 :: Int)+      let service req = atomicModifyIORef' ref (\c -> (c + 1, ())) >> throwIO (NotRetryableException req) :: IO Int+          retryable e = maybe True (const False) (fromException e :: Maybe NotRetryableException)+          retryService = retryingService defaultRetryOptions { retryOn = retryable } service+      retryService 4 `shouldThrow` (== NotRetryableException 4)+      readIORef ref `shouldReturn` 1++    it "Does not retry a request rejected by retryAllowed" $ do+      ref <- newIORef 0+      let retryService = retryingService defaultRetryOptions { retryAllowed = (> 0) } (failingTimes ref 1)+      retryService 0 `shouldThrow` (== RetryTestException)+      readIORef ref `shouldReturn` 1++    it "Waits according to the backoff, capped by the maximum delay" $ do+      service <- alwaysFailing+      sleeps <- withSleeps $ \ref -> do+        let options = defaultRetryOptions+              { maximumRetries  = 5+              , retryBackoff    = ExponentialBackoff 100 2+              , retryMaxDelayMs = Just 300+              , retryClock      = recordingClock ref+              }+        retryingService options service 1 `shouldThrow` (== RetryTestException)+      sleeps `shouldBe` [100, 200, 300, 300, 300]++    it "Computes constant, linear and exponential backoffs" $ do+      fmap (backoffDelayMs (ConstantBackoff 50)) [0 .. 3] `shouldBe` [50, 50, 50, 50]+      fmap (backoffDelayMs (LinearBackoff 50 25)) [0 .. 3] `shouldBe` [50, 75, 100, 125]+      fmap (backoffDelayMs (ExponentialBackoff 50 3)) [0 .. 3] `shouldBe` [50, 150, 450, 1350]++    it "Keeps jittered waits within their bounds" $+      property $ \(randoms :: [NonNegative Int], equal :: Bool) -> do+        source <- newIORef (cycle (0 : fmap getNonNegative randoms))+        service <- alwaysFailing+        let random n = atomicModifyIORef' source (\rs -> (drop 1 rs, head' rs `mod` (n + 1)))+            head' = foldr const 0+        sleeps <- withSleeps $ \ref -> do+          let options = defaultRetryOptions+                { maximumRetries  = 6+                , retryBackoff    = ExponentialBackoff 10 2+                , retryMaxDelayMs = Just 200+                , retryJitter     = if equal then EqualJitter else FullJitter+                , retryRandom     = random+                , retryClock      = recordingClock ref+                }+          retryingService options service 1 `shouldThrow` (== RetryTestException)+        let caps = [10, 20, 40, 80, 160, 200]+            lower cap = if equal then cap `div` 2 else 0+        sleeps `shouldSatisfy` (\s -> and (zipWith (\d cap -> d >= lower cap && d <= cap) s caps) && length s == 6)++    it "Skips a retry whose wait would pass the deadline, returning the last failure" $ do+      ref     <- newIORef (0 :: Int)+      retries <- newIORef []+      let service req = do+            n <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))+            throwIO (NotRetryableException (req + n)) :: IO Int+      sleeps <- withSleeps $ \sleepRef -> do+        let options = defaultRetryOptions+              { maximumRetries   = 5+              , retryBackoff     = LinearBackoff 100 100+              , retryRemainingMs = pure (Just 150)+              , onRetry          = \attempt _ delay -> atomicModifyIORef' retries (\r -> (r ++ [(attempt, delay)], ()))+              , retryClock       = recordingClock sleepRef+              }+        retryingService options service 10 `shouldThrow` (== NotRetryableException 12)+      sleeps `shouldBe` [100]+      readIORef retries `shouldReturn` [(1, 100)]++    it "Does not retry once the deadline has passed" $ do+      ref <- newIORef 0+      let options = defaultRetryOptions { retryRemainingMs = pure (Just 0) }+      retryingService options (failingTimes ref 1) 1 `shouldThrow` (== RetryTestException)+      readIORef ref `shouldReturn` 1++  describe "retryingServiceEither" $ do+    it "Retries Left failures accepted by retryOn and returns the last one" $ do+      ref <- newIORef (0 :: Int)+      let service (_ :: Int) = atomicModifyIORef' ref (\c -> (c + 1, Left (c + 1) :: Either Int Int))+          options = defaultRetryOptions { maximumRetries = 5, retryOn = (< 3) }+      retryingServiceEither options service 1 `shouldReturn` Left 3+      readIORef ref `shouldReturn` 3++    it "Behaves like the exception shape for the same outcomes" $+      property $ \(outcomes :: [Bool], SmallInt retries) -> do+        exceptionRef <- newIORef outcomes+        valueRef     <- newIORef outcomes+        let next ref = atomicModifyIORef' ref (\o -> case o of { (x : xs) -> (xs, x); [] -> ([], True) })+            exceptional (req :: Int) = next exceptionRef >>= \ok -> if ok then pure req else throwIO RetryTestException+            valued (req :: Int) = next valueRef >>= \ok -> pure (if ok then Right req else Left RetryTestException)+            options :: RetryOptions IO Int f+            options = defaultRetryOptions { maximumRetries = retries }+        viaException <- try (retryingService options exceptional 7)+        viaValue     <- retryingServiceEither options valued 7+        viaException `shouldBe` viaValue+        remaining <- readIORef valueRef+        readIORef exceptionRef `shouldReturn` remaining
test/Glue/ShutdownSpec.hs view
@@ -2,26 +2,35 @@  module Glue.ShutdownSpec (spec) where -import           Control.Concurrent       (threadDelay)+import           Control.Concurrent       (yield) import           Control.Concurrent.Async (async, wait)+import           Glue.Clock+import           Glue.Clock.Virtual import           Glue.Shutdown import           Test.Hspec+import           UnliftIO.STM             (atomically, newEmptyTMVarIO,+                                           putTMVar, takeTMVar)  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)+      vc      <- newVirtualClock+      release <- newEmptyTMVarIO+      let clock = virtualClock vc :: Clock IO+          service (req :: Int) = do+            atomically $ takeTMVar release             pure (req * 2)-      (wrapped, handle) <- gracefulShutdown (defaultShutdownOptions { shutdownGracePeriodMs = 500 }) service+      (wrapped, handle) <- gracefulShutdown ((defaultShutdownOptions :: ShutdownOptions IO Int) { shutdownGracePeriodMs = 500, shutdownClock = clock }) service       isShuttingDown handle `shouldReturn` False       inFlight <- async (wrapped 21)-      threadDelay (5 * 1000)-      inFlightCount handle `shouldReturn` 1-      drainedCleanly <- initiateShutdown handle-      drainedCleanly `shouldBe` True+      let waitInFlight = inFlightCount handle >>= \n -> if n == 1 then pure () else yield >> waitInFlight+      waitInFlight+      shutdown <- async (initiateShutdown handle)+      waitForSleepers vc 1       isShuttingDown handle `shouldReturn` True+      atomically $ putTMVar release ()+      wait shutdown `shouldReturn` True       wait inFlight `shouldReturn` 42       wrapped 99 `shouldThrow` (== ServiceShuttingDownException "Service is shutting down.") @@ -33,11 +42,22 @@       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+      vc <- newVirtualClock+      let clock = virtualClock vc :: Clock IO+          opts  = (defaultShutdownOptions :: ShutdownOptions IO Int) { shutdownFallback = Just (-999), shutdownGracePeriodMs = 20, shutdownClock = clock }+          slowService (_ :: Int) = clockSleep clock (500 * 1000) >> pure (100 :: Int)       (wrapped, handle) <- gracefulShutdown opts slowService       inFlight <- async (wrapped 1)-      threadDelay (5 * 1000)-      drainedCleanly <- initiateShutdown handle-      drainedCleanly `shouldBe` False+      waitForSleepers vc 1+      shutdown <- async (initiateShutdown handle)+      waitForSleepers vc 2+      advanceClock vc (20 * 1000)+      wait shutdown `shouldReturn` False       wait inFlight `shouldReturn` (-999)++  describe "gracefulShutdownEither" $+    it "Returns the shutting-down failure as a value" $ do+      (wrapped, handle) <- gracefulShutdownEither defaultShutdownOptions "shutting down" (\(x :: Int) -> pure (Right (x * 2)))+      wrapped 4 `shouldReturn` (Right 8 :: Either String Int)+      _ <- initiateShutdown handle+      wrapped 4 `shouldReturn` Left "shutting down"
test/Glue/TimeoutSpec.hs view
@@ -1,20 +1,48 @@+{-# LANGUAGE ScopedTypeVariables #-}+ module Glue.TimeoutSpec (spec) where -import           Control.Concurrent (threadDelay)+import           Control.Concurrent.Async (async, wait)+import           Glue.Clock+import           Glue.Clock.Virtual import           Glue.Timeout import           Test.Hspec import           Test.QuickCheck -timeoutOptions :: TimeoutOptions-timeoutOptions = defaultTimeoutOptions { timeoutLimitMs = 10 }- spec :: Spec spec = do   describe "addTimeout" $ do     it "Services taking too long should fail with a timeout" $-      property $ \request ->-        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+      property $ \(request :: Int) -> do+        vc <- newVirtualClock+        let clock          = virtualClock vc :: Clock IO+            options        = (defaultTimeoutOptions :: TimeoutOptions IO) { timeoutLimitMs = 10, timeoutClock = clock }+            service req    = (if req > 0 then pure () else clockSleep clock (50 * 1000)) >> pure (req + 100)+            timeoutService = addTimeout options service+        if request > 0+          then timeoutService request `shouldReturn` (request + 100)+          else do+            call <- async (timeoutService request)+            waitForSleepers vc 2+            advanceClock vc (10 * 1000)+            wait call `shouldThrow` (== TimeoutException (timeoutDescription options))++    it "Does not time out a call that finishes just inside the limit" $ do+      vc <- newVirtualClock+      let clock   = virtualClock vc :: Clock IO+          options = (defaultTimeoutOptions :: TimeoutOptions IO) { timeoutLimitMs = 10, timeoutClock = clock }+      call <- async (addTimeout options (\(x :: Int) -> clockSleep clock 9999 >> pure x) 7)+      waitForSleepers vc 2+      advanceClock vc 9999+      wait call `shouldReturn` 7++  describe "addTimeoutEither" $+    it "Returns the timeout failure as a value" $ do+      vc <- newVirtualClock+      let clock   = virtualClock vc :: Clock IO+          options = (defaultTimeoutOptions :: TimeoutOptions IO) { timeoutLimitMs = 10, timeoutClock = clock }+          service (_ :: Int) = clockSleep clock 50000 >> pure (Right (1 :: Int))+      call <- async (addTimeoutEither options "timed out" service 1)+      waitForSleepers vc 2+      advanceClock vc 10000+      wait call `shouldReturn` (Left ("timed out" :: String))
+ test/Glue/WeightedLoadBalancerSpec.hs view
@@ -0,0 +1,108 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Glue.WeightedLoadBalancerSpec (spec) where++import qualified Data.ByteString       as BS+import qualified Data.ByteString.Char8 as BC+import           Data.Either           (isRight)+import           Data.List.NonEmpty    (NonEmpty (..))+import qualified Data.Map.Strict       as M+import           Glue.LoadBalancer+import           Test.Hspec+import           Test.QuickCheck+import           UnliftIO.Exception    (Exception, throwIO)++data BackendDown = BackendDown+  deriving (Eq, Show)++instance Exception BackendDown++referenceKey :: (Integer, Integer)+referenceKey = (0x0706050403020100, 0x0f0e0d0c0b0a0908)++referenceHash :: Int -> Integer+referenceHash n = toInteger (sipHash24 (fromInteger (fst referenceKey)) (fromInteger (snd referenceKey)) (BS.pack (fmap fromIntegral [0 .. n - 1])))++keys :: [BS.ByteString]+keys = fmap (BC.pack . ("session-" ++) . show) [1 .. 20000 :: Int]++spec :: Spec+spec = do+  describe "sipHash24" $+    it "Matches the reference vectors (key 00..0f, message 00..n-1)" $ do+      referenceHash 0  `shouldBe` 0x726fdb47dd0e0e31+      referenceHash 1  `shouldBe` 0x74f839c593dc67fd+      referenceHash 7  `shouldBe` 0xab0200f58b01d137+      referenceHash 8  `shouldBe` 0x93f5f5799a932462+      referenceHash 15 `shouldBe` 0xa129ca6149be45e5+      referenceHash 63 `shouldBe` 0x958a324ceb064572++  describe "validateWeights" $ do+    it "Accepts non-negative weights summing to 10000, including zero weights" $ do+      validateWeights (10000 :| []) `shouldBe` Right ()+      validateWeights (8000 :| [2000, 0]) `shouldBe` Right ()++    it "Rejects negative weights and wrong sums" $ do+      validateWeights (10500 :| [-500]) `shouldBe` Left (NegativeWeight 1 (-500))+      validateWeights (5000 :| [4000]) `shouldBe` Left (WeightsDoNotSumToTotal 9000)++  describe "chooseIndex" $ do+    it "Is deterministic for a key and salt" $+      property $ \(key :: String) -> do+        let salt = saltFromBytes (BC.pack "split")+        chooseIndex salt (BC.pack key) (5000 :| [5000]) `shouldBe` chooseIndex salt (BC.pack key) (5000 :| [5000])++    it "Never chooses a zero-weight backend and gives a full-weight backend everything" $+      property $ \(key :: String) -> do+        let salt = saltFromBytes (BC.pack "split")+        chooseIndex salt (BC.pack key) (0 :| [10000, 0]) `shouldBe` 1+        chooseIndex salt (BC.pack key) (10000 :| [0]) `shouldBe` 0++    it "Maps buckets to backends by their cumulative ranges" $+      property $ \(key :: String) -> do+        let salt   = saltFromBytes (BC.pack "split")+            bucket = bucketOf salt (BC.pack key)+            index  = chooseIndex salt (BC.pack key) (2500 :| [2500, 5000])+        index `shouldBe` (if bucket < 2500 then 0 else if bucket < 5000 then 1 else 2)++    it "Keeps a key's backend when unrelated weights change order" $+      property $ \(key :: String) -> do+        let salt   = saltFromBytes (BC.pack "split")+            original = chooseIndex salt (BC.pack key) (5000 :| [3000, 2000])+            reordered = chooseIndex salt (BC.pack key) (5000 :| [2000, 3000])+        -- Keys in the first backend's range are unaffected by reordering the others.+        (original == 0) `shouldBe` (reordered == 0)++    it "Splits traffic in proportion to the weights" $ do+      let salt   = saltFromBytes (BC.pack "split")+          counts = M.fromListWith (+) [(chooseIndex salt key (8000 :| [2000]), 1 :: Int) | key <- keys]+          share index = fromIntegral (M.findWithDefault 0 index counts) / fromIntegral (length keys) :: Double+      share 0 `shouldSatisfy` (\s -> abs (s - 0.8) < 0.015)+      share 1 `shouldSatisfy` (\s -> abs (s - 0.2) < 0.015)++    it "Gives independent assignments for different salts" $ do+      let a = saltFromBytes (BC.pack "split-a")+          b = saltFromBytes (BC.pack "split-b")+          both = length [() | key <- keys, chooseIndex a key (5000 :| [5000]) == 0, chooseIndex b key (5000 :| [5000]) == 0]+          ratio = fromIntegral both / fromIntegral (length keys) :: Double+      ratio `shouldSatisfy` (\r -> abs (r - 0.25) < 0.015)++  describe "weightedBy" $ do+    it "Rejects invalid weights up front" $ do+      let backends = (6000, pure . (+ 1)) :| [(6000, pure . (+ 2))] :: NonEmpty (Int, Int -> IO Int)+      either Just (const Nothing) (weightedBy (BC.pack . show) (Salt 1 2) backends) `shouldBe` Just (WeightsDoNotSumToTotal 12000)++    it "Routes every request to the backend chooseIndex picks" $+      property $ \(request :: Int) -> do+        let salt     = Salt 1 2+            backends = (3000, \r -> pure (0, r)) :| [(7000, \r -> pure (1, r))] :: NonEmpty (Int, Int -> IO (Int, Int))+            keyOf    = BC.pack . show+        case weightedBy keyOf salt backends of+          Left err      -> expectationFailure (show err)+          Right service -> service request `shouldReturn` (chooseIndex salt (keyOf request) (3000 :| [7000]), request)++    it "Propagates a failure of the chosen backend" $ do+      let backends = (10000, \_ -> throwIO BackendDown) :| [(0, pure)] :: NonEmpty (Int, Int -> IO Int)+          built    = weightedBy (BC.pack . show) (Salt 1 2) backends+      isRight built `shouldBe` True+      either (const (pure ())) (\service -> service 1 `shouldThrow` (== BackendDown)) built