packages feed

numerus-closus-0.4.0.1: src/Control/NumerusClosus/Typed.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE NoFieldSelectors #-}

-- |
-- Module        : Control.NumerusClosus.Typed
-- Copyright     : Gautier DI FOLCO
-- License       : ISC
--
-- Maintainer    : Gautier DI FOLCO <gautier.difolco@gmail.com>
-- Stability     : Stable
-- Portability   : Portable
--
-- Simple, composable, pure rate-limiting primitives supporting finite buckets,
-- fixed windows, sliding windows, and sliding window counters. Includes AND/OR
-- combinators and scheduling helpers.
--
-- > import Control.NumerusClosus
-- > import Data.Time (getCurrentTime)
-- >
-- > main :: IO ()
-- > main = do
-- >   now <- getCurrentTime
-- >   let rl = fixedWindow 60 5 now  -- 5 requests per 60 seconds
-- >   result <- schedule rl (putStrLn "Request allowed")
-- >   print result
module Control.NumerusClosus.Typed
  ( RateLimiter (..),
    NextDebitable (..),

    -- * Base helpers
    AlwaysAllow (..),
    alwaysAllow,
    AlwaysDeny (..),
    alwaysDeny,

    -- * Strategies
    BucketSize (..),
    FiniteBucket (..),
    finiteBucket,
    WindowSize (..),
    FixedWindow (..),
    fixedWindow,
    SlidingWindow (..),
    slidingWindow,
    SlidingWindowCount (..),
    slidingWindowCount,
    BucketsCount (..),
    SlidingWindowBucketed (..),
    slidingWindowBucketed,

    -- * Combinators
    type (:&&) (..),
    type (:||) (..),

    -- * Scheduling
    PositionInTime (..),
    ioPositionInTime,
    schedule,
    scheduleWith,
    loopSchedule,
    loopScheduleWith,

    -- * Reexports
    First (..),
    Last (..),
    Max (..),
    Min (..),
  )
where

import Control.Concurrent.Thread.Delay (delay)
import Control.Monad (mfilter)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Semigroup (First (..), Last (..), Max (..), Min (..))
import qualified Data.Set as Set
import Data.Time (FormatTime, NominalDiffTime, ParseTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import GHC.Generics (Generic)

-- * Main logic

-- | The core rate limiter type.
-- Given the current time, returns either the next debitable time or a new rate limiter state.
class RateLimiter a where
  -- | Function to determine if a request is allowed at a given time.
  debit :: a -> UTCTime -> Either NextDebitable a

-- | Represents when the next request can be debited.
data NextDebitable
  = -- | The request can be debited from this time onwards.
    DebitableFrom UTCTime
  | -- | The request will never be debitable again.
    Never
  deriving stock (Eq, Ord, Show)

-- * Base helpers

-- | A rate limiter that always allows requests.
data AlwaysAllow = AlwaysAllow
  deriving stock (Eq, Show, Generic)

instance RateLimiter AlwaysAllow where
  debit AlwaysAllow _ = Right AlwaysAllow

-- | Smart constructor for 'AlwaysAllow'.
alwaysAllow :: AlwaysAllow
alwaysAllow = AlwaysAllow

-- | A rate limiter that always denies requests.
data AlwaysDeny = AlwaysDeny
  deriving stock (Eq, Show, Generic)

instance RateLimiter AlwaysDeny where
  debit AlwaysDeny _ = Left Never

-- | Smart constructor for 'AlwaysDeny'.
alwaysDeny :: AlwaysDeny
alwaysDeny = AlwaysDeny

-- * Strategies

-- | The maximum number of requests a bucket can hold.
newtype BucketSize a = BucketSize
  { unBucketSize :: a
  }
  deriving stock (Eq, Ord, Show)
  deriving newtype (Num)

-- | A simple finite bucket that allows exactly @n@ requests.
newtype FiniteBucket
  = FiniteBucket {fullBucket :: BucketSize Integer}
  deriving stock (Eq, Show, Generic)

instance RateLimiter FiniteBucket where
  debit (FiniteBucket (BucketSize n)) _ =
    if n > 0
      then Right $ FiniteBucket $ BucketSize (n - 1)
      else Left Never

finiteBucket :: BucketSize Integer -> FiniteBucket
finiteBucket fullBucket = FiniteBucket {..}

-- | The size of a time window.
newtype WindowSize = WindowSize
  { unWindowSize :: NominalDiffTime
  }
  deriving stock (Eq, Ord, Show)
  deriving newtype (Num, Fractional, Real, RealFrac, FormatTime, ParseTime)

-- | A fixed window rate limiter that allows a given number of requests per window.
data FixedWindow = FixedWindow
  { windowSize :: WindowSize,
    maxBucket :: BucketSize Integer,
    -- \* Iteration fields
    bucket :: BucketSize Integer,
    windowStart :: UTCTime,
    windowEnd :: UTCTime
  }
  deriving stock (Eq, Show, Generic)

instance RateLimiter FixedWindow where
  debit (FixedWindow {..}) now =
    if refreshedBucket > 0
      then Right $ FixedWindow {bucket = refreshedBucket - 1, windowEnd = refreshedEndTime, ..}
      else Left $ DebitableFrom $ nextTimeUnit refreshedEndTime
    where
      (refreshedBucket, refreshedEndTime) =
        if now > windowEnd
          then (maxBucket, addUTCTime (fromIntegral (floor (diffUTCTime now windowEnd / windowSize.unWindowSize) + 1 :: Integer) * windowSize.unWindowSize) windowEnd)
          else (bucket, windowEnd)

fixedWindow :: WindowSize -> BucketSize Integer -> UTCTime -> FixedWindow
fixedWindow windowSize@(WindowSize window) maxBucket windowStart =
  FixedWindow {bucket = maxBucket, windowEnd = addUTCTime window windowStart, ..}

-- | A sliding window rate limiter that allows a given number of requests within the sliding window.
data SlidingWindow = SlidingWindow
  { windowSize :: WindowSize,
    maxBucket :: BucketSize Int,
    -- \* Iteration field
    bucket :: Set.Set UTCTime
  }
  deriving stock (Eq, Show, Generic)

instance RateLimiter SlidingWindow where
  debit SlidingWindow {..} now =
    if Set.size refreshedBucket < maxBucket.unBucketSize
      then Right $ SlidingWindow {bucket = Set.insert now refreshedBucket, ..}
      else Left $ DebitableFrom $ nextTimeUnit $ addUTCTime windowSize.unWindowSize $ fromMaybe now $ Set.lookupMin refreshedBucket
    where
      refreshedBucket = snd $ Set.split (addUTCTime ((-1) * windowSize.unWindowSize) now) bucket

slidingWindow :: WindowSize -> BucketSize Int -> SlidingWindow
slidingWindow windowSize maxBucket =
  SlidingWindow {bucket = Set.empty, ..}

-- | The number of sub-windows for bucketed sliding windows.
newtype BucketsCount a = BucketsCount
  { unBucketsCount :: a
  }
  deriving stock (Eq, Ord, Show)
  deriving newtype (Num)

-- | Sliding window rate limiter using sub-bucket counters.
-- Divides the window into sub-buckets keyed by request arrival times and sums
-- their counters. This is a bucketed variant rather than the canonical
-- two-window weighted interpolation.
data SlidingWindowBucketed = SlidingWindowBucketed
  { windowSize :: WindowSize,
    bucketsCount :: BucketsCount Int,
    maxBucket :: BucketSize Integer,
    -- \* Iteration field
    buckets :: Map.Map UTCTime Integer
  }
  deriving stock (Eq, Show, Generic)

instance RateLimiter SlidingWindowBucketed where
  debit SlidingWindowBucketed {..} now =
    if sum refreshedBucket < maxBucket.unBucketSize
      then Right $ SlidingWindowBucketed {buckets = Map.alter (Just . maybe 1 (+ 1)) lastBucket refreshedBucket, ..}
      else Left $ DebitableFrom $ nextTimeUnit $ addUTCTime windowSize.unWindowSize $ maybe now fst $ Map.lookupMin refreshedBucket
    where
      refreshedBucket =
        Map.restrictKeys buckets $
          snd $
            Set.split (addUTCTime ((-1) * windowSize.unWindowSize) now) $
              Map.keysSet buckets
      lastBucket =
        fromMaybe now $
          mfilter (> addUTCTime ((-1) * bucketWindow) now) $
            fst <$> Map.lookupMax refreshedBucket
      bucketWindow = windowSize.unWindowSize / fromIntegral bucketsCount.unBucketsCount

slidingWindowBucketed :: WindowSize -> BucketsCount Int -> BucketSize Integer -> SlidingWindowBucketed
slidingWindowBucketed windowSize bucketsCount maxBucket =
  SlidingWindowBucketed {buckets = Map.empty, ..}

-- | Sliding window counter using two-window weighted interpolation.
-- Tracks counters for the current and previous fixed windows, then estimates
-- the request rate as: @prev * (1 - elapsed\/window) + current@.
-- This is the canonical sliding window counter algorithm with O(1) memory.
data SlidingWindowCount = SlidingWindowCount
  { windowSize :: WindowSize,
    maxBucket :: BucketSize Int,
    -- \* Iteration field
    prevCount :: Integer,
    currentCount :: Integer,
    windowStart :: UTCTime,
    windowEnd :: UTCTime
  }
  deriving stock (Eq, Show, Generic)

instance RateLimiter SlidingWindowCount where
  debit SlidingWindowCount {..} now =
    if estimate < fromIntegral maxBucket.unBucketSize
      then Right $ SlidingWindowCount {prevCount = refreshedPrev, currentCount = refreshedCurrent + 1, windowEnd = refreshedEnd, ..}
      else Left $ DebitableFrom $ nextTimeUnit refreshedEnd
    where
      (refreshedPrev, refreshedCurrent, refreshedEnd) =
        if now > windowEnd
          then
            let windowsElapsed = floor (diffUTCTime now windowEnd / windowSize.unWindowSize) :: Integer
                newEnd = addUTCTime (fromIntegral (windowsElapsed + 1) * windowSize.unWindowSize) windowEnd
             in if windowsElapsed == 0
                  then (currentCount, 0, newEnd)
                  else (0, 0, newEnd)
          else (prevCount, currentCount, windowEnd)
      newWindowStart = addUTCTime ((-1) * windowSize.unWindowSize) refreshedEnd
      fraction = realToFrac (diffUTCTime now newWindowStart) / realToFrac windowSize.unWindowSize :: Double
      estimate = fromIntegral refreshedPrev * (1 - fraction) + fromIntegral refreshedCurrent :: Double

slidingWindowCount :: WindowSize -> BucketSize Int -> UTCTime -> SlidingWindowCount
slidingWindowCount windowSize@(WindowSize window) maxBucket windowStart =
  SlidingWindowCount {prevCount = 0, currentCount = 0, windowEnd = addUTCTime window windowStart, ..}

-- | Helper to get the smallest next time unit for debiting.
nextTimeUnit :: UTCTime -> UTCTime
nextTimeUnit = addUTCTime 0.000001

-- * Combinators

-- | AND combinator type: allows a request if both rate limiters allow it.
data a :&& b = a :&& b
  deriving stock (Eq, Show, Generic)

infixr 3 :&&

instance (RateLimiter a, RateLimiter b) => RateLimiter (a :&& b) where
  debit (x :&& y) at =
    case (debit x at, debit y at) of
      (Right x', Right y') -> Right $ x' :&& y'
      (Left x', Left y') -> Left $ getLast $ Last x' <> Last y'
      (Left x', _) -> Left x'
      (_, Left y') -> Left y'

-- | OR combinator type: allows a request if either rate limiter allows it.
data a :|| b = a :|| b
  deriving stock (Eq, Show, Generic)

infixr 3 :||

instance (RateLimiter a, RateLimiter b) => RateLimiter (a :|| b) where
  debit (x :|| y) at =
    case (debit x at, debit y at) of
      (Right x', Right y') -> Right $ x' :|| y'
      (Left x', Left y') -> Left $ getFirst $ First x' <> First y'
      (Right x', _) -> Right $ x' :|| y
      (_, Right y') -> Right $ x :|| y'

-- * Scheduling

-- | Fetch the time and compute the delay time.
data PositionInTime m = PositionInTime
  { -- | How to fetch the current time.
    getTime :: m UTCTime,
    -- | How to delay until a specific time.
    delayUntil :: UTCTime -> m ()
  }

-- | Run an IO action if the rate limiter allows it, using the current time.
schedule :: (RateLimiter a) => a -> (a -> IO b) -> IO (Either NextDebitable (a, b))
schedule = scheduleWith ioPositionInTime

-- | Default time strategy for IO.
ioPositionInTime :: PositionInTime IO
ioPositionInTime = PositionInTime getCurrentTime diffDelay
  where
    diffDelay to = do
      now <- getCurrentTime
      delay $ 0 `max` round (diffUTCTime to now * 1000000)

-- | Run a monadic action if the rate limiter allows it, using a custom time strategy.
scheduleWith :: (Monad m, RateLimiter a) => PositionInTime m -> a -> (a -> m b) -> m (Either NextDebitable (a, b))
scheduleWith pit rl action = do
  now <- pit.getTime
  case debit rl now of
    Right rl' -> Right . (rl',) <$> action rl'
    Left nd -> return $ Left nd

-- | Repeatedly run an IO action, respecting the rate limiter. Will sleep until the next available slot if rate limited.
loopSchedule :: (RateLimiter a) => a -> (a -> IO b) -> IO ()
loopSchedule = loopScheduleWith ioPositionInTime

-- | Repeatedly run a monadic action, respecting the rate limiter and using a custom time strategy. Will sleep until the next available slot if rate limited.
loopScheduleWith :: (Monad m, RateLimiter a) => PositionInTime m -> a -> (a -> m b) -> m ()
loopScheduleWith pit rl action = go rl
  where
    go rl' = do
      result <- scheduleWith pit rl' action
      case result of
        Right (rl'', _) -> go rl''
        Left Never -> return ()
        Left (DebitableFrom at) -> pit.delayUntil at >> go rl'