packages feed

eventium-core-0.2.1: src/Eventium/EventSubscription.hs

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Defines an event subscription abstraction for push-based event delivery.
--
-- An 'EventSubscription' cleanly separates the delivery mechanism from the
-- consumer (which is just an 'EventHandler').
--
-- This design makes it easy to swap delivery mechanisms (polling, RabbitMQ,
-- Kafka, etc.) without changing the consumer code.
module Eventium.EventSubscription
  ( EventSubscription (..),
    CheckpointStore (..),
    PollingIntervalMillis,
    pollingSubscription,
    runProjectionSubscription,
    RetryConfig (..),
    defaultRetryConfig,
    resilientPollingSubscription,
    resilientRunProjectionSubscription,
  )
where

import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, throwIO, try)
import Control.Monad (forever)
import Control.Monad.IO.Class (MonadIO, liftIO)
import qualified Data.List.NonEmpty as NE
import Eventium.EventHandler
import Eventium.Projection
import Eventium.Store.Class

-- | Polling interval in milliseconds.
type PollingIntervalMillis = Int

-- | A pair of actions for reading and writing checkpoint state.
--
-- Groups the get\/set operations for the state a subscription needs to
-- persist between polls (e.g. a sequence number, or a sequence number
-- paired with projection state).
data CheckpointStore m a = CheckpointStore
  { getCheckpoint :: m a,
    saveCheckpoint :: a -> m ()
  }

-- | An 'EventSubscription' delivers events to an 'EventHandler' using
-- some delivery mechanism (polling, message queue, etc.).
--
-- Call 'runSubscription' with an 'EventHandler' to start consuming events.
-- The subscription runs indefinitely (typically in a separate thread).
newtype EventSubscription m event = EventSubscription
  { runSubscription :: EventHandler m event -> m ()
  }

-- | A single poll cycle: read checkpoint, fetch new events, handle them,
-- update checkpoint, delay.
pollOnce ::
  (MonadIO m) =>
  GlobalEventStoreReader m event ->
  CheckpointStore m SequenceNumber ->
  PollingIntervalMillis ->
  EventHandler m (GlobalStreamEvent event) ->
  m ()
pollOnce globalReader checkpoint pollIntervalMs handler = do
  latestSeq <- checkpoint.getCheckpoint
  newEvents <- globalReader.getEvents (eventsStartingAt () $ latestSeq + 1)
  handleEvents handler newEvents
  case NE.nonEmpty newEvents of
    Nothing -> return ()
    Just ne -> checkpoint.saveCheckpoint (NE.last ne).position
  delayMillis pollIntervalMs

-- | Create a polling-based 'EventSubscription' that reads from the global
-- event stream at a regular interval.
--
-- The caller is responsible for lifting the store reader into the target
-- monad (e.g. via 'runEventStoreReaderUsing') before passing it in.
--
-- Parameters:
--
-- * @globalReader@ — the global event store reader (already in monad @m@)
-- * @checkpoint@ — checkpoint store for tracking the consumed position
-- * @pollIntervalMs@ — milliseconds to wait between polls
pollingSubscription ::
  (MonadIO m) =>
  GlobalEventStoreReader m event ->
  CheckpointStore m SequenceNumber ->
  PollingIntervalMillis ->
  EventSubscription m (GlobalStreamEvent event)
pollingSubscription globalReader checkpoint pollIntervalMs =
  EventSubscription $ \handler ->
    forever $
      pollOnce globalReader checkpoint pollIntervalMs handler

projectionPollOnce ::
  (MonadIO m) =>
  GlobalEventStoreReader m event ->
  Projection state (VersionedStreamEvent event) ->
  CheckpointStore m (SequenceNumber, state) ->
  PollingIntervalMillis ->
  m ()
projectionPollOnce globalReader proj checkpoint pollIntervalMs = do
  (latestSeq, currentState) <- checkpoint.getCheckpoint
  newEvents <- globalReader.getEvents (eventsStartingAt () $ latestSeq + 1)
  let (finalSeq, finalState) = foldl' applyGlobalEvent (latestSeq, currentState) newEvents
  checkpoint.saveCheckpoint (finalSeq, finalState)
  delayMillis pollIntervalMs
  where
    applyGlobalEvent (_, st) (StreamEvent _ newSeq _ innerEvent) =
      let newState = proj.eventHandler st innerEvent
       in (newSeq, newState)

-- | Convenience function to run a polling loop that maintains a
-- 'GlobalStreamProjection'. The projection state is updated with each
-- batch of events and the latest sequence number is tracked.
--
-- The 'CheckpointStore' allows the caller to store the
-- @(SequenceNumber, state)@ pair wherever they choose (e.g. TVar, IORef,
-- database).
runProjectionSubscription ::
  (MonadIO m) =>
  GlobalEventStoreReader m event ->
  Projection state (VersionedStreamEvent event) ->
  CheckpointStore m (SequenceNumber, state) ->
  PollingIntervalMillis ->
  m ()
