packages feed

unliftio-messagebox-2.0.0: src/UnliftIO/MessageBox/Limited.hs

-- | Thread safe queues for uni directional message passing
-- between threads.
--
-- This message box has an upper limit, that means that
-- sometimes delivery either fails or is blocked until
-- the receiving thread has consumed more messages.
--
-- Use this module if the producer(s) outperform the consumer,
-- but you want the extra safety that the queue blocks the
-- 'Input' after a certain message limit is reached.
--
-- If you are sure that the producers fire at a slower rate
-- then the rate at which the consumer consumes messages, use this
-- module.
module UnliftIO.MessageBox.Limited
  ( MessageLimit (..),
    messageLimitToInt,
    BlockingBoxLimit (..),
    BlockingBox (),
    BlockingInput (),
    NonBlockingBoxLimit (..),
    NonBlockingBox (),
    NonBlockingInput (..),
    WaitingBoxLimit (..),
    WaitingBox (..),
    WaitingInput (..),
  )
where

import qualified Control.Concurrent.Chan.Unagi.Bounded as Unagi
import Control.Monad (unless)
import Data.Functor (($>))
import Data.Maybe (fromMaybe)
import UnliftIO.MessageBox.Util.Future (Future (..))
import qualified UnliftIO.MessageBox.Class as Class
import UnliftIO
  ( MonadIO (liftIO),
    MonadUnliftIO,
    timeout,
  )
import UnliftIO.Concurrent (threadDelay)

-- | Message Limit
--
-- The message limit must be a reasonable small positive integer
-- that is also a power of two. This stems from the fact that
-- Unagi is used under the hood.
--
-- The limit is a lower bound.
data MessageLimit
  = MessageLimit_1
  | MessageLimit_2
  | MessageLimit_4
  | MessageLimit_8
  | MessageLimit_16
  | MessageLimit_32
  | MessageLimit_64
  | MessageLimit_128
  | MessageLimit_256
  | MessageLimit_512
  | MessageLimit_1024
  | MessageLimit_2048
  | MessageLimit_4096
  deriving stock
    (Eq, Ord, Show, Bounded, Enum)

-- | Convert a 'MessageLimit' to the
-- 'Int' representation.
{-# INLINE messageLimitToInt #-}
messageLimitToInt :: MessageLimit -> Int
messageLimitToInt =
  \case
    MessageLimit_1 -> 1
    MessageLimit_2 -> 2
    MessageLimit_4 -> 4
    MessageLimit_8 -> 8
    MessageLimit_16 -> 16
    MessageLimit_32 -> 32
    MessageLimit_64 -> 64
    MessageLimit_128 -> 128
    MessageLimit_256 -> 256
    MessageLimit_512 -> 512
    MessageLimit_1024 -> 1024
    MessageLimit_2048 -> 2048
    MessageLimit_4096 -> 4096

-- * 'Class.IsMessageBoxArg' instances

-- ** Blocking

-- | Contains the (vague) limit of messages that a 'BlockingBox'
-- can buffer, i.e. that 'deliver' can put into a 'BlockingInput'
-- of a 'BlockingBox'.
newtype BlockingBoxLimit = BlockingBoxLimit MessageLimit
  deriving stock (Eq, Ord)

instance Show BlockingBoxLimit where
  showsPrec _ (BlockingBoxLimit !l) =
    showString "Blocking" . showsPrec 9 (messageLimitToInt l)

-- | A message queue out of which messages can by 'receive'd.
--
-- This is the counter part of 'Input'. Can be used for reading
-- messages.
--
-- Messages can be received by 'receive' or 'tryReceive'.
data BlockingBox a
  = MkBlockingBox
      !(Unagi.InChan a)
      !(Unagi.OutChan a)

-- | A message queue into which messages can be enqued by,
--   e.g. 'tryToDeliver'.
--   Messages can be received from an 'BlockingBox`.
--
--   The 'Input' is the counter part of a 'BlockingBox'.
newtype BlockingInput a = MkBlockingInput (Unagi.InChan a)

instance Class.IsMessageBoxArg BlockingBoxLimit where
  type MessageBox BlockingBoxLimit = BlockingBox
  {-# INLINE newMessageBox #-}
  newMessageBox (BlockingBoxLimit !limit) = create limit
  getConfiguredMessageLimit (BlockingBoxLimit !limit) =
    Just (messageLimitToInt limit)

-- | A blocking instance that invokes 'receive'.
instance Class.IsMessageBox BlockingBox where
  type Input BlockingBox = BlockingInput

  {-# INLINE receive #-}
  receive !i = Just <$> receive i
  {-# INLINE tryReceive #-}
  tryReceive !i = tryReceive i
  {-# INLINE newInput #-}
  newInput !i = newInput i
  receiveAfter (MkBlockingBox _ !s) !rto =
    do
      (!promise, !blocker) <- liftIO (Unagi.tryReadChan s)
      liftIO (Unagi.tryRead promise)
        >>= maybe
          (timeout rto (liftIO blocker))
          (return . Just)

-- | A blocking instance that invokes 'deliver'.
instance Class.IsInput BlockingInput where
  {-# INLINE deliver #-}
  deliver !o !a = deliver o a $> True

--  ** A wrapper around 'BlockingBox' for Non-Blocking Input (NBI)

-- | A 'BlockingBoxLimit' wrapper for non-blocking 'Class.IsMessageBoxArg' instances.
newtype NonBlockingBoxLimit = NonBlockingBoxLimit MessageLimit
  deriving stock (Eq, Ord)

instance Show NonBlockingBoxLimit where
  showsPrec _ (NonBlockingBoxLimit !l) =
    showString "NonBlocking" . showsPrec 9 (messageLimitToInt l)

instance Class.IsMessageBoxArg NonBlockingBoxLimit where
  type MessageBox NonBlockingBoxLimit = NonBlockingBox
  {-# INLINE newMessageBox #-}
  newMessageBox (NonBlockingBoxLimit !l) =
    NonBlockingBox <$> Class.newMessageBox (BlockingBoxLimit l)
  getConfiguredMessageLimit (NonBlockingBoxLimit !limit) =
    Just (messageLimitToInt limit)

-- | A 'BlockingBox' wrapper for non-blocking 'Class.IsMessageBox' instances.
--
-- The difference to the 'BlockingBox' instance is that 'Class.deliver'
-- immediately returns if the message box limit is surpassed.
newtype NonBlockingBox a = NonBlockingBox (BlockingBox a)

instance Class.IsMessageBox NonBlockingBox where
  type Input NonBlockingBox = NonBlockingInput
  {-# INLINE receive #-}
  receive (NonBlockingBox !i) = Just <$> receive i
  {-# INLINE tryReceive #-}
  tryReceive (NonBlockingBox !i) = tryReceive i
  {-# INLINE receiveAfter #-}
  receiveAfter (NonBlockingBox !b) !rto =
    Class.receiveAfter b rto
  {-# INLINE newInput #-}
  newInput (NonBlockingBox !i) = NonBlockingInput <$> newInput i

-- | A wrapper around 'BlockingInput' with a non-blocking 'Class.IsInput' instance.
--
-- 'deliver' will enqueue the message or return 'False' immediately,
-- if the message box already contains more messages than
-- it's limit allows.
newtype NonBlockingInput a = NonBlockingInput (BlockingInput a)

instance Class.IsInput NonBlockingInput where
  {-# INLINE deliver #-}
  deliver (NonBlockingInput !o) !a = do
    !res <- tryToDeliver o a
    unless res (threadDelay 10)
    return res

--  ** 'BlockingBox' Wrapper with Timeout

-- | A 'Class.IsMessageBoxArg' instance wrapping the 'BlockingBox'
--  with independently configurable timeouts for 'receive' and 'deliver'.
data WaitingBoxLimit
  = WaitingBoxLimit
      !(Maybe Int)
      !Int
      !MessageLimit
  deriving stock (Eq, Ord)

instance Show WaitingBoxLimit where
  showsPrec _ (WaitingBoxLimit !t0 !t1 !l) =
    showString "Waiting_"
      . ( case t0 of
            Nothing -> id
            Just !t -> showsPrec 9 t . showChar '_'
        )
      . showsPrec 9 t1
      . showChar '_'
      . showsPrec 9 (messageLimitToInt l)

instance Class.IsMessageBoxArg WaitingBoxLimit where
  type MessageBox WaitingBoxLimit = WaitingBox
  {-# INLINE newMessageBox #-}
  newMessageBox l@(WaitingBoxLimit _ _ !c) =
    WaitingBox l <$> Class.newMessageBox (BlockingBoxLimit c)
  getConfiguredMessageLimit (WaitingBoxLimit _ _ !limit) =
    Just (messageLimitToInt limit)

-- | A 'BlockingBox' an a 'WaitingBoxLimit' for
-- the 'Class.IsMessageBox' instance.
data WaitingBox a
  = WaitingBox WaitingBoxLimit (BlockingBox a)

instance Class.IsMessageBox WaitingBox where
  type Input WaitingBox = WaitingInput
  {-# INLINE receive #-}
  receive (WaitingBox (WaitingBoxLimit (Just !rto) _ _) (MkBlockingBox _ !s)) =
    liftIO $ do
      (!promise, !blocker) <- Unagi.tryReadChan s
      Unagi.tryRead promise
        >>= maybe
          (timeout rto blocker)
          (return . Just)
  receive (WaitingBox !_ !m) =
    Class.receive m
  {-# INLINE receiveAfter #-}
  receiveAfter (WaitingBox _ !b) !rto =
    Class.receiveAfter b rto
  {-# INLINE tryReceive #-}
  tryReceive (WaitingBox _ !m) = tryReceive m
  {-# INLINE newInput #-}
  newInput (WaitingBox (WaitingBoxLimit _ !dto _) !m) =
    WaitingInput dto <$> newInput m

-- | An input for a 'BlockingBox' that will block
-- for not much more than the given timeout when
-- the message box is full.
data WaitingInput a
  = WaitingInput
      !Int
      !(BlockingInput a)

instance Class.IsInput WaitingInput where
  {-# INLINE deliver #-}
  deliver (WaitingInput !t !o) !a = tryToDeliverAndWait t o a

-- Internal Functions

{-# INLINE create #-}
create :: MonadUnliftIO m => MessageLimit -> m (BlockingBox a)
create !limit = do
  (!inChan, !outChan) <- liftIO (Unagi.newChan (messageLimitToInt limit))
  return $! MkBlockingBox inChan outChan

{-# INLINE receive #-}
receive :: MonadUnliftIO m => BlockingBox a -> m a
receive (MkBlockingBox _ !s) =
  liftIO (Unagi.readChan s)

-- | Return a 'Future' for the next value that will be received.
{-# INLINE tryReceive #-}
tryReceive :: MonadUnliftIO m => BlockingBox a -> m (Future a)
tryReceive (MkBlockingBox _ !s) = liftIO $ do
  (!promise, _) <- Unagi.tryReadChan s
  return (Future (Unagi.tryRead promise))

{-# INLINE newInput #-}
newInput :: MonadUnliftIO m => BlockingBox a -> m (BlockingInput a)
newInput (MkBlockingBox !s _) = return $! MkBlockingInput s

{-# INLINE deliver #-}
deliver :: MonadUnliftIO m => BlockingInput a -> a -> m ()
deliver (MkBlockingInput !s) !a =
  liftIO $ Unagi.writeChan s a

-- | Try to put a message into the 'BlockingInput'
-- of a 'MessageBox', such that the process
-- reading the 'MessageBox' receives the message.
--
-- If the 'MessageBox' is full return False.
{-# INLINE tryToDeliver #-}
tryToDeliver :: MonadUnliftIO m => BlockingInput a -> a -> m Bool
tryToDeliver (MkBlockingInput !s) !a =
  liftIO $ Unagi.tryWriteChan s a

-- | Send a message by putting it into the 'BlockingInput'
-- of a 'MessageBox', such that the process
-- reading the 'MessageBox' receives the message.
--
-- Return False if the
-- 'MessageBox' has been closed or is full.
--
-- This assumes that the queue is likely empty, and
-- tries 'tryToDeliver' first before wasting any
-- precious cpu cycles entering 'timeout'.
tryToDeliverAndWait ::
  MonadUnliftIO m =>
  Int ->
  BlockingInput a ->
  a ->
  m Bool
tryToDeliverAndWait !t !o !a =
  -- Benchmarks have shown great improvements
  -- when calling tryToDeliver once before doing
  -- deliver in a System.Timeout.timeout;
  --
  -- We even tried calling 'tryToDeliver' more than once,
  -- but that did not lead to convinving improvements.
  --
  -- Benachmarks have also shown, that sending pessimistically
  -- (i.e. avoiding `tryToDeliver`) does not improve performance,
  -- even when the message queue is congested
  --
  -- See benchmark results:
  -- `benchmark-results/optimistic-vs-pessimistic.html`
  tryToDeliver o a >>= \case
    True -> return True
    False ->
      fromMaybe False <$> timeout t (deliver o a $> True)