packages feed

keiro-ops-http-0.22.0.0: src/Keiro/Ops/Http/Feed/Queue.hs

-- | One bounded outbound queue per socket, including the writer's in-flight
-- frame. Broadcast bytes are shared; overflow is signalled before later deltas.
module Keiro.Ops.Http.Feed.Queue
  ( OutboundQueue,
    Outgoing (..),
    newOutboundQueue,
    prepareOutgoing,
    enqueueOutgoing,
    nextOutgoing,
    finishOutgoing,
    nextFrame,
    purgeFeed,
    closeQueue,
  )
where

import Control.Concurrent.STM
import Data.Aeson (toJSON)
import Data.ByteString qualified as Bytes
import Data.ByteString.Lazy qualified as Lazy
import Data.Map.Strict qualified as Map
import Data.Sequence (Seq, ViewL (..), (|>))
import Data.Sequence qualified as Seq
import Data.Set qualified as Set
import Keiro.Ops.Http.Budget (encodeBounded)
import Keiro.Ops.Http.Protocol

-- | Keep the frame for typed consumers and its single shared wire encoding.
data Outgoing = Outgoing {frame :: !ServerFrame, bytes :: !Bytes.ByteString, owner :: !(Maybe FeedName)}

data QueueState = QueueState
  { pending :: !(Seq Outgoing),
    usedBytes :: !Int,
    usedFrames :: !Int,
    overflow :: !(Set.Set (Maybe FeedName)),
    closed :: !Bool
  }

data OutboundQueue = OutboundQueue !Int !Int !(Map.Map (Maybe FeedName) Outgoing) !(TVar QueueState)

newOutboundQueue :: Int -> Int -> Int -> IO OutboundQueue
newOutboundQueue capacity byteLimit frameLimit
  | capacity <= 0 || byteLimit < 256 || frameLimit < 256 = ioError (userError "feed queues must hold a control frame (256 bytes) and at least one frame")
  | otherwise = do
      errors <- mapM makeError [Nothing, Just Workflows, Just Timers, Just ProjectionGroups]
      OutboundQueue capacity byteLimit (Map.fromList errors) <$> newTVarIO (QueueState Seq.empty 0 0 Set.empty False)
  where
    makeError feed = do
      let value = ErrorFrame QueueOverflow "frames were dropped for this feed; re-subscribe or re-read" feed
      prepared <- prepareOutgoing (min frameLimit byteLimit) value
      maybe (ioError (userError "feed byte limits cannot hold an overflow frame")) (pure . (feed,)) prepared

prepareOutgoing :: Int -> ServerFrame -> IO (Maybe Outgoing)
prepareOutgoing limit value = do
  encoded <- encodeBounded limit (toJSON value)
  pure (fmap (\bytes -> Outgoing value (Lazy.toStrict bytes) (frameOwner value)) encoded)

frameOwner :: ServerFrame -> Maybe FeedName
frameOwner = \case
  Snapshot feed _ _ _ _ _ -> Just feed
  Update feed _ _ _ _ _ _ -> Just feed
  Unsubscribed feed -> Just feed
  ErrorFrame _ _ feed -> feed
  _ -> Nothing

size :: Outgoing -> Int
size value = Bytes.length value.bytes

-- Drop oldest queued frames until both limits fit. An in-flight frame cannot
-- be retracted; if it occupies the budget, discard the incoming frame instead.
roomFor :: Int -> Int -> Outgoing -> QueueState -> QueueState
roomFor capacity byteLimit incoming state
  | state.usedFrames < capacity && toInteger state.usedBytes + toInteger (size incoming) <= toInteger byteLimit = state
  | otherwise = case Seq.viewl state.pending of
      EmptyL -> state
      dropped :< rest ->
        roomFor
          capacity
          byteLimit
          incoming
          state
            { pending = rest,
              usedFrames = state.usedFrames - 1,
              usedBytes = state.usedBytes - size dropped,
              overflow = Set.insert dropped.owner state.overflow
            }

enqueueOutgoing :: OutboundQueue -> Outgoing -> STM Bool
enqueueOutgoing (OutboundQueue capacity byteLimit _ variable) value = do
  initial <- readTVar variable
  if initial.closed
    then pure False
    else do
      let state = roomFor capacity byteLimit value initial
          fits = state.usedFrames < capacity && toInteger state.usedBytes + toInteger (size value) <= toInteger byteLimit
          result =
            if fits
              then state {pending = state.pending |> value, usedFrames = state.usedFrames + 1, usedBytes = state.usedBytes + size value}
              else state {overflow = Set.insert value.owner state.overflow}
      writeTVar variable result
      pure fits

-- | The caller must finish this frame in a masked/bracketed writer before
-- dequeuing again. Its bytes/count stay charged while network delivery blocks.
nextOutgoing :: OutboundQueue -> STM (Maybe Outgoing)
nextOutgoing (OutboundQueue capacity byteLimit errors variable) = do
  state <- readTVar variable
  if state.closed
    then pure Nothing
    else case Set.lookupMin state.overflow of
      Just feed -> do
        let value = errors Map.! feed
            withRoom = roomFor capacity byteLimit value state
        check (withRoom.usedFrames < capacity && toInteger withRoom.usedBytes + toInteger (size value) <= toInteger byteLimit)
        writeTVar
          variable
          withRoom
            { overflow = Set.delete feed withRoom.overflow,
              usedFrames = withRoom.usedFrames + 1,
              usedBytes = withRoom.usedBytes + size value
            }
        pure (Just value)
      Nothing -> case Seq.viewl state.pending of
        EmptyL -> retry
        value :< rest -> do
          writeTVar variable state {pending = rest}
          pure (Just value)

finishOutgoing :: OutboundQueue -> Outgoing -> STM ()
finishOutgoing (OutboundQueue _ _ _ variable) value = modifyTVar' variable $ \state ->
  state {usedFrames = state.usedFrames - 1, usedBytes = state.usedBytes - size value}

-- | Typed in-process consumption releases the queue's reference immediately.
nextFrame :: OutboundQueue -> STM ServerFrame
nextFrame queue =
  nextOutgoing queue >>= \case
    Nothing -> pure Goodbye
    Just value -> finishOutgoing queue value >> pure value.frame

purgeFeed :: OutboundQueue -> FeedName -> STM ()
purgeFeed (OutboundQueue _ _ _ variable) feed = modifyTVar' variable $ \state ->
  let removed = Seq.filter ((== Just feed) . (.owner)) state.pending
   in state
        { pending = Seq.filter ((/= Just feed) . (.owner)) state.pending,
          usedFrames = state.usedFrames - Seq.length removed,
          usedBytes = state.usedBytes - sum (fmap size removed),
          overflow = Set.delete (Just feed) state.overflow
        }

closeQueue :: OutboundQueue -> STM ()
closeQueue (OutboundQueue _ _ _ variable) = modifyTVar' variable $ \state ->
  state
    { pending = Seq.empty,
      usedFrames = state.usedFrames - Seq.length state.pending,
      usedBytes = state.usedBytes - sum (fmap size state.pending),
      overflow = Set.empty,
      closed = True
    }