packages feed

gbnet-hs-0.2.2.0: src/GBNet/Congestion.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}

-- |
-- Module      : GBNet.Congestion
-- Description : Binary congestion control and bandwidth tracking
--
-- Good\/Bad mode congestion control, byte-budget gating,
-- adaptive recovery timer, message batching, and bandwidth tracking.
module GBNet.Congestion
  ( -- * Constants
    congestionRateReduction,
    minSendRate,
    batchHeaderSize,
    batchLengthSize,
    maxBatchMessages,
    initialCwndPackets,
    minCwndBytes,
    minRecoverySecs,
    maxRecoverySecs,
    recoveryHalveIntervalSecs,
    quickDropThresholdSecs,
    initialSsthresh,

    -- * Congestion mode
    CongestionMode (..),
    CongestionPhase (..),

    -- * Binary congestion controller
    CongestionController (..),
    newCongestionController,
    ccRefillBudget,
    ccDeductBudget,
    ccUpdate,
    ccCanSend,

    -- * Congestion level query
    ccCongestionLevel,
    cwCongestionLevel,

    -- * Window-based congestion controller
    CongestionWindow (..),
    newCongestionWindow,
    cwOnAck,
    cwOnLoss,
    cwOnSend,
    cwCanSend,
    cwUpdatePacing,
    cwCanSendPaced,
    cwSlowStartRestart,

    -- * Bandwidth tracking
    BandwidthTracker (..),
    newBandwidthTracker,
    btRecord,
    btBytesPerSecond,

    -- * Message batching
    batchMessages,
    unbatchMessages,
  )
where

import Control.DeepSeq (NFData (..))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as BSB
import qualified Data.ByteString.Lazy as BSL
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import Data.Word (Word64, Word8)
import GBNet.Reliability (MonoTime, elapsedMs)
import GBNet.Stats (CongestionLevel (..))
import Optics ((%~), (&), (.~), (?~))
import Optics.TH (makeFieldLabelsNoPrefix)

-- Constants

-- | Multiplicative decrease factor on congestion (halve rate).
congestionRateReduction :: Double
congestionRateReduction = 0.5

-- | Minimum send rate floor in packets per second.
minSendRate :: Double
minSendRate = 1.0

-- | Batch header overhead: 1 byte for message count.
batchHeaderSize :: Int
batchHeaderSize = 1

-- | Per-message length prefix size in a batch: 2 bytes (big-endian u16).
batchLengthSize :: Int
batchLengthSize = 2

-- | Maximum messages per batch (limited by u8 count field).
maxBatchMessages :: Word8
maxBatchMessages = 255

-- | Initial congestion window in packets (slow start).
initialCwndPackets :: Int
initialCwndPackets = 10

-- | Minimum congestion window in bytes (one MTU).
minCwndBytes :: Int
minCwndBytes = 1200

-- | Minimum adaptive recovery time in seconds.
minRecoverySecs :: Double
minRecoverySecs = 1.0

-- | Maximum adaptive recovery time in seconds.
maxRecoverySecs :: Double
maxRecoverySecs = 60.0

-- | Interval in seconds of sustained good conditions before halving recovery time.
recoveryHalveIntervalSecs :: Double
recoveryHalveIntervalSecs = 10.0

-- | If Good mode lasts less than this (seconds) before re-entering Bad, double recovery time.
quickDropThresholdSecs :: Double
quickDropThresholdSecs = 10.0

-- | Initial slow-start threshold (IEEE 754 positive infinity).
initialSsthresh :: Double
initialSsthresh = 1 / 0

-- | Additive increase per update tick when in Good mode (packets/sec).
sendRateIncrease :: Double
sendRateIncrease = 1.0

-- | Maximum send rate multiplier relative to base rate.
maxSendRateMultiplier :: Double
maxSendRateMultiplier = 4.0

-- | Binary congestion state.
data CongestionMode
  = CongestionGood
  | CongestionBad
  deriving (Eq, Show)

instance NFData CongestionMode where rnf x = x `seq` ()

-- | Phase for window-based congestion control.
data CongestionPhase
  = SlowStart
  | Avoidance
  | Recovery
  deriving (Eq, Show)

