numerus-closus 0.4.0.1 → 0.4.1.0
raw patch · 4 files changed
+231/−3 lines, 4 filesPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
API changes (from Hackage documentation)
+ Control.NumerusClosus: DrainRate :: Double -> DrainRate
+ Control.NumerusClosus: RefillRate :: Double -> RefillRate
+ Control.NumerusClosus: [unDrainRate] :: DrainRate -> Double
+ Control.NumerusClosus: [unRefillRate] :: RefillRate -> Double
+ Control.NumerusClosus: gcra :: Int -> NominalDiffTime -> UTCTime -> RateLimiter
+ Control.NumerusClosus: leakyBucket :: BucketSize Double -> DrainRate -> UTCTime -> RateLimiter
+ Control.NumerusClosus: newtype DrainRate
+ Control.NumerusClosus: newtype RefillRate
+ Control.NumerusClosus: tokenBucket :: BucketSize Double -> RefillRate -> UTCTime -> RateLimiter
+ Control.NumerusClosus.Typed: DrainRate :: Double -> DrainRate
+ Control.NumerusClosus.Typed: GCRA :: NominalDiffTime -> NominalDiffTime -> UTCTime -> GCRA
+ Control.NumerusClosus.Typed: LeakyBucket :: BucketSize Double -> DrainRate -> Double -> UTCTime -> LeakyBucket
+ Control.NumerusClosus.Typed: RefillRate :: Double -> RefillRate
+ Control.NumerusClosus.Typed: TokenBucket :: BucketSize Double -> RefillRate -> Double -> UTCTime -> TokenBucket
+ Control.NumerusClosus.Typed: [burstTolerance] :: GCRA -> NominalDiffTime
+ Control.NumerusClosus.Typed: [drainRate] :: LeakyBucket -> DrainRate
+ Control.NumerusClosus.Typed: [emissionInterval] :: GCRA -> NominalDiffTime
+ Control.NumerusClosus.Typed: [lastDrain] :: LeakyBucket -> UTCTime
+ Control.NumerusClosus.Typed: [lastRefill] :: TokenBucket -> UTCTime
+ Control.NumerusClosus.Typed: [level] :: LeakyBucket -> Double
+ Control.NumerusClosus.Typed: [refillRate] :: TokenBucket -> RefillRate
+ Control.NumerusClosus.Typed: [tat] :: GCRA -> UTCTime
+ Control.NumerusClosus.Typed: [tokens] :: TokenBucket -> Double
+ Control.NumerusClosus.Typed: [unDrainRate] :: DrainRate -> Double
+ Control.NumerusClosus.Typed: [unRefillRate] :: RefillRate -> Double
+ Control.NumerusClosus.Typed: data GCRA
+ Control.NumerusClosus.Typed: data LeakyBucket
+ Control.NumerusClosus.Typed: data TokenBucket
+ Control.NumerusClosus.Typed: gcra :: Int -> NominalDiffTime -> UTCTime -> GCRA
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.GCRA
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.LeakyBucket
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.TokenBucket
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.DrainRate
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.GCRA
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.LeakyBucket
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.RefillRate
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.TokenBucket
+ Control.NumerusClosus.Typed: instance GHC.Classes.Ord Control.NumerusClosus.Typed.DrainRate
+ Control.NumerusClosus.Typed: instance GHC.Classes.Ord Control.NumerusClosus.Typed.RefillRate
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.GCRA
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.LeakyBucket
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.TokenBucket
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.DrainRate
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.GCRA
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.LeakyBucket
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.RefillRate
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.TokenBucket
+ Control.NumerusClosus.Typed: leakyBucket :: BucketSize Double -> DrainRate -> UTCTime -> LeakyBucket
+ Control.NumerusClosus.Typed: newtype DrainRate
+ Control.NumerusClosus.Typed: newtype RefillRate
+ Control.NumerusClosus.Typed: tokenBucket :: BucketSize Double -> RefillRate -> UTCTime -> TokenBucket
- Control.NumerusClosus.Typed: [maxBucket] :: SlidingWindowBucketed -> BucketSize Integer
+ Control.NumerusClosus.Typed: [maxBucket] :: LeakyBucket -> BucketSize Double
Files
- numerus-closus.cabal +1/−1
- src/Control/NumerusClosus.hs +21/−1
- src/Control/NumerusClosus/Typed.hs +114/−0
- test/Spec.hs +95/−1
numerus-closus.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: numerus-closus-version: 0.4.0.1+version: 0.4.1.0 author: Gautier DI FOLCO maintainer: gautier.difolco@gmail.com category: Scheduling
src/Control/NumerusClosus.hs view
@@ -37,6 +37,8 @@ Typed.BucketSize (..), Typed.WindowSize (..), Typed.BucketsCount (..),+ Typed.RefillRate (..),+ Typed.DrainRate (..), -- * Base helpers alwaysAllow,@@ -48,6 +50,9 @@ slidingWindow, slidingWindowCount, slidingWindowBucketed,+ tokenBucket,+ leakyBucket,+ gcra, -- * Combinators (.&&),@@ -74,7 +79,7 @@ import qualified Control.NumerusClosus.Typed as Typed import qualified Data.List.NonEmpty as NE import Data.Semigroup (First (..), Last (..), Max (..), Min (..))-import Data.Time (UTCTime (..))+import Data.Time (NominalDiffTime, UTCTime (..)) -- * Main logic @@ -126,6 +131,21 @@ -- | Sliding window rate limiter using sub-bucket counters. slidingWindowBucketed :: Typed.WindowSize -> Typed.BucketsCount Int -> Typed.BucketSize Integer -> RateLimiter slidingWindowBucketed ws bc bs = RateLimiter $ Typed.slidingWindowBucketed ws bc bs++-- | Token bucket with continuous refill.+-- @maxBucket@ is the burst capacity, @refillRate@ is tokens restored per second.+tokenBucket :: Typed.BucketSize Double -> Typed.RefillRate -> UTCTime -> RateLimiter+tokenBucket bs rr = RateLimiter . Typed.tokenBucket bs rr++-- | Leaky bucket rate limiter.+-- @maxBucket@ is the queue capacity, @drainRate@ is how fast it empties.+leakyBucket :: Typed.BucketSize Double -> Typed.DrainRate -> UTCTime -> RateLimiter+leakyBucket bs dr = RateLimiter . Typed.leakyBucket bs dr++-- | Generic Cell Rate Algorithm (GCRA).+-- @limit@ requests allowed per @period@.+gcra :: Int -> NominalDiffTime -> UTCTime -> RateLimiter+gcra limit period = RateLimiter . Typed.gcra limit period -- * Combinators
src/Control/NumerusClosus/Typed.hs view
@@ -49,6 +49,14 @@ BucketsCount (..), SlidingWindowBucketed (..), slidingWindowBucketed,+ RefillRate (..),+ TokenBucket (..),+ tokenBucket,+ DrainRate (..),+ LeakyBucket (..),+ leakyBucket,+ GCRA (..),+ gcra, -- * Combinators type (:&&) (..),@@ -274,6 +282,112 @@ slidingWindowCount :: WindowSize -> BucketSize Int -> UTCTime -> SlidingWindowCount slidingWindowCount windowSize@(WindowSize window) maxBucket windowStart = SlidingWindowCount {prevCount = 0, currentCount = 0, windowEnd = addUTCTime window windowStart, ..}++-- | The rate at which tokens are refilled (tokens per second).+newtype RefillRate = RefillRate+ { unRefillRate :: Double+ }+ deriving stock (Eq, Ord, Show)++-- | Token bucket with continuous refill.+-- Tokens are consumed on each request and refilled at a steady rate over time.+-- Allows bursts up to the bucket capacity while maintaining a long-term average rate.+data TokenBucket = TokenBucket+ { maxBucket :: BucketSize Double,+ refillRate :: RefillRate,+ -- \* Iteration fields+ tokens :: Double,+ lastRefill :: UTCTime+ }+ deriving stock (Eq, Show, Generic)++instance RateLimiter TokenBucket where+ debit TokenBucket {..} now =+ if refreshedTokens >= 1+ then Right $ TokenBucket {tokens = refreshedTokens - 1, lastRefill = now, ..}+ else+ let tokensNeeded = 1 - refreshedTokens+ secondsToWait = tokensNeeded / refillRate.unRefillRate+ in Left $ DebitableFrom $ nextTimeUnit $ addUTCTime (realToFrac secondsToWait) now+ where+ elapsed = realToFrac (diffUTCTime now lastRefill) :: Double+ refreshedTokens = min maxBucket.unBucketSize (tokens + elapsed * refillRate.unRefillRate)++-- | Create a token bucket rate limiter.+-- @maxBucket@ is the burst capacity, @refillRate@ is tokens restored per second.+tokenBucket :: BucketSize Double -> RefillRate -> UTCTime -> TokenBucket+tokenBucket maxBucket refillRate lastRefill =+ TokenBucket {tokens = maxBucket.unBucketSize, ..}++-- | The rate at which the bucket drains (requests per second).+newtype DrainRate = DrainRate+ { unDrainRate :: Double+ }+ deriving stock (Eq, Ord, Show)++-- | Leaky bucket rate limiter.+-- Requests fill a bucket that drains at a constant rate. If the bucket overflows+-- the request is denied. This produces a perfectly smooth output rate.+data LeakyBucket = LeakyBucket+ { maxBucket :: BucketSize Double,+ drainRate :: DrainRate,+ -- \* Iteration fields+ level :: Double,+ lastDrain :: UTCTime+ }+ deriving stock (Eq, Show, Generic)++instance RateLimiter LeakyBucket where+ debit LeakyBucket {..} now =+ let newLevel = refreshedLevel + 1+ in if newLevel <= maxBucket.unBucketSize+ then Right $ LeakyBucket {level = newLevel, lastDrain = now, ..}+ else+ let excess = newLevel - maxBucket.unBucketSize+ secondsToWait = excess / drainRate.unDrainRate+ in Left $ DebitableFrom $ nextTimeUnit $ addUTCTime (realToFrac secondsToWait) now+ where+ elapsed = realToFrac (diffUTCTime now lastDrain) :: Double+ refreshedLevel = max 0 (level - elapsed * drainRate.unDrainRate)++-- | Create a leaky bucket rate limiter.+-- @maxBucket@ is the queue capacity, @drainRate@ is how fast it empties.+leakyBucket :: BucketSize Double -> DrainRate -> UTCTime -> LeakyBucket+leakyBucket maxBucket drainRate lastDrain =+ LeakyBucket {level = 0, ..}++-- | Generic Cell Rate Algorithm (GCRA).+-- A memoryless variant of leaky bucket that tracks only a single \"theoretical+-- arrival time\" (TAT). Each request advances the TAT by the emission interval+-- (@1 / rate@). A request is allowed if the TAT is not too far in the future+-- (bounded by the burst tolerance, derived from @limit / rate@).+data GCRA = GCRA+ { emissionInterval :: NominalDiffTime,+ burstTolerance :: NominalDiffTime,+ -- \* Iteration field+ tat :: UTCTime+ }+ deriving stock (Eq, Show, Generic)++instance RateLimiter GCRA where+ debit GCRA {..} now =+ let newTat = addUTCTime emissionInterval (max now tat)+ allowAt = addUTCTime (negate burstTolerance) newTat+ in if allowAt <= now+ then Right $ GCRA {tat = newTat, ..}+ else Left $ DebitableFrom $ nextTimeUnit allowAt++-- | Create a GCRA rate limiter.+-- @limit@ is the number of requests allowed in the period, @period@ is the+-- time window. The emission interval is @period / limit@ and the burst+-- tolerance is @period@.+gcra :: Int -> NominalDiffTime -> UTCTime -> GCRA+gcra limit period now =+ GCRA+ { emissionInterval = period / fromIntegral limit,+ burstTolerance = period,+ tat = now+ } -- | Helper to get the smallest next time unit for debiting. nextTimeUnit :: UTCTime -> UTCTime
test/Spec.hs view
@@ -8,7 +8,7 @@ import qualified Control.NumerusClosus as NC import Data.List.NonEmpty (NonEmpty ((:|))) import Data.Semigroup (Max (..), Min (..))-import Data.Time (UTCTime (..), addUTCTime, fromGregorian, secondsToNominalDiffTime)+import Data.Time (NominalDiffTime, UTCTime (..), addUTCTime, fromGregorian, secondsToNominalDiffTime) import qualified Hedgehog import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range@@ -70,6 +70,10 @@ assertLeftDebitableFromH t (Left (NC.DebitableFrom t')) | t == t' = pure () assertLeftDebitableFromH _ _ = Hedgehog.failure +assertLeftAnyH :: (Hedgehog.MonadTest m) => Either NC.NextDebitable NC.RateLimiter -> m ()+assertLeftAnyH (Left _) = pure ()+assertLeftAnyH (Right _) = Hedgehog.failure+ spec :: Spec spec = do describe "Control.NumerusClosus" do@@ -306,6 +310,96 @@ Left _ -> Hedgehog.failure case NC.debit (rl2 .|| rl1) t of Right _ -> pure ()+ Left _ -> Hedgehog.failure++ describe "tokenBucket" do+ let maxBucket = 5 :: Int+ refillRate = NC.RefillRate 1.0+ rl = NC.tokenBucket (NC.BucketSize (fromIntegral maxBucket)) refillRate baseTime++ it "Allows burst up to maxBucket" do+ assertRight $ debitN maxBucket baseTime rl++ it "Denies after burst exhausted" do+ case debitN maxBucket baseTime rl of+ Right rl' -> assertLeftAny $ NC.debit rl' baseTime+ Left _ -> fail "should allow maxBucket debits"++ it "Refills tokens over time" do+ case debitN maxBucket baseTime rl of+ Right rl' -> assertRight $ NC.debit rl' (offsetTime 1)+ Left _ -> fail "should allow maxBucket debits"++ it "Does not exceed maxBucket after long wait" do+ case debitN maxBucket (offsetTime 1000) rl of+ Right rl' -> assertLeftAny $ NC.debit rl' (offsetTime 1000)+ Left _ -> fail "should allow maxBucket debits after refill"++ describe "leakyBucket" do+ let maxBucket = 5 :: Int+ drainRate = NC.DrainRate 1.0+ rl = NC.leakyBucket (NC.BucketSize (fromIntegral maxBucket)) drainRate baseTime++ it "Allows up to maxBucket requests" do+ assertRight $ debitN maxBucket baseTime rl++ it "Denies when bucket overflows" do+ case debitN maxBucket baseTime rl of+ Right rl' -> assertLeftAny $ NC.debit rl' baseTime+ Left _ -> fail "should allow maxBucket debits"++ it "Drains over time allowing more requests" do+ case debitN maxBucket baseTime rl of+ Right rl' -> assertRight $ NC.debit rl' (offsetTime 1)+ Left _ -> fail "should allow maxBucket debits"++ it "Fully drained after sufficient time" do+ case debitN maxBucket baseTime rl of+ Right rl' -> assertRight $ debitN maxBucket (offsetTime 10) rl'+ Left _ -> fail "should allow maxBucket debits"++ describe "gcra" do+ let limit = 5 :: Int+ period = 60 :: NominalDiffTime+ rl = NC.gcra limit period baseTime++ it "Allows burst up to limit" do+ assertRight $ debitN limit baseTime rl++ it "Denies after limit exhausted within period" do+ case debitN limit baseTime rl of+ Right rl' -> assertLeftAny $ NC.debit rl' baseTime+ Left _ -> fail "should allow limit debits"++ it "Allows again after emission interval passes" do+ case debitN limit baseTime rl of+ Right rl' -> assertRight $ NC.debit rl' (offsetTime 12)+ Left _ -> fail "should allow limit debits"++ it "Fully resets after full period" do+ case debitN limit baseTime rl of+ Right rl' -> assertRight $ debitN limit (offsetTime 61) rl'+ Left _ -> fail "should allow limit debits"++ it "tokenBucket allows exactly maxBucket burst then denies" $ hedgehog do+ maxBucket <- forAll (Gen.integral (Range.linear 1 50))+ let rl = NC.tokenBucket (NC.BucketSize (fromIntegral @Integer maxBucket)) (NC.RefillRate 1.0) baseTime+ case debitN (fromIntegral maxBucket) baseTime rl of+ Right rl' -> assertLeftAnyH $ NC.debit rl' baseTime+ Left _ -> Hedgehog.failure++ it "leakyBucket allows exactly maxBucket then denies" $ hedgehog do+ maxBucket <- forAll (Gen.integral (Range.linear 1 50))+ let rl = NC.leakyBucket (NC.BucketSize (fromIntegral @Integer maxBucket)) (NC.DrainRate 1.0) baseTime+ case debitN (fromIntegral maxBucket) baseTime rl of+ Right rl' -> assertLeftAnyH $ NC.debit rl' baseTime+ Left _ -> Hedgehog.failure++ it "gcra allows exactly limit then denies" $ hedgehog do+ limit <- forAll (Gen.integral (Range.linear 1 50))+ let rl = NC.gcra (fromIntegral @Integer limit) 60 baseTime+ case debitN (fromIntegral limit) baseTime rl of+ Right rl' -> assertLeftAnyH $ NC.debit rl' baseTime Left _ -> Hedgehog.failure genNextDebitable :: Hedgehog.Gen NC.NextDebitable