packages feed

keiro-0.6.0.0: src/Keiro/Inbox/Types.hs

-- | Shared types for the idempotent integration-event inbox.
--
-- The inbox lives in the consuming bounded context. When a Kafka consumer
-- receives an integration event, the inbox records a stable external
-- identity for that message and runs the local handler in the same
-- transaction. Duplicate redeliveries (Kafka offset retry, rebalance,
-- producer republish) become observable as duplicates instead of
-- re-running the handler.
module Keiro.Inbox.Types
  ( InboxDedupePolicy (..),
    InboxPersistence (..),
    InboxStatus (..),
    InboxResult (..),
    InboxError (..),
    InboxRow (..),
    KafkaDeliveryRef (..),
    inboxStatusText,
    parseInboxStatus,
    dedupeKeyFor,
  )
where

import Data.Text qualified as Text
import Data.UUID qualified as UUID
import Keiro.Integration.Event (IntegrationEvent)
import Keiro.Prelude
import Kiroku.Store.Types (EventId (..), GlobalPosition (..))

-- | Which identity is used as the inbox primary key for an
-- 'IntegrationEvent'.
--
-- * 'PreferIntegrationMessageId' (default) — use the application-level
--   @messageId@ minted at the producer's outbox enqueue. EP-19 / EP-20
--   keep this id stable across publish retries, so it is the natural
--   primary dedupe key for Kafka-delivered events.
-- * 'PreferSourceEventIdentity' — use the @sourceEventId@ of the private
--   event that produced this integration event. Useful when a producer
--   may emit the same logical fact under different @messageId@s (e.g.
--   schema-upgrade republish), and the consumer wants those republishes
--   collapsed to a single handler run.
-- * 'KafkaDeliveryIdentity' — use the Kafka topic-partition-offset triple
--   as the dedupe key. Fallback only when neither @messageId@ nor source
--   identity is available. This identifies one broker delivery, not one
--   logical producer message: if a producer republishes the same logical
--   message, Kafka assigns a new offset and this policy will not collapse
--   the republish.
-- * 'CustomDedupeKey' — caller supplies the key. Use only when the other
--   policies cannot represent the identity scheme; the consuming service
--   owns key collision resistance.
data InboxDedupePolicy
  = PreferIntegrationMessageId
  | PreferSourceEventIdentity
  | KafkaDeliveryIdentity
  | CustomDedupeKey !Text
  deriving stock (Generic, Eq, Show)

-- | How much of the integration-event envelope the inbox persists on the
-- success path.
--
-- The failure path always persists the full envelope because a failed
-- inbox row is the operator's dead-letter record.
data InboxPersistence
  = PersistFullEnvelope
  | PersistDedupeOnly
  deriving stock (Generic, Eq, Show)

-- | Lifecycle state of an inbox row.
--
-- * 'InboxProcessing' — legacy on-disk state from older wrappers and
--   reserved for future async paths. Current single-transaction intake
--   inserts fresh successful rows directly as 'InboxCompleted'.
-- * 'InboxCompleted' — handler ran to completion; terminal.
-- * 'InboxFailed' — handler signaled a permanent failure; terminal. The
--   caller is responsible for operator action (dead-letter, manual
--   retry).
data InboxStatus
  = InboxProcessing
  | InboxCompleted
  | InboxFailed
  deriving stock (Generic, Eq, Show)

-- | The classified outcome of 'Keiro.Inbox.runInboxTransaction'.
--
-- * 'InboxProcessed a' — first delivery; handler ran and returned @a@.
-- * 'InboxDuplicate' — a previous delivery already completed; handler not
--   run.
-- * 'InboxInProgress' — a previous attempt is currently in-flight, or a
--   legacy @processing@ row was read. Current single-transaction intake
--   does not commit @processing@ rows. Treat as transient.
-- * 'InboxPreviouslyFailed' — a previous attempt recorded a permanent
--   failure. Operator should review before reprocessing.
data InboxResult a
  = InboxProcessed !a
  | InboxDuplicate
  | InboxInProgress
  | InboxPreviouslyFailed !(Maybe Text)
  | InboxHandlerFailed !Text !Int
  deriving stock (Generic, Eq, Show)

-- | Errors surfaced by the inbox wrapper that originate from the inbox
-- itself rather than from the supplied handler.
data InboxError
  = -- | The integration event lacked the field required by the chosen policy.
    DedupePolicyUnsatisfied !InboxDedupePolicy
  deriving stock (Generic, Eq, Show)

-- | Optional Kafka-delivery metadata recorded alongside an inbox row.
--
-- Used by 'KafkaDeliveryIdentity' to compute the dedupe key, and stored on
-- the row regardless of policy so operators can correlate the inbox
-- record with Kafka logs. Not part of the EP-19 envelope.
data KafkaDeliveryRef = KafkaDeliveryRef
  { topic :: !Text,
    partition :: !Int64,
    offset :: !Int64
  }
  deriving stock (Generic, Eq, Show)

-- | One row read back from @keiro_inbox@.
--
-- Rows written with 'PersistDedupeOnly' decode with an empty
-- 'IntegrationEvent' @payloadBytes@, no attributes, no trace context, and
-- no schema reference. Identity, routing, source-event ids, occurrence
-- time, and Kafka delivery metadata are still preserved.
data InboxRow = InboxRow
  { source :: !Text,
    dedupeKey :: !Text,
    event :: !IntegrationEvent,
    kafka :: !(Maybe KafkaDeliveryRef),
    status :: !InboxStatus,
    attemptCount :: !Int,
    receivedAt :: !UTCTime,
    completedAt :: !(Maybe UTCTime),
    failedAt :: !(Maybe UTCTime),
    lastError :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)

inboxStatusText :: InboxStatus -> Text
inboxStatusText = \case
  InboxProcessing -> "processing"
  InboxCompleted -> "completed"
  InboxFailed -> "failed"

parseInboxStatus :: Text -> Either Text InboxStatus
parseInboxStatus = \case
  "processing" -> Right InboxProcessing
  "completed" -> Right InboxCompleted
  "failed" -> Right InboxFailed
  other -> Left ("unknown keiro_inbox.status: " <> other)

-- | Compute the inbox dedupe key for an integration event under the
-- given policy plus optional Kafka delivery context. Returns 'Left' when
-- the policy demands a field the envelope does not carry (for example,
-- 'PreferSourceEventIdentity' on an envelope with no
-- @sourceEventId@).
dedupeKeyFor ::
  InboxDedupePolicy ->
  IntegrationEvent ->
  Maybe KafkaDeliveryRef ->
  Either InboxError Text
dedupeKeyFor policy event kafka = case policy of
  PreferIntegrationMessageId ->
    let mid = event ^. #messageId
     in if Text.null mid
          then Left (DedupePolicyUnsatisfied policy)
          else Right mid
  PreferSourceEventIdentity ->
    case event ^. #sourceEventId of
      Just (EventId u) -> Right (UUID.toText u)
      Nothing ->
        case event ^. #sourceGlobalPosition of
          Just (GlobalPosition p) -> Right (Text.pack (show p))
          Nothing -> Left (DedupePolicyUnsatisfied policy)
  KafkaDeliveryIdentity ->
    case kafka of
      Just ref ->
        Right
          ( (ref ^. #topic)
              <> ":"
              <> Text.pack (show (ref ^. #partition))
              <> ":"
              <> Text.pack (show (ref ^. #offset))
          )
      Nothing -> Left (DedupePolicyUnsatisfied policy)
  CustomDedupeKey k ->
    if Text.null k
      then Left (DedupePolicyUnsatisfied policy)
      else Right k