packages feed

shibuya-metrics-0.11.0.0: src/Shibuya/Metrics/Outbound.hs

-- | Pure, strictly bounded outbound admission. Constructors for frames and queues
-- are private so cached sizes cannot diverge from the retained strict payloads.
module Shibuya.Metrics.Outbound
  ( Retention (..),
    Frame,
    frame,
    payload,
    generation,
    retention,
    Limits (..),
    OutboundQueue,
    empty,
    queuedFrames,
    queuedBytes,
    entries,
    Outcome (..),
    enqueue,
    pop,
    discardUpdates,
  )
where

import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Foldable (toList)
import Data.Sequence (Seq, ViewL (..), (><))
import Data.Sequence qualified as Seq

-- | Pongs are expendable but their loss does not invalidate the metrics ledger.
data Retention = Required | Update | Ephemeral
  deriving stock (Eq, Show)

data Frame = Frame !Int !Retention !ByteString
  deriving stock (Eq, Show)

frame :: Int -> Retention -> ByteString -> Frame
frame = Frame

payload :: Frame -> ByteString
payload (Frame _ _ bytes) = bytes

generation :: Frame -> Int
generation (Frame value _ _) = value

retention :: Frame -> Retention
retention (Frame _ value _) = value

data Limits = Limits {maxFrames :: !Int, maxBytes :: !Int}
  deriving stock (Eq, Show)

data OutboundQueue = Queue !(Seq Frame) !Int
  deriving stock (Eq, Show)

empty :: OutboundQueue
empty = Queue Seq.empty 0

queuedFrames :: OutboundQueue -> Int
queuedFrames (Queue values _) = Seq.length values

queuedBytes :: OutboundQueue -> Int
queuedBytes (Queue _ bytes) = bytes

entries :: OutboundQueue -> [Frame]
entries (Queue values _) = toList values

data Outcome = Enqueued | DroppedUpdates | MustClose
  deriving stock (Eq, Show)

-- | Admit the entire batch atomically or none of it. Evict expendable frames
-- oldest-first; never evict a required frame. Saturation by required frames
-- closes the connection instead of exceeding either limit. A rejected update
-- marks the comparison ledger as needing a full resynchronization.
enqueue :: Limits -> [Frame] -> OutboundQueue -> (Outcome, OutboundQueue)
enqueue limits batch original = go False original
  where
    incoming = Seq.fromList batch
    bytes = sum $ map (toInteger . BS.length . payload) batch
    fits q =
      toInteger (queuedFrames q) + toInteger (length batch) <= toInteger limits.maxFrames
        && toInteger (queuedBytes q) + bytes <= toInteger limits.maxBytes
    go dropped q@(Queue values size)
      | fits q = (if dropped then DroppedUpdates else Enqueued, Queue (values >< incoming) (size + fromInteger bytes))
      | otherwise = case Seq.findIndexL ((/= Required) . retention) values of
          Just index ->
            let victim = Seq.index values index
                next = Queue (Seq.deleteAt index values) (size - BS.length (payload victim))
             in go (dropped || retention victim == Update) next
          Nothing
            | any ((== Required) . retention) batch -> (MustClose, original)
            | otherwise -> (if dropped || any ((== Update) . retention) batch then DroppedUpdates else Enqueued, q)

pop :: OutboundQueue -> Maybe (Frame, OutboundQueue)
pop (Queue values size) = case Seq.viewl values of
  EmptyL -> Nothing
  value :< rest -> Just (value, Queue rest (size - BS.length (payload value)))

-- | Remove only unsent metrics updates. Required frames keep their order.
discardUpdates :: OutboundQueue -> OutboundQueue
discardUpdates (Queue values _) =
  let kept = Seq.filter ((/= Update) . retention) values
   in Queue kept (sum $ fmap (BS.length . payload) kept)