packages feed

keiro-0.6.0.0: src/Keiro/Inbox.hs

-- | Idempotent inbox for cross-bounded-context integration events.
--
-- The inbox lives in the consuming bounded context. When a Kafka consumer
-- receives an integration event, the inbox records the event's stable
-- external identity and runs the local handler in the same Postgres
-- transaction. Duplicate redeliveries (Kafka offset retry, rebalance,
-- producer republish) become observable as duplicates instead of
-- re-running the handler.
--
-- The wrapper is a single-transaction primitive: the completed inbox row
-- and the handler's local writes commit atomically. If the handler raises
-- or condemns the transaction, the inbox row never appears and the next
-- delivery starts fresh.
--
-- Completed-row retention defines the deduplication window. After
-- 'garbageCollectCompleted' removes a row, a later delivery of the same key is
-- processed again. A concurrent GC can also delete a conflicting completed row
-- between the insert attempt and its lookup; the handler then commits without a
-- replacement deduplication row, so a later redelivery can run it again. These
-- cases preserve at-least-once delivery, not permanent exactly-once processing;
-- size retention beyond the maximum redelivery delay and keep handlers
-- idempotent.
module Keiro.Inbox
  ( -- * Re-exports
    module Keiro.Inbox.Types,

    -- * Storage primitives
    lookupInbox,
    listInbox,
    garbageCollectCompleted,
    countInboxBacklog,
    markFailedTx,

    -- * Transactional handler wrapper
    runInboxTransaction,
    runInboxTransactionWith,
    runInboxTransactionWithKey,
    runInboxTransactionWithRetries,
    runInboxTransactionWithRetriesWith,
    runInboxTransactionWithRetriesKey,
    runInboxTransactionBatch,
    sampleInboxBacklog,
  )
where

import Data.Map.Strict qualified as Map
import Data.Maybe (listToMaybe, mapMaybe)
import Data.Text qualified as Text
import Effectful (Eff, IOE, (:>))
import Effectful.Exception (displayException, trySync)
import Keiro.Inbox.Schema
import Keiro.Inbox.Types
import Keiro.Integration.Event (IntegrationEvent)
import Keiro.Prelude
import Keiro.Telemetry
  ( KeiroMetrics,
    recordInboxBacklog,
    recordInboxDuplicates,
    recordInboxFailed,
    recordInboxPoisoned,
    recordInboxProcessed,
  )
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import "hasql-transaction" Hasql.Transaction qualified as Tx

data BatchPlan
  = BatchKeyError !InboxError
  | BatchDuplicate
  | BatchWork !Text !Text !IntegrationEvent !(Maybe KafkaDeliveryRef)

-- | Run @handler@ at most once for each @(source, dedupe_key)@.
--
-- Computes the dedupe key from @policy@ and @kafka@, then in one
-- transaction:
--
-- * Inserts the inbox row with status @completed@.
-- * If the row already exists, branches on its status: 'InboxCompleted'
--   → 'InboxDuplicate'; 'InboxProcessing' → 'InboxInProgress';
--   'InboxFailed' → 'InboxPreviouslyFailed'.
-- * Otherwise runs @handler@; the row commits only if the handler succeeds.
--
-- The handler is invoked with the decoded 'IntegrationEvent' so it does
-- not need to redecode bytes. On exception or 'Tx.condemn' the whole
-- transaction rolls back, including the inbox row insert — the next
-- delivery sees no row and can retry.
runInboxTransaction ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  InboxDedupePolicy ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (Either InboxError (InboxResult a))
runInboxTransaction mMetrics policy event kafka handler =
  runInboxTransactionWith mMetrics PersistFullEnvelope policy event kafka handler

-- | Variant of 'runInboxTransaction' that controls success-path
-- envelope persistence.
--
-- 'PersistDedupeOnly' keeps enough columns for dedupe and operator
-- correlation but stores an empty payload and omits schema, trace, and
-- attribute columns for successfully processed rows.
runInboxTransactionWith ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  InboxPersistence ->
  InboxDedupePolicy ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (Either InboxError (InboxResult a))
runInboxTransactionWith mMetrics persistence policy event kafka handler =
  case dedupeKeyFor policy event kafka of
    Left err -> pure (Left err)
    Right dedupe ->
      Right
        <$> runInboxTransactionWithKeyPersist
          mMetrics
          persistence
          (event ^. #source)
          dedupe
          event
          kafka
          handler

-- | Lower-level variant that takes the dedupe key directly.
--
-- Use when the policy is not enough to express the identity scheme — for
-- example, when the consumer joins fields from multiple headers or
-- derives the key from the payload itself.
runInboxTransactionWithKey ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (InboxResult a)
runInboxTransactionWithKey mMetrics src dedupe event kafka handler =
  runInboxTransactionWithKeyPersist mMetrics PersistFullEnvelope src dedupe event kafka handler

runInboxTransactionWithKeyPersist ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  InboxPersistence ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (InboxResult a)
runInboxTransactionWithKeyPersist mMetrics persistence src dedupe event kafka handler = do
  now <- liftIO getCurrentTime
  result <-
    runTransaction $
      attemptOneTx persistence Nothing src dedupe event kafka now handler
  -- Record the classification counter outside the handler transaction.
  -- Backlog gauge sampling is intentionally scheduled separately via
  -- 'sampleInboxBacklog'.
  recordInboxResult mMetrics Nothing result
  pure result

-- | Run @handler@ with opt-in poison-message accounting.
--
-- This wrapper behaves like 'runInboxTransaction' for fresh messages,
-- duplicates, and in-flight rows, but changes the behavior for handler
-- exceptions and previously failed rows:
--
-- * A synchronous exception from @handler@ rolls back the handler
--   transaction, then records a failed attempt in a second transaction and
--   returns 'InboxHandlerFailed' with the new attempt count.
-- * A previously failed row with @attempt_count < ceiling@ is retried.
-- * A previously failed row with @attempt_count >= ceiling@ returns
--   'InboxPreviouslyFailed' without running the handler. The consumer can
--   commit its offset and move on; the failed inbox row is the dead-letter
--   record for operator review.
--
-- 'Tx.condemn' is not treated as a handler failure by this wrapper. It
-- keeps the original rollback semantics from 'runInboxTransaction'.
runInboxTransactionWithRetries ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  Int ->
  InboxDedupePolicy ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (Either InboxError (InboxResult a))
runInboxTransactionWithRetries mMetrics attemptCeiling policy event kafka handler =
  runInboxTransactionWithRetriesWith mMetrics attemptCeiling PersistFullEnvelope policy event kafka handler

-- | Variant of 'runInboxTransactionWithRetries' that controls success-path persistence.
runInboxTransactionWithRetriesWith ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  Int ->
  InboxPersistence ->
  InboxDedupePolicy ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (Either InboxError (InboxResult a))
runInboxTransactionWithRetriesWith mMetrics attemptCeiling persistence policy event kafka handler =
  case dedupeKeyFor policy event kafka of
    Left err -> pure (Left err)
    Right dedupe ->
      Right
        <$> runInboxTransactionWithRetriesKeyPersist
          mMetrics
          attemptCeiling
          persistence
          (event ^. #source)
          dedupe
          event
          kafka
          handler

-- | Lower-level retrying variant that takes the dedupe key directly.
runInboxTransactionWithRetriesKey ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  Int ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (InboxResult a)
runInboxTransactionWithRetriesKey mMetrics attemptCeiling src dedupe event kafka handler =
  runInboxTransactionWithRetriesKeyPersist mMetrics attemptCeiling PersistFullEnvelope src dedupe event kafka handler

runInboxTransactionWithRetriesKeyPersist ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  Int ->
  InboxPersistence ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es (InboxResult a)
runInboxTransactionWithRetriesKeyPersist mMetrics attemptCeiling persistence src dedupe event kafka handler = do
  now <- liftIO getCurrentTime
  attempted <-
    trySync $
      runTransaction $
        attemptOneTx persistence (Just attemptCeiling) src dedupe event kafka now handler
  result <- case attempted of
    Right ok -> pure ok
    Left err -> do
      failedAt <- liftIO getCurrentTime
      let errMsg = Text.pack (displayException err)
      attempts <-
        runTransaction $
          recordFailedAttemptTx src dedupe event kafka errMsg failedAt
      pure (InboxHandlerFailed errMsg attempts)
  recordInboxResult mMetrics (Just attemptCeiling) result
  pure result

-- | Process a batch of inbox deliveries with a single transactional fast path.
--
-- The fast path computes each @(source, dedupe_key)@, suppresses repeated
-- keys within the batch as duplicates, then runs all remaining deliveries
-- in one Postgres transaction. If any handler throws or condemns that
-- transaction, the whole batch rolls back and every original delivery is
-- retried through 'runInboxTransactionWithRetries'. That fallback preserves
-- per-message failure accounting and prevents one poison message from
-- discarding unrelated batch mates.
--
-- 'Tx.condemn' rolls the transaction back at commit but returns normally,
-- so it cannot be observed from the transaction's return value alone.
-- The batch detects it by re-reading one row it should have committed:
-- every write in the fast path belongs to a delivery classified
-- 'InboxProcessed' (fresh insert as @completed@ or retry promotion to
-- @completed@), so if the first such row is not @completed@ after the
-- transaction returns, the whole batch was condemned and the per-message
-- fallback runs. A batch with no 'InboxProcessed' rows performed no writes,
-- so a condemned transaction loses nothing.
runInboxTransactionBatch ::
  forall a es.
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  Int ->
  InboxDedupePolicy ->
  InboxPersistence ->
  [(IntegrationEvent, Maybe KafkaDeliveryRef)] ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Eff es [Either InboxError (InboxResult a)]
runInboxTransactionBatch mMetrics attemptCeiling policy persistence deliveries handler = do
  now <- liftIO getCurrentTime
  let plan = planInboxBatch policy deliveries
  attempted <-
    trySync $
      runTransaction $
        traverse
          ( \case
              BatchKeyError err -> pure (Left err)
              BatchDuplicate -> pure (Right InboxDuplicate)
              BatchWork src dedupe event kafka ->
                Right <$> attemptOneTx persistence (Just attemptCeiling) src dedupe event kafka now handler
          )
          plan
  case attempted of
    Right results -> do
      committed <- verifyBatchCommitted plan results
      if committed
        then do
          for_ results $ \case
            Right result -> recordInboxResult mMetrics (Just attemptCeiling) result
            Left _ -> pure ()
          pure results
        else perMessageFallback
    Left _ -> perMessageFallback
  where
    perMessageFallback :: Eff es [Either InboxError (InboxResult a)]
    perMessageFallback =
      traverse
        ( \(event, kafka) ->
            runInboxTransactionWithRetriesWith mMetrics attemptCeiling persistence policy event kafka handler
        )
        deliveries

    -- A condemned transaction returns its results normally but commits
    -- nothing. Re-read the first row the batch claims to have completed;
    -- if it is not @completed@, the transaction rolled back at commit.
    verifyBatchCommitted ::
      [BatchPlan] ->
      [Either InboxError (InboxResult a)] ->
      Eff es Bool
    verifyBatchCommitted plan results =
      case listToMaybe (mapMaybe processedKey (zip plan results)) of
        Nothing -> pure True
        Just (src, dedupe) -> do
          row <- lookupInbox src dedupe
          pure (fmap (^. #status) row == Just InboxCompleted)

    processedKey :: (BatchPlan, Either InboxError (InboxResult a)) -> Maybe (Text, Text)
    processedKey = \case
      (BatchWork src dedupe _ _, Right (InboxProcessed _)) -> Just (src, dedupe)
      _ -> Nothing

attemptOneTx ::
  InboxPersistence ->
  Maybe Int ->
  Text ->
  Text ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  UTCTime ->
  (IntegrationEvent -> Tx.Transaction a) ->
  Tx.Transaction (InboxResult a)
attemptOneTx persistence attemptCeiling src dedupe event kafka now handler = do
  inserted <- tryInsertCompletedTx persistence src dedupe event kafka now
  case inserted of
    Right () -> do
      handled <- handler event
      pure (InboxProcessed handled)
    Left row -> case row ^. #status of
      InboxCompleted -> pure InboxDuplicate
      InboxProcessing -> pure InboxInProgress
      InboxFailed -> case attemptCeiling of
        Nothing -> pure (InboxPreviouslyFailed (row ^. #lastError))
        Just attemptLimit
          | row ^. #attemptCount >= attemptLimit ->
              pure (InboxPreviouslyFailed (row ^. #lastError))
          | otherwise -> do
              handled <- handler event
              markCompletedTx src dedupe now
              pure (InboxProcessed handled)

recordInboxResult :: (IOE :> es) => Maybe KeiroMetrics -> Maybe Int -> InboxResult a -> Eff es ()
recordInboxResult mMetrics attemptCeiling = \case
  InboxProcessed _ -> recordInboxProcessed mMetrics 1
  InboxDuplicate -> recordInboxDuplicates mMetrics 1
  InboxPreviouslyFailed _ -> recordInboxFailed mMetrics 1
  InboxHandlerFailed _ attempts -> do
    recordInboxFailed mMetrics 1
    case attemptCeiling of
      Just attemptLimit | attempts >= attemptLimit -> recordInboxPoisoned mMetrics 1
      _ -> pure ()
  InboxInProgress -> pure ()

planInboxBatch ::
  InboxDedupePolicy ->
  [(IntegrationEvent, Maybe KafkaDeliveryRef)] ->
  [BatchPlan]
planInboxBatch policy = go Map.empty
  where
    go _ [] = []
    go seen ((event, kafka) : rest) =
      case dedupeKeyFor policy event kafka of
        Left err -> BatchKeyError err : go seen rest
        Right dedupe ->
          let key = (event ^. #source, dedupe)
           in if Map.member key seen
                then BatchDuplicate : go seen rest
                else BatchWork (event ^. #source) dedupe event kafka : go (Map.insert key () seen) rest

-- | Count the inbox backlog and record the gauge when metrics are enabled.
--
-- The backlog is non-terminal rows: legacy @processing@ rows plus failed rows.
-- Schedule this on its own interval; it is intentionally not part of the
-- per-message intake path.
sampleInboxBacklog :: (IOE :> es, Store :> es) => Maybe KeiroMetrics -> Eff es ()
sampleInboxBacklog Nothing = pure ()
sampleInboxBacklog (Just metrics) = do
  backlog <- countInboxBacklog
  recordInboxBacklog (Just metrics) (fromIntegral backlog)