packages feed

fb-util-0.1.0.1: Data/MovingAverageRateLimiter.hs

{-
  Copyright (c) Meta Platforms, Inc. and affiliates.
  All rights reserved.

  This source code is licensed under the BSD-style license found in the
  LICENSE file in the root directory of this source tree.
-}

{- |
A standalone rate limiter that accepts some fluctuations in the traffic.

QPS_new = QPS_old * exp(-alpha * t) + n * alpha
where alpha is the decay factor, t is the time elapsed and n is the number of
new requests (that are allowed).

See also www/flib/social/ma_rate_limiter/MovingAverageRateLimiter.php
and fbcode/common/datastruct/MovingAverageRateLimiter.h
-}

module Data.MovingAverageRateLimiter
  ( RateLimiterConfig(qpsLimit, decayHalfLife)
  , mkRateLimiterConfig
  , RateLimiter(qps)
  , mkRateLimiter
  , updateConfig
  , observedRate
  , allow
  ) where

-- ----------------------------------------------------------------------------

data RateLimiterConfig = RateLimiterConfig
  { qpsLimit      :: {-# UNPACK #-} !Double  -- ^ Query per second limit
  , decayHalfLife :: {-# UNPACK #-} !Double
  -- ^ Time in seconds for the weight of past query to decay to half
  -- in moving average calculation
  , alpha         :: {-# UNPACK #-} !Double
  }

mkRateLimiterConfig
  :: Double  -- ^ query per second limit
  -> Double  -- ^ decay half life
  -> RateLimiterConfig
mkRateLimiterConfig ql d = RateLimiterConfig
  { qpsLimit = ql
  , decayHalfLife = d
  , alpha = log 2.0 / d
  }

updateConfig
  :: Double  -- ^ new query per second limit
  -> Double  -- ^ new decay half life
  -> RateLimiterConfig -> RateLimiterConfig
updateConfig ql d r = r
  { qpsLimit = ql
  , decayHalfLife = d
  , alpha = log 2.0 / d
  }

-- ----------------------------------------------------------------------------

data RateLimiter = RateLimiter
  { qps           :: {-# UNPACK #-} !Double
  , lastTimestamp :: {-# UNPACK #-} !Double  -- in sec
  }

mkRateLimiter :: RateLimiter
mkRateLimiter = RateLimiter
  { qps = 0
  , lastTimestamp = 0
  }

observedRate
  :: RateLimiter
  -> Double      -- ^ queries per second
  -> Double      -- ^ timestamp of the last query
  -> RateLimiter
observedRate r q t = r
  { qps = q
  , lastTimestamp = t
  }

allow
  :: RateLimiterConfig
  -> RateLimiter
  -> Double               -- ^ current timestamp
  -> (Bool, RateLimiter)  -- ^ should the query is allowed? and the new state
allow RateLimiterConfig{..} r@RateLimiter{..} t = (a, observedRate r q2 t)
  where
  timeDiff = t - lastTimestamp
  q1 = if timeDiff > 0 then qps * exp (- alpha * timeDiff) else qps
  (a, q2) | lastTimestamp == 0 = (True , qpsLimit * (1 - alpha) + alpha)
                                   -- See Note [First Bump]
          | q1 < qpsLimit      = (True , q1 + alpha)
          | otherwise          = (False, q1)

-- Note [First Bump]
-- If the lastTimestamp is zero, this is the first time a RateLimiter has been
-- bumped. In that case, always allow, then set the qps to the qpsLimit minus
-- enough room for (qpsLimit - 1) additional bumps. This means the RateLimiter
-- will allow qpsLimit bumps at time zero, then throttle at bump qpsLimit + 1.
-- Without this, time is required for the count to approach the decay rate,
-- resulting in an initial spike of allowed bumps. For example, a RateLimiter
-- configured to 1 qps with 600 s half-life would allow at least the first
-- 865 bumps before actually limiting.