packages feed

natskell-1.1.0.0: internal/Policy/Engine.hs

module Engine
  ( closeClient
  , interruptConnection
  , resetClient
  , runEngine
  ) where

import           Auth.Types
import           Control.Concurrent
    ( MVar
    , ThreadId
    , forkIOWithUnmask
    , killThread
    , myThreadId
    , newMVar
    , withMVar
    )
import           Control.Concurrent.STM
import           Control.Exception
    ( SomeException
    , finally
    , mask
    , mask_
    , onException
    )
import           Control.Monad             (unless, void, when)
import           Data.Foldable             (for_)
import           Data.Maybe                (listToMaybe)
import           GHC.Clock                 (getMonotonicTimeNSec)
import           Handshake.Nats
    ( HandshakeError (..)
    , performHandshake
    )
import           Lib.Exception             (trySync)
import           Lib.Logger                (LogLevel (..), MonadLogger (..))
import           Network.ConnectionAPI
    ( Conn
    , ConnectionAPI
    , WriterAPI (..)
    , abort
    , close
    , closeReader
    , connectTcp
    , open
    , reader
    , writer
    )
import           Parser.API
    ( ParsedMessage (ParsedPing)
    , ParserAPI
    )
import           Pipeline.Broadcasting.API (BroadcastingAPI (BroadcastingAPI))
import           Pipeline.Streaming.API    (StreamingAPI (StreamingAPI))
import           Queue.API                 (QueueItem (QueueBatch, QueueItem))
import           Router.Nats               (RouteDirective (..), routeMessage)
import           State.Store
    ( ClientState
    , activateConnectionAttempt
    , beginConnectionAttempt
    , closeQueue
    , config
    , connectedOnce
    , connection
    , failInitialConnection
    , incrementAttemptIndex
    , isManagedThread
    , markClosed
    , markConnectionReadyWithRejectedPublishes
    , queue
    , readAttemptIndex
    , readStatus
    , registerManagedThread
    , runClient
    , setClosing
    , setEndpoint
    , setReconnecting
    , unregisterManagedThread
    , waitForClosed
    , waitForConnectionGenerationAfter
    , withSubscriptionGate
    )
import           State.Types
    ( ClientConfig (..)
    , ClientExitReason (..)
    , ConnectAttemptError (..)
    , ConnectError (..)
    , ConnectFailure (..)
    , ConnectionState (..)
    , serverErrorKind
    )
import           Subscription.Store
    ( SubscriptionStore
    , active
    , closeStore
    )
import           Subscription.Types
    ( SubscriptionMeta (SubscriptionMeta)
    , isOneShotSubscription
    , isResumableSubscription
    )
import           System.Timeout            (timeout)
import           Transformers.Transformers (Transformer (transform))
import           Types.Pong                (Pong (Pong))
import qualified Types.Sub                 as Sub
import qualified Types.Unsub               as Unsub

data ConnectionRunResult = ConnectionDisconnected
                         | ConnectionExit ClientExitReason

data ReaderRuntime = ReaderRuntime
                       { runtimeReaderThread :: ThreadId
                       , runtimeReaderDone   :: TMVar ()
                       , runtimeExit         :: TMVar ClientExitReason
                       }

runEngine
  :: ConnectionAPI
  -> StreamingAPI
  -> BroadcastingAPI
  -> ParserAPI ParsedMessage
  -> ClientState
  -> SubscriptionStore
  -> Auth
  -> IO ()
runEngine connectionApi streamingApi broadcastingApi parserApi state store auth =
  mask $ \restore -> do
    result <- trySync (restore engine `finally` cleanupEngineResources)
    case result of
      Left err -> handleEngineFailure err
      Right () -> pure ()
  where
    StreamingAPI runStreaming = streamingApi
    BroadcastingAPI runBroadcasting = broadcastingApi

    engine =
      case connectOptions (config state) of
        [] -> do
          failInitialConnection state ConnectNoServers
          finalize (ExitRetriesExhausted (Just "No servers provided"))
        _ ->
          loop (connectionAttempts (config state)) []

    handleEngineFailure :: SomeException -> IO ()
    handleEngineFailure _ = mask_ $ do
      let reason = ExitRetriesExhausted (Just "client engine failed")
      failInitialConnection state (ConnectAttemptsExhausted [])
      setClosing state reason
      finalize reason

    cleanupEngineResources = mask_ $ do
      let ignoreSync action = void (trySync action)
          conn = connection state
      ignoreSync (closeQueue state)
      ignoreSync (closeReader (reader connectionApi) conn)
      ignoreSync (closeWriter (writer connectionApi) conn)
      ignoreSync (abort connectionApi conn)
      ignoreSync (close connectionApi conn)

    loop remaining attemptErrors
      | remaining <= 0 = do
          runClient state $
            logMessage Info "retries exhausted; exiting"
          let orderedErrors = reverse attemptErrors
              connectError = ConnectAttemptsExhausted orderedErrors
              lastErr = show <$> listToMaybe orderedErrors
          failInitialConnection state connectError
          finalize (ExitRetriesExhausted lastErr)
      | otherwise = do
          status <- readStatus state
          case status of
            ConnectionConnecting -> continueLoop remaining attemptErrors
            ConnectionConnected -> continueLoop remaining attemptErrors
            ConnectionReconnecting -> continueLoop remaining attemptErrors
            ConnectionClosing reason ->
              finalize reason
            ConnectionClosed _ ->
              pure ()
      where
        continueLoop remaining' attemptErrors' = do
          attemptErr <- runAttempt
          nextStatus <- readStatus state
          case nextStatus of
            ConnectionConnecting -> retryLoop attemptErr
            ConnectionConnected -> retryLoop attemptErr
            ConnectionReconnecting -> retryLoop attemptErr
            ConnectionClosing reason ->
              finalize reason
            ConnectionClosed _ ->
              pure ()
          where
            retryLoop attemptErr = do
              when (remaining' > 1) $ do
                runClient state $
                  logMessage Info "retrying client connection"
                incrementAttemptIndex state
              loop (remaining' - 1) (attemptErr : attemptErrors')

    runAttempt = do
      let cfg = config state
          endpoints = connectOptions cfg
      attemptIndex <- readAttemptIndex state
      let endpoint@(host, _port) =
            endpoints !! (attemptIndex `mod` length endpoints)
          attemptTimeout = max 1 (connectTimeoutMicros cfg)
      attempt <- beginConnectionAttempt state
      case attempt of
        Nothing ->
          pure (ConnectAttemptError endpoint (ConnectTransportFailure "connection attempt cancelled"))
        Just attemptEpoch -> do
          attemptStarted <- toInteger <$> getMonotonicTimeNSec
          setEndpoint state endpoint
          acquired <- timeout attemptTimeout (acquireTransport endpoint)
          case acquired of
            Nothing -> do
              runClient state $
                logMessage Error "transport acquisition timed out"
              terminateTransport (connection state)
              pure (ConnectAttemptError endpoint (ConnectTransportFailure "transport acquisition timed out"))
            Just (Left err) -> do
              terminateTransport (connection state)
              pure (ConnectAttemptError endpoint (ConnectTransportFailure err))
            Just (Right conn) -> do
              open connectionApi conn
              activated <- activateConnectionAttempt state attemptEpoch
              if not activated
                then do
                  terminateTransport conn
                  pure (ConnectAttemptError endpoint (ConnectTransportFailure "connection attempt cancelled"))
                else do
                  remaining <- remainingAttemptMicros attemptStarted attemptTimeout
                  initialized <-
                    if remaining <= 0
                      then pure Nothing
                      else timeout remaining $
                        performHandshake connectionApi parserApi state auth conn host
                  case initialized of
                    Nothing -> do
                      runClient state $
                        logMessage Error "connection handshake timed out"
                      terminateTransport conn
                      pure (ConnectAttemptError endpoint ConnectHandshakeTimeout)
                    Just (Left handshakeError) -> do
                      let failure = handshakeFailure handshakeError
                      runClient state $
                        logMessage Error ("connection initialization failed: " ++ failureCategory failure)
                      terminateTransport conn
                      pure (ConnectAttemptError endpoint failure)
                    Just (Right ()) ->
                      finishConnection
                        endpoint
                        conn
                        attemptEpoch
                        attemptStarted
                        attemptTimeout

    finishConnection endpoint conn attemptEpoch attemptStarted attemptTimeout = mask $ \restore -> do
      writeLock <- newMVar ()
      readerRuntime <- startConnectionReader conn writeLock
      remaining <- remainingAttemptMicros attemptStarted attemptTimeout
      readinessResult <-
        if remaining <= 0
          then pure Nothing
          else restore (timeout remaining (completeReadiness writeLock conn attemptEpoch))
            `onException` terminateReader conn readerRuntime
      case readinessResult of
        Nothing -> do
          runClient state $
            logMessage Error "connection readiness timed out"
          terminateReader conn readerRuntime
          terminateTransport conn
          pure
            ( ConnectAttemptError
                endpoint
                (ConnectTransportFailure "connection readiness timed out")
            )
        Just (Left err) -> do
          runClient state $
            logMessage Error
              ("resubscription failed: " ++ failureCategory (ConnectTransportFailure err))
          void (setReconnecting state)
          terminateReader conn readerRuntime
          terminateTransport conn
          pure (ConnectAttemptError endpoint (ConnectTransportFailure err))
        Just (Right False) -> do
          closeQueue state
          terminateReader conn readerRuntime
          terminateTransport conn
          pure (ConnectAttemptError endpoint (ConnectTransportFailure "connection attempt cancelled"))
        Just (Right True) -> do
          connectionResult <- restore (runConnection conn writeLock readerRuntime)
            `onException` terminateReader conn readerRuntime
          case connectionResult of
            ConnectionDisconnected -> void (setReconnecting state)
            ConnectionExit reason  -> setClosing state reason
          close connectionApi conn
          case connectionResult of
            ConnectionDisconnected -> do
              runClient state $
                logMessage Info "connection disconnected"
              pure (ConnectAttemptError endpoint (ConnectTransportFailure "connection disconnected"))
            ConnectionExit reason ->
              pure (ConnectAttemptError endpoint (ConnectProtocolFailure (exitCategory reason)))

    terminateTransport conn = do
      abort connectionApi conn
      close connectionApi conn

    remainingAttemptMicros started timeoutMicros = do
      now <- toInteger <$> getMonotonicTimeNSec
      let deadline = started + toInteger timeoutMicros * 1000
          remainingNanos = max 0 (deadline - now)
          roundedMicros = (remainingNanos + 999) `div` 1000
      pure (fromInteger (min (toInteger (maxBound :: Int)) roundedMicros))

    acquireTransport (host, port) = do
      let conn = connection state
      result <- connectTcp connectionApi conn host port
      case result of
        Left err -> pure (Left err)
        Right () -> pure (Right conn)

    handshakeFailure (HandshakeTransportError err) = ConnectTransportFailure err
    handshakeFailure (HandshakeTLSError err) = ConnectTLSFailure err
    handshakeFailure (HandshakeProtocolError err) = ConnectProtocolFailure err
    handshakeFailure (HandshakeAuthError err) = ConnectAuthenticationFailure (show err)

    failureCategory (ConnectTransportFailure _)      = "transport failure"
    failureCategory (ConnectTLSFailure _)            = "tls failure"
    failureCategory (ConnectProtocolFailure _)       = "protocol failure"
    failureCategory (ConnectAuthenticationFailure _) = "authentication failure"
    failureCategory ConnectHandshakeTimeout          = "handshake timeout"

    exitCategory ExitClosedByUser                  = "closed by user"
    exitCategory (ExitRetriesExhausted _)          = "retries exhausted"
    exitCategory (ExitServerError serverError)     = "server error: " ++ show (serverErrorKind serverError)
    exitCategory (ExitInboundMessageTooLarge _ _)  = "inbound message too large"
    exitCategory ExitResetRequested                = "reset requested"

    completeReadiness writeLock conn attemptEpoch =
      withSubscriptionGate state $
        (do
          result <- resubscribeIfNeeded writeLock conn
          case result of
            Left err -> do
              abort connectionApi conn
              pure (Left err)
            Right () -> do
              (accepted, rejectedPublishes) <-
                markConnectionReadyWithRejectedPublishes state attemptEpoch
              sequence_ rejectedPublishes
              unless accepted (abort connectionApi conn)
              pure (Right accepted))
        `onException` abort connectionApi conn

    resubscribeIfNeeded writeLock conn = do
      alreadyConnected <- connectedOnce state
      if alreadyConnected
        then do
          activeSubscriptions <- active store
          let resumable = filter isResumable activeSubscriptions
          result <- writeSubscriptions writeLock conn resumable
          let resumableCount = length resumable
          case result of
            Right () ->
              unless (resumableCount == 0) $
                runClient state
                  (logMessage Info ("resubscribed " ++ show resumableCount ++ " subscriptions"))
            Left _ -> pure ()
          pure result
        else pure (Right ())

    writeSubscriptions _ _ [] = pure (Right ())
    writeSubscriptions writeLock conn subscriptions = do
      let (chunk, remaining) = splitAt resubscribeChunkSize subscriptions
      result <- writeSerialized
        writeLock
        conn
        (transform (QueueBatch (map subscriptionCommand chunk)))
      case result of
        Left err -> pure (Left err)
        Right () -> writeSubscriptions writeLock conn remaining

    writeSerialized writeLock conn bytes =
      withMVar writeLock $ \_ ->
        writeDataLazy (writer connectionApi) conn bytes

    resubscribeChunkSize = 64

    subscriptionCommand (sid, meta@(SubscriptionMeta subject queueGroup _)) =
      QueueBatch $
        QueueItem
          Sub.Sub
            { Sub.subject = subject
            , Sub.queueGroup = queueGroup
            , Sub.sid = sid
            } :
          [ QueueItem
              Unsub.Unsub
                { Unsub.sid = sid
                , Unsub.maxMsg = Just 1
                }
          | isOneShotSubscription meta
          ]

    finalize reason = do
      closeStore store
      result <- markClosed state reason
      for_ result (exitAction (config state))

    startManaged :: IO () -> IO () -> IO ThreadId
    startManaged body cleanup =
      forkIOWithUnmask $ \unmask -> do
        threadId <- myThreadId
        registerManagedThread state threadId
        unmask body
          `finally` (cleanup `finally` unregisterManagedThread state threadId)

    startConnectionReader :: Conn -> MVar () -> IO ReaderRuntime
    startConnectionReader conn writeLock = do
      exitVar <- newEmptyTMVarIO
      readerDone <- newEmptyTMVarIO
      let stopReader = closeReader (reader connectionApi) conn
          stopWriter = closeWriter (writer connectionApi) conn
          stopQueue = closeQueue state
          signalReaderDone = atomically (void (tryPutTMVar readerDone ()))
          signalExit reason = atomically (void (tryPutTMVar exitVar reason))
          cleanup =
            ( do
                void (setReconnecting state)
                stopQueue
                stopWriter
                abort connectionApi conn
            ) `finally` signalReaderDone
          route message = do
            directive <- routeForConnection conn writeLock message
            case directive of
              RouteContinue -> pure ()
              RouteReconnect -> do
                signalExit ExitResetRequested
                stopQueue
                stopReader
                stopWriter
              RouteExit reason -> do
                setClosing state reason
                signalExit reason
                stopQueue
                stopReader
                stopWriter
      readerThread <- startManaged
        (runClient state $ do
          logMessage Debug "starting streaming thread"
          runStreaming
            (streamBufferLimit (messageLimit (config state)))
            (reader connectionApi)
            conn
            parserApi
            route
          logMessage Debug "streaming thread exited")
        cleanup
      pure
        ReaderRuntime
          { runtimeReaderThread = readerThread
          , runtimeReaderDone = readerDone
          , runtimeExit = exitVar
          }

    routeForConnection :: Conn -> MVar () -> ParsedMessage -> IO RouteDirective
    routeForConnection conn writeLock message@(ParsedPing _) = do
      status <- readStatus state
      case status of
        ConnectionConnected -> routeMessage state store message
        ConnectionClosing _  -> pure RouteReconnect
        ConnectionClosed _   -> pure RouteReconnect
        _ -> do
          runClient state (logMessage Debug "routing pre-ready PING")
          result <- writeSerialized writeLock conn (transform Pong)
          pure $ case result of
            Left _   -> RouteReconnect
            Right () -> RouteContinue
    routeForConnection _ _ message = routeMessage state store message

    terminateReader :: Conn -> ReaderRuntime -> IO ()
    terminateReader conn runtime = do
      closeReader (reader connectionApi) conn
      closeWriter (writer connectionApi) conn
      abort connectionApi conn
      killThread (runtimeReaderThread runtime)
      atomically (readTMVar (runtimeReaderDone runtime))

    runConnection :: Conn -> MVar () -> ReaderRuntime -> IO ConnectionRunResult
    runConnection conn writeLock readerRuntime = mask $ \restore -> do
      writerDone <- newEmptyTMVarIO

      let stopReader = closeReader (reader connectionApi) conn
          stopWriter = closeWriter (writer connectionApi) conn
          stopQueue = closeQueue state
          signalWriterDone = atomically (void (tryPutTMVar writerDone ()))

      writerThread <- startManaged
        (runClient state $ do
          logMessage Debug "starting broadcasting thread"
          runBroadcasting
            (queue state)
            (serializedWriter writeLock)
            conn
          logMessage Debug "broadcasting thread exited")
        ((stopQueue >> stopReader) `finally` signalWriterDone)

      let terminateChildren = do
            stopQueue
            stopReader
            stopWriter
            abort connectionApi conn
            killThread writerThread
            killThread (runtimeReaderThread readerRuntime)
            awaitChildren (runtimeReaderDone readerRuntime) writerDone

      outcome <- restore
        (atomically $
          (Left <$> readTMVar (runtimeExit readerRuntime))
            `orElse` (Right () <$ readTMVar (runtimeReaderDone readerRuntime))
            `orElse` (Right () <$ readTMVar writerDone))
        `onException` terminateChildren
      terminateChildren
      case outcome of
        Left ExitResetRequested -> pure ConnectionDisconnected
        Left reason             -> pure (ConnectionExit reason)
        Right ()                -> pure ConnectionDisconnected

      where
        awaitChildren readerDone writerDone = atomically $ do
          readTMVar readerDone
          readTMVar writerDone

    serializedWriter :: MVar () -> WriterAPI Conn
    serializedWriter writeLock =
      let baseWriter = writer connectionApi
      in WriterAPI
          { writeData = \conn bytes ->
              withMVar writeLock $ \_ -> writeData baseWriter conn bytes
          , writeDataLazy = \conn bytes ->
              withMVar writeLock $ \_ -> writeDataLazy baseWriter conn bytes
          , closeWriter = closeWriter baseWriter
          , openWriter = openWriter baseWriter
          }

    isResumable :: (a, SubscriptionMeta) -> Bool
    isResumable = isResumableSubscription . snd

    streamBufferLimit :: Int -> Int
    streamBufferLimit maximumMessageSize
      | maximumMessageSize > maxBound - controlLineAllowance = maxBound
      | otherwise = maximumMessageSize + controlLineAllowance

    controlLineAllowance = 64 * 1024

closeClient :: ConnectionAPI -> ClientState -> SubscriptionStore -> IO ()
closeClient connectionApi state store = mask_ $ do
  setClosing state ExitClosedByUser
  closeStore store
  closeQueue state
  closeReader (reader connectionApi) (connection state)
  closeWriter (writer connectionApi) (connection state)
  abort connectionApi (connection state)
  void . trySync . runClient state $
    logMessage Info "closing client connection"

resetClient :: ConnectionAPI -> ClientState -> SubscriptionStore -> IO ()
resetClient connectionApi state _store = mask $ \restore -> do
  invalidatedGeneration <- interruptConnection connectionApi state
  void . trySync . runClient state $
    logMessage Info "resetting client connection"
  current <- myThreadId
  managed <- isManagedThread state current
  unless managed . restore . atomically $
    waitForConnectionGenerationAfter state invalidatedGeneration
      `orElse` waitForClosed state

interruptConnection :: ConnectionAPI -> ClientState -> IO Int
interruptConnection connectionApi state = mask_ $ do
  invalidatedGeneration <- setReconnecting state
  closeReader (reader connectionApi) (connection state)
  closeWriter (writer connectionApi) (connection state)
  abort connectionApi (connection state)
  pure invalidatedGeneration