instance NFData CongestionPhase where rnf x = x `seq` ()

-- | Binary congestion controller (Good\/Bad mode with AIMD).
data CongestionController = CongestionController
  { ccMode :: !CongestionMode,
    ccGoodConditionsStart :: !(Maybe MonoTime),
    ccLossThreshold :: !Double,
    ccRttThresholdMs :: !Double,
    ccBaseSendRate :: !Double,
    ccCurrentSendRate :: !Double,
    ccBudgetBytesRemaining :: !Int,
    ccBytesPerTick :: !Int,
    ccAdaptiveRecoverySecs :: !Double,
    ccLastGoodEntry :: !(Maybe MonoTime),
    ccLastBadEntry :: !(Maybe MonoTime)
  }
  deriving (Show)

makeFieldLabelsNoPrefix ''CongestionController

instance NFData CongestionController where
  rnf (CongestionController m gs lt rt br cr bb bt ar lg lb) =
    rnf m `seq`
      rnf gs `seq`
        rnf lt `seq`
          rnf rt `seq`
            rnf br `seq`
              rnf cr `seq`
                rnf bb `seq`
                  rnf bt `seq`
                    rnf ar `seq`
                      rnf lg `seq`
                        rnf lb

-- | Create a new congestion controller.
newCongestionController :: Double -> Double -> Double -> Double -> CongestionController
newCongestionController baseSendRate lossThreshold rttThresholdMs recoveryTimeMs =
  CongestionController
    { ccMode = CongestionGood,
      ccGoodConditionsStart = Nothing,
      ccLossThreshold = lossThreshold,
      ccRttThresholdMs = rttThresholdMs,
      ccBaseSendRate = baseSendRate,
      ccCurrentSendRate = baseSendRate,
      ccBudgetBytesRemaining = 0,
      ccBytesPerTick = 0,
      ccAdaptiveRecoverySecs = recoveryTimeMs / 1000.0,
      ccLastGoodEntry = Nothing,
      ccLastBadEntry = Nothing
    }

-- | Refill the byte budget at the start of each tick.
ccRefillBudget :: Int -> CongestionController -> CongestionController
ccRefillBudget mtu cc =
  let bytesPerTick = floor (ccCurrentSendRate cc * fromIntegral mtu)
   in cc
        & #ccBytesPerTick
        .~ bytesPerTick
        & #ccBudgetBytesRemaining
        .~ bytesPerTick

-- | Deduct bytes from the send budget.
ccDeductBudget :: Int -> CongestionController -> CongestionController
ccDeductBudget bytes cc =
  cc & #ccBudgetBytesRemaining %~ subtract bytes
{-# INLINE ccDeductBudget #-}

-- | Update congestion state based on current network conditions.
ccUpdate :: Double -> Double -> MonoTime -> CongestionController -> CongestionController
ccUpdate packetLoss rttMs now cc =
  let isBad = packetLoss > ccLossThreshold cc || rttMs > ccRttThresholdMs cc
   in case ccMode cc of
        CongestionGood
          | isBad ->
              -- Quick re-entry to Bad doubles recovery timer
              let recoveryMult = case ccLastGoodEntry cc of
                    Just goodEntry ->
                      if elapsedMs goodEntry now < quickDropThresholdSecs * 1000.0
                        then 2.0
                        else 1.0
                    Nothing -> 1.0
                  newRecovery = min maxRecoverySecs (ccAdaptiveRecoverySecs cc * recoveryMult)
                  newRate = max minSendRate (ccCurrentSendRate cc * congestionRateReduction)
               in cc
                    & #ccMode
                    .~ CongestionBad
                    & #ccLastBadEntry
                    ?~ now
                    & #ccCurrentSendRate
                    .~ newRate
                    & #ccGoodConditionsStart
                    .~ Nothing
                    & #ccAdaptiveRecoverySecs
                    .~ newRecovery
          | otherwise ->
              -- Additive increase: ramp rate up toward max capacity
              let maxRate = ccBaseSendRate cc * maxSendRateMultiplier
                  newRate = min maxRate (ccCurrentSendRate cc + sendRateIncrease)
                  -- Halve recovery time after sustained good conditions
                  ramped = cc & #ccCurrentSendRate .~ newRate
               in case ccLastGoodEntry ramped of
                    Just goodEntry ->
                      let elapsed = elapsedMs goodEntry now / 1000.0
                          intervals = floor (elapsed / recoveryHalveIntervalSecs) :: Int
                       in if intervals > 0
                            then
                              let newRecovery =
                                    max
                                      minRecoverySecs
                                      (ccAdaptiveRecoverySecs ramped / (2.0 ^ intervals))
                               in ramped
                                    & #ccAdaptiveRecoverySecs
                                    .~ newRecovery
                                    & #ccLastGoodEntry
                                    ?~ now
                            else ramped
                    Nothing -> ramped
        CongestionBad
          | not isBad ->
              case ccGoodConditionsStart cc of
                Nothing ->
                  cc & #ccGoodConditionsStart ?~ now
                Just start ->
                  let requiredMs = ccAdaptiveRecoverySecs cc * 1000.0
                   in if elapsedMs start now >= requiredMs
                        then
                          cc
                            & #ccMode
                            .~ CongestionGood
                            & #ccLastGoodEntry
                            ?~ now
                            & #ccCurrentSendRate
                            .~ ccBaseSendRate cc
                            & #ccGoodConditionsStart
                            .~ Nothing
                        else cc
          | otherwise ->
              cc & #ccGoodConditionsStart .~ Nothing

-- | Check if a packet can be sent given packets sent and size.
ccCanSend :: Int -> Int -> CongestionController -> Bool
ccCanSend packetsSentThisCycle packetBytes cc =
  fromIntegral packetsSentThisCycle < ccCurrentSendRate cc
    && ccBudgetBytesRemaining cc >= packetBytes
{-# INLINE ccCanSend #-}

-- | Query congestion level from the binary controller.
ccCongestionLevel :: CongestionController -> CongestionLevel
ccCongestionLevel cc = case ccMode cc of
  CongestionBad -> CongestionCritical
  CongestionGood
    | budgetRatio < budgetElevatedThreshold -> CongestionElevated
    | otherwise -> CongestionNone
  where
    budgetRatio
      | ccBytesPerTick cc <= 0 = 1.0
      | otherwise = fromIntegral (ccBudgetBytesRemaining cc) / fromIntegral (ccBytesPerTick cc) :: Double
    budgetElevatedThreshold = 0.25

-- | Query congestion level from the window-based controller.
cwCongestionLevel :: CongestionWindow -> CongestionLevel
cwCongestionLevel cw
  | utilization > windowCriticalThreshold = CongestionCritical
  | utilization > windowHighThreshold = CongestionHigh
  | utilization > windowElevatedThreshold = CongestionElevated
  | otherwise = CongestionNone
  where
    utilization
      | cwCwnd cw <= 0 = 1.0
      | otherwise = fromIntegral (cwBytesInFlight cw) / cwCwnd cw
    windowElevatedThreshold = 0.7
    windowHighThreshold = 0.85
    windowCriticalThreshold = 0.95

-- | Window-based congestion controller (TCP-like).
data CongestionWindow = CongestionWindow
  { cwPhase :: !CongestionPhase,
    cwCwnd :: !Double,
    cwSsthresh :: !Double,
    cwBytesInFlight :: !Word64,
    cwMtu :: !Int,
    cwLastSendTime :: !(Maybe MonoTime),
    cwMinInterPacketDelay :: !Double -- milliseconds
  }
  deriving (Show)

makeFieldLabelsNoPrefix ''CongestionWindow

instance NFData CongestionWindow where
  rnf (CongestionWindow p c s b m l d) =
    rnf p `seq` rnf c `seq` rnf s `seq` rnf b `seq` rnf m `seq` rnf l `seq` rnf d

-- | Create a new congestion window.
newCongestionWindow :: Int -> CongestionWindow
newCongestionWindow mtu =
  CongestionWindow
    { cwPhase = SlowStart,
      cwCwnd = fromIntegral (initialCwndPackets * mtu),
      cwSsthresh = initialSsthresh,
      cwBytesInFlight = 0,
      cwMtu = mtu,
      cwLastSendTime = Nothing,
      cwMinInterPacketDelay = 0.0
    }

-- | Called when bytes are acknowledged.
cwOnAck :: Int -> CongestionWindow -> CongestionWindow
cwOnAck bytes cw =
  let deflated = cw & #cwBytesInFlight %~ (\b -> b - fromIntegral (min bytes (fromIntegral b)))
   in case cwPhase deflated of
        SlowStart ->
          let newCwnd = cwCwnd deflated + fromIntegral bytes
           in if newCwnd >= cwSsthresh deflated
                then deflated & #cwCwnd .~ newCwnd & #cwPhase .~ Avoidance
                else deflated & #cwCwnd .~ newCwnd
        Avoidance
          | cwCwnd deflated > 0 ->
              -- Additive increase: cwnd += mtu * bytes / cwnd
              let increase = fromIntegral (cwMtu deflated) * fromIntegral bytes / cwCwnd deflated
               in deflated & #cwCwnd %~ (+ increase)
          | otherwise -> deflated
        Recovery ->
          deflated -- Conservative in recovery

-- | Called on packet loss detection.
cwOnLoss :: CongestionWindow -> CongestionWindow
cwOnLoss cw =
  let newSsthresh = max (fromIntegral minCwndBytes) (cwCwnd cw / 2.0)
   in cw
        & #cwSsthresh
        .~ newSsthresh
        & #cwCwnd
        .~ newSsthresh
        & #cwPhase
        .~ Recovery

-- | Record bytes sent.
cwOnSend :: Int -> MonoTime -> CongestionWindow -> CongestionWindow
cwOnSend bytes now cw =
  cw
    & #cwBytesInFlight
    %~ (+ fromIntegral bytes)
    & #cwLastSendTime
    ?~ now

-- | Check if a packet can be sent.
cwCanSend :: Int -> CongestionWindow -> Bool
cwCanSend packetBytes cw =
  cwBytesInFlight cw + fromIntegral packetBytes <= floor (cwCwnd cw)
{-# INLINE cwCanSend #-}

-- | Update pacing delay from cwnd and RTT.
cwUpdatePacing :: Double -> CongestionWindow -> CongestionWindow
cwUpdatePacing rttMs cw
  | cwCwnd cw > 0 && rttMs > 0 =
      let packetsInWindow = cwCwnd cw / fromIntegral (cwMtu cw)
          delay =
            if packetsInWindow > 0
              then rttMs / packetsInWindow
              else 0.0
       in cw & #cwMinInterPacketDelay .~ delay
  | otherwise = cw

-- | Check if enough time has elapsed for pacing.
cwCanSendPaced :: MonoTime -> CongestionWindow -> Bool
cwCanSendPaced now cw =
  case cwLastSendTime cw of
    Nothing -> True
    Just lastSend -> elapsedMs lastSend now >= cwMinInterPacketDelay cw
{-# INLINE cwCanSendPaced #-}

-- | Reset to slow start if idle too long (RFC 2861).
-- Prevents bursting a stale window after an idle period.
cwSlowStartRestart :: Double -> MonoTime -> CongestionWindow -> CongestionWindow
cwSlowStartRestart rtoMs now cw =
  case cwLastSendTime cw of
    Nothing -> cw
    Just lastSend
      | elapsedMs lastSend now > ssrIdleThreshold * rtoMs ->
          let initialCwnd = fromIntegral (initialCwndPackets * cwMtu cw)
           in cw
                & #cwPhase
                .~ SlowStart
                & #cwCwnd
                .~ initialCwnd
                & #cwSsthresh
                .~ cwCwnd cw
      | otherwise -> cw
  where
    -- Idle for more than 2 RTOs triggers restart
    ssrIdleThreshold = 2.0

-- | Bandwidth tracker using a sliding window with cached byte total.
data BandwidthTracker = BandwidthTracker
  { btWindow :: !(Seq (MonoTime, Int)),
    btWindowDurationMs :: !Double,
    -- | Cached running total of bytes in window
    btTotalBytes :: !Int
  }
  deriving (Show)

instance NFData BandwidthTracker where
  rnf (BandwidthTracker w d t) = rnf w `seq` rnf d `seq` rnf t

makeFieldLabelsNoPrefix ''BandwidthTracker

-- | Create a new bandwidth tracker.
newBandwidthTracker :: Double -> BandwidthTracker
newBandwidthTracker windowDurationMs =
  BandwidthTracker
    { btWindow = Seq.empty,
      btWindowDurationMs = windowDurationMs,
      btTotalBytes = 0
    }

-- | Record bytes at the given time.
btRecord :: Int -> MonoTime -> BandwidthTracker -> BandwidthTracker
btRecord bytes now bt =
  let recorded = bt & #btWindow %~ (Seq.|> (now, bytes)) & #btTotalBytes %~ (+ bytes)
   in btCleanup now recorded

-- | Get bytes per second.
btBytesPerSecond :: BandwidthTracker -> Double
btBytesPerSecond bt
  | Seq.null (btWindow bt) = 0.0
  | otherwise =
      let elapsedSecs = btWindowDurationMs bt / 1000.0
       in if elapsedSecs > 0
            then fromIntegral (btTotalBytes bt) / elapsedSecs
            else 0.0

-- | Clean up old entries, maintaining the cached byte total.
btCleanup :: MonoTime -> BandwidthTracker -> BandwidthTracker
btCleanup now bt =
  let cutoffMs = btWindowDurationMs bt
      isStale (t, _) = elapsedMs t now >= cutoffMs
      -- Drop stale entries from the front, subtracting their bytes
      (stale, recent) = Seq.spanl isStale (btWindow bt)
      evictedBytes = sum $ snd <$> stale
   in bt & #btWindow .~ recent & #btTotalBytes %~ subtract evictedBytes

-- | Pack multiple messages into batched packets.
-- Wire format: [u8 count][u16 len][data]...
batchMessages :: [BS.ByteString] -> Int -> [BS.ByteString]
batchMessages messages maxSize = reverse $ go messages [] [] batchHeaderSize 0
  where
    -- Both accumulators are built in reverse (cons) for O(1) prepend.
    go [] currentBatch batchesAcc _ msgCount
      | msgCount > 0 = finalizeBatch msgCount currentBatch : batchesAcc
      | otherwise = batchesAcc
    go (msg : rest) currentBatch batchesAcc currentSize msgCount =
      let msgWireSize = batchLengthSize + BS.length msg
          shouldFinalize =
            (currentSize + msgWireSize > maxSize && msgCount > 0)
              || msgCount >= fromIntegral maxBatchMessages
       in if shouldFinalize
            then
              go
                (msg : rest)
                []
                (finalizeBatch msgCount currentBatch : batchesAcc)
                batchHeaderSize
                0
            else
              go
                rest
                (msg : currentBatch)
                batchesAcc
                (currentSize + msgWireSize)
                (msgCount + 1)

    finalizeBatch :: Int -> [BS.ByteString] -> BS.ByteString
    finalizeBatch count msgsRev =
      let msgs = reverse msgsRev
          countByte = BSB.word8 (fromIntegral count)
          msgBuilders = map (\m -> BSB.word16BE (fromIntegral (BS.length m)) <> BSB.byteString m) msgs
       in BSL.toStrict $ BSB.toLazyByteString $ countByte <> mconcat msgBuilders

-- | Unbatch a batched packet into individual messages.
unbatchMessages :: BS.ByteString -> Maybe [BS.ByteString]
unbatchMessages dat
  | BS.null dat = Nothing
  | otherwise =
      let msgCount = fromIntegral (BS.index dat 0) :: Int
       in readMessages msgCount 1 []
  where
    readMessages 0 _ acc = Just (reverse acc)
    readMessages n offset acc
      | offset + 2 > BS.length dat = Nothing
      | otherwise =
          let len =
                fromIntegral (BS.index dat offset) * 256
                  + fromIntegral (BS.index dat (offset + 1))
              msgStart = offset + 2
           in if msgStart + len > BS.length dat
                then Nothing
                else
                  let msg = BS.take len (BS.drop msgStart dat)
                   in readMessages (n - 1) (msgStart + len) (msg : acc)