packages feed

unliftio-messagebox-1.0.0: src/UnliftIO/MessageBox/Command.hs

{-# LANGUAGE StrictData #-}

-- | Abstractions for the definition of
-- 'Command' 'Messages', that flow between
module UnliftIO.MessageBox.Command
  ( Message (..),
    Command,
    ReturnType (..),
    ReplyBox (),
    CommandError (..),
    DuplicateReply (..),
    cast,
    call,
    replyTo,
    callAsync,
    delegateCall,
    AsyncReply (),
    waitForReply,
    tryTakeReply,
  )
where

import Control.Applicative (Alternative ((<|>)))
import Control.Monad (unless)
import Control.Monad.Reader (MonadReader)
import Data.Kind (Type)
import UnliftIO.MessageBox.Util.CallId
  ( CallId (),
    HasCallIdCounter,
  )
import qualified UnliftIO.MessageBox.Util.CallId as CallId
import qualified UnliftIO.MessageBox.Class as MessageBox
import UnliftIO
  ( Exception,
    MonadUnliftIO,
    TMVar,
    Typeable,
    atomically,
    checkSTM,
    newEmptyTMVarIO,
    readTMVar,
    readTVar,
    registerDelay,
    takeTMVar,
    throwIO,
    tryPutTMVar,
    tryReadTMVar,
  )

-- | This family allows to encode imperative /commands/.
--
-- The clauses of a 'Command' define the commands that
-- a process should execute.
--
-- Every clause may specify an individual 'ReturnType' that
-- declares if and what response is valid for a message.
--
-- For example:
--
-- >
-- > type LampId = Int
-- >
-- > data instance Command LightControl r where
-- >   GetLamps :: Command LigthControl (Return [LampId])
-- >   SwitchOn :: LampId -> Command LigthControl FireAndForget
-- >
-- > data LightControl -- the phantom type
-- >
--
-- The type index of the Command family is the uninhabited
-- @LightControl@ type.
-- .
--
-- The second type parameter indicates if a message requires the
-- receiver to send a reply back to the blocked and waiting
-- sender, or if no reply is necessary.
data family Command apiTag :: ReturnType -> Type

-- | Indicates if a 'Command' requires the
-- receiver to send a reply or not.
data ReturnType where
  -- | Indicates that a 'Command' value is sent _one-way_.
  --
  -- Values of a 'Command' instance with 'FireAndForget' as second
  -- parameter indicate that the sender should not expect any direct
  -- answer from the recepient.
  FireAndForget :: ReturnType
  -- | Indicates that a 'Command' value requires the receiver
  -- to send a reply of the given type.
  --
  -- Values of a 'Command' instance with 'Return' as second parameter
  -- are received wrapped into a 'Blocking'.
  Return :: Type -> ReturnType

-- | A message valid for some user defined @apiTag@.
--
-- The @apiTag@ tag (phantom-) type defines the
-- messages allowed here, declared by the instance of
-- 'Command' for 'apiTag'.
data Message apiTag where
  -- | Wraps a 'Command' with a 'ReturnType' of 'Return' @result@.
  --
  -- Such a message can formed by using 'call'.
  --
  -- A 'Blocking' contains a 'ReplyBox' that can be
  -- used to send the reply to the other process
  -- blocking on 'call'
  Blocking ::
    Show (Command apiTag ( 'Return result)) =>
    Command apiTag ( 'Return result) ->
    ReplyBox result ->
    Message apiTag
  -- | If the 'Command' has a 'ReturnType' of 'FireAndForget'
  -- it has fire-and-forget semantics.
  --
  -- The smart constructor 'cast' can be used to
  -- this message.
  NonBlocking ::
    (Show (Command apiTag 'FireAndForget)) =>
    Command apiTag 'FireAndForget ->
    Message apiTag

instance Show (Message apiTag) where
  showsPrec d (NonBlocking !m) =
    showParen (d >= 9) (showString "NB: " . showsPrec 9 m)
  showsPrec d (Blocking !m (MkReplyBox _ !callId)) =
    showParen (d >= 9) (showString "B: " . showsPrec 9 m . showChar ' ' . shows callId)

-- | This is like 'Input', it can be used
-- by the receiver of a 'Blocking'
-- to either send a reply using 'reply'
-- or to fail/abort the request using 'sendRequestError'
data ReplyBox a
  = MkReplyBox
      !(TMVar (InternalReply a))
      !CallId

-- | This is the reply to a 'Blocking' sent through the 'ReplyBox'.
type InternalReply a = (Either CommandError a)

-- | The failures that the receiver of a 'Return' 'Command', i.e. a 'Blocking',
-- can communicate to the /caller/, in order to indicate that
-- processing a request did not or will not lead to the result the
-- caller is blocked waiting for.
data CommandError where
  -- | Failed to enqueue a 'Blocking' 'Command' 'Message' into the corresponding
  -- 'MessageBox.Input'
  CouldNotEnqueueCommand :: !CallId -> CommandError
  -- | The request has failed /for reasons/.
  BlockingCommandFailure :: !CallId -> CommandError
  -- | Timeout waiting for the result.
  BlockingCommandTimedOut :: !CallId -> CommandError
  deriving stock (Show, Eq)

-- | Enqueue a 'NonBlocking' 'Message' into an 'Input'.
-- This is just for symetry to 'call', this is
-- equivalent to: @\input -> MessageBox.tryToDeliver input . NonBlocking@
--
-- The
{-# INLINE cast #-}
cast ::
  ( MonadUnliftIO m,
    MessageBox.IsInput o,
    Show (Command apiTag 'FireAndForget)
  ) =>
  o (Message apiTag) ->
  Command apiTag 'FireAndForget ->
  m Bool
cast input !msg =
  MessageBox.deliver input (NonBlocking msg)

-- | Enqueue a 'Blocking' 'Message' into an 'MessageBox.IsInput' and wait for the
-- response.
--
-- If message 'deliver'y failed, return @Left 'CouldNotEnqueueCommand'@.
--
-- If no reply was given by the receiving process (using 'replyTo') within
-- a given duration, return @Left 'BlockingCommandTimedOut'@.
--
-- Important: The given timeout starts __after__ 'deliver' has returned,
-- if 'deliver' blocks and delays, 'call' might take longer than the
-- specified timeout.
--
-- The receiving process can either delegate the call using
-- 'delegateCall' or reply to the call by using: 'replyTo'.
call ::
  ( HasCallIdCounter env,
    MonadReader env m,
    MonadUnliftIO m,
    MessageBox.IsInput input,
    Show (Command apiTag ( 'Return result))
  ) =>
  input (Message apiTag) ->
  Command apiTag ( 'Return result) ->
  Int ->
  m (Either CommandError result)
call !input !pdu !timeoutMicroseconds = do
  !callId <- CallId.takeNext
  !resultVar <- newEmptyTMVarIO
  !sendSuccessful <- do
    let !rbox = MkReplyBox resultVar callId
    let !msg = Blocking pdu rbox
    MessageBox.deliver input msg
  if not sendSuccessful
    then return (Left (CouldNotEnqueueCommand callId))
    else do
      timedOutVar <- registerDelay timeoutMicroseconds
      atomically $
        takeTMVar resultVar
          <|> ( do
                  readTVar timedOutVar >>= checkSTM
                  return (Left (BlockingCommandTimedOut callId))
              )

-- | This is called from the callback contained in the 'Blocking' 'Message'.
--
-- When handling a 'Blocking' 'Message' the 'ReplyBox' contained
-- in the message contains the 'TMVar' for the result, and this
-- function puts the result into it.
{-# INLINE replyTo #-}
replyTo :: (MonadUnliftIO m) => ReplyBox a -> a -> m ()
replyTo (MkReplyBox !replyBox !callId) !message =
  atomically (tryPutTMVar replyBox (Right message))
    >>= \success -> unless success (throwIO (DuplicateReply callId))

-- | Exception thrown by 'replyTo' when 'replyTo' is call more than once.
newtype DuplicateReply = DuplicateReply CallId deriving stock (Eq)

instance Show DuplicateReply where
  showsPrec d (DuplicateReply !callId) =
    showParen (d >= 9) (showString "more than one reply sent for: " . shows callId)

instance Exception DuplicateReply

-- | Pass on the call to another process.
--
-- Used to implement dispatcher processes.
--
-- Returns 'True' if the 'MessageBox.deliver' operation was
-- successful.
{-# INLINE delegateCall #-}
delegateCall ::
  ( MonadUnliftIO m,
    MessageBox.IsInput o,
    Show (Command apiTag ( 'Return r))
  ) =>
  o (Message apiTag) ->
  Command apiTag ( 'Return r) ->
  ReplyBox r ->
  m Bool
delegateCall !o !c !r =
  MessageBox.deliver o (Blocking c r)

-- ** Non-Blocking call API

-- |  Enqueue a 'Blocking' 'Message' into an 'MessageBox.IsInput'.
--
-- If the call to 'deliver' fails, return @Nothing@ otherwise
-- @Just@ the 'AsyncReply'.
--
-- The receiving process must use 'replyTo'  with the 'ReplyBox'
-- received along side the 'Command' in the 'Blocking'.
callAsync ::
  ( HasCallIdCounter env,
    MonadReader env m,
    MonadUnliftIO m,
    MessageBox.IsInput o,
    Show (Command apiTag ( 'Return result))
  ) =>
  o (Message apiTag) ->
  Command apiTag ( 'Return result) ->
  m (Maybe (AsyncReply result))
callAsync !input !pdu = do
  !callId <- CallId.takeNext
  !resultVar <- newEmptyTMVarIO
  !sendSuccessful <- do
    let !rbox = MkReplyBox resultVar callId
    let !msg = Blocking pdu rbox
    MessageBox.deliver input msg
  if sendSuccessful
    then return (Just (MkAsyncReply callId resultVar))
    else return Nothing

-- | The result of 'callAsync'.
-- Use 'waitForReply' or 'tryTakeReply'.
data AsyncReply r
  = MkAsyncReply !CallId !(TMVar (InternalReply r))

instance (Typeable r) => Show (AsyncReply r) where
  showsPrec !d (MkAsyncReply !cId _) =
    showParen (d >= 9) (showString "AR: " . shows cId)

-- | Wait for the reply of a 'Blocking' 'Message'
-- sent by 'callAsync'.
{-# INLINE waitForReply #-}
waitForReply ::
  MonadUnliftIO m =>
  -- | The time in micro seconds to wait
  -- before returning 'Left' 'BlockingCommandTimedOut'
  Int ->
  AsyncReply result ->
  m (Either CommandError result)
waitForReply !t (MkAsyncReply !cId !rVar) = do
  !delay <- registerDelay t
  atomically
    ( ( do
          !hasTimedOut <- readTVar delay
          checkSTM hasTimedOut
          return (Left (BlockingCommandTimedOut cId))
      )
        <|> readTMVar rVar
    )

-- | If a reply for an 'callAsync' operation is available
-- return it, otherwise return 'Nothing'.
{-# INLINE tryTakeReply #-}
tryTakeReply ::
  MonadUnliftIO m =>
  AsyncReply result ->
  m (Maybe (Either CommandError result))
tryTakeReply (MkAsyncReply _expectedCallId !resultVar) = do
  !maybeTheResult <- atomically (tryReadTMVar resultVar)
  case maybeTheResult of
    Nothing ->
      return Nothing
    Just !result ->
      return (Just result)