packages feed

keiro-0.2.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