runProjectionSubscription globalReader proj checkpoint pollIntervalMs =
  forever $ projectionPollOnce globalReader proj checkpoint pollIntervalMs

-- | Configuration for retry behavior when a polling subscription
-- encounters an exception.
data RetryConfig = RetryConfig
  { -- | Initial delay before the first retry, in milliseconds.
    initialDelayMs :: !Int,
    -- | Maximum backoff delay in milliseconds (caps exponential growth).
    maxDelayMs :: !Int,
    -- | Multiplier for exponential backoff on consecutive failures.
    backoffMultiplier :: !Double,
    -- | Predicate called on each exception. Return 'True' to retry
    -- (after backoff), 'False' to re-throw and kill the subscription.
    onError :: SomeException -> IO Bool,
    -- | Callback invoked on each caught exception. Receives the exception
    -- and the consecutive error count (1 on first failure). Use for logging.
    onErrorCallback :: SomeException -> Int -> IO ()
  }

-- | Sensible defaults: 1 second initial delay, 30 second cap, 2x backoff,
-- always retry, no-op callback.
defaultRetryConfig :: RetryConfig
defaultRetryConfig =
  RetryConfig
    { initialDelayMs = 1000,
      maxDelayMs = 30000,
      backoffMultiplier = 2.0,
      onError = const (return True),
      onErrorCallback = \_ _ -> return ()
    }

-- | Like 'pollingSubscription' but with automatic error recovery.
--
-- When an exception occurs during a poll cycle, the exception is caught
-- and the subscription retries after an exponentially increasing backoff
-- delay. On a successful poll, the backoff resets to zero.
--
-- The @unlift@ parameter converts @m@ actions to @IO@, needed to catch
-- exceptions. This follows the same pattern as 'runEventStoreReaderUsing'.
--
-- @
-- -- With m ~ IO:
-- resilientPollingSubscription id globalReader checkpoint 500 defaultRetryConfig
--
-- -- With m ~ SqlPersistT IO:
-- resilientPollingSubscription (flip runSqlPool pool) globalReader checkpoint 500 defaultRetryConfig
-- @
resilientPollingSubscription ::
  (MonadIO m) =>
  (forall a. m a -> IO a) ->
  GlobalEventStoreReader m event ->
  CheckpointStore m SequenceNumber ->
  PollingIntervalMillis ->
  RetryConfig ->
  EventSubscription m (GlobalStreamEvent event)
resilientPollingSubscription unlift globalReader checkpoint pollIntervalMs retryCfg =
  EventSubscription $ \handler -> loop handler 0
  where
    loop handler consecutiveErrors = do
      result <- liftIO $ try $ unlift $ pollOnce globalReader checkpoint pollIntervalMs handler
      case result of
        Right () -> loop handler 0
        Left (ex :: SomeException) -> do
          shouldRetry <- liftIO $ retryCfg.onError ex
          if shouldRetry
            then do
              let newCount = consecutiveErrors + 1
              liftIO $ retryCfg.onErrorCallback ex newCount
              liftIO $ threadDelay (backoffMicros retryCfg newCount)
              loop handler newCount
            else liftIO $ throwIO ex

-- | Like 'runProjectionSubscription' but with automatic error recovery.
-- See 'resilientPollingSubscription' for details on the retry behavior.
resilientRunProjectionSubscription ::
  (MonadIO m) =>
  (forall a. m a -> IO a) ->
  GlobalEventStoreReader m event ->
  Projection state (VersionedStreamEvent event) ->
  CheckpointStore m (SequenceNumber, state) ->
  PollingIntervalMillis ->
  RetryConfig ->
  m ()
resilientRunProjectionSubscription unlift globalReader proj checkpoint pollIntervalMs retryCfg =
  loop 0
  where
    loop consecutiveErrors = do
      result <- liftIO $ try $ unlift $ projectionPollOnce globalReader proj checkpoint pollIntervalMs
      case result of
        Right () -> loop 0
        Left (ex :: SomeException) -> do
          shouldRetry <- liftIO $ retryCfg.onError ex
          if shouldRetry
            then do
              let newCount = consecutiveErrors + 1
              liftIO $ retryCfg.onErrorCallback ex newCount
              liftIO $ threadDelay (backoffMicros retryCfg newCount)
              loop newCount
            else liftIO $ throwIO ex

backoffMicros :: RetryConfig -> Int -> Int
backoffMicros retryCfg count =
  let delayMs =
        min retryCfg.maxDelayMs $
          round (fromIntegral retryCfg.initialDelayMs * retryCfg.backoffMultiplier ^^ max 0 (count - 1))
   in delayMs * 1000

-- | Sleep for the given number of milliseconds.
delayMillis :: (MonadIO m) => PollingIntervalMillis -> m ()
delayMillis ms = liftIO $ threadDelay (ms * 1000)