packages feed

keiro-0.2.0.0: src/Keiro/Outbox.hs

{- | Durable integration-event outbox.

The outbox decouples "this service has decided to publish an integration
event" from "this service has actually published it". Two surfaces use
it:

* The canonical 'IntegrationProducer' helper maps durable private events
  to public 'Keiro.Integration.Event.IntegrationEvent' values and enqueues
  one outbox row per mapped event. It mints @messageId@ as a prefixed
  UUIDv7 (TypeID) so the id is time-ordered, human-readable, and stable
  across publish retries.
* 'enqueueOutboxTx' is the inline escape hatch for sagas and process
  managers that need to emit an integration event without an intermediate
  private domain event. It runs inside the caller's
  'Hasql.Transaction.Transaction'.

The 'publishClaimedOutbox' worker is transport-neutral. It claims rows
with @FOR UPDATE SKIP LOCKED@ plus the configured 'OrderingPolicy',
hands claimed batches to a caller-supplied publish function, and marks rows
sent, retryable, or dead. The Kafka adapter lives in
'Keiro.Outbox.Kafka'.

Run 'outboxMaintenancePass' on a separate, slower schedule to reclaim rows
left in @publishing@ by crashed workers and to sample the backlog gauge.

The per-key and per-source ordering policies sort by @created_at@, which
PostgreSQL fills at transaction start. The canonical 'IntegrationProducer'
subscription serializes same-key enqueues, so its ordering is stable. Callers
using the inline 'enqueueIntegrationEventTx' escape hatch concurrently for the
same key must serialize those enqueues themselves or accept best-effort order:
two transactions can commit in the opposite order of their @created_at@ values.
-}
module Keiro.Outbox (
    -- * Re-exports
    module Keiro.Outbox.Types,

    -- * Storage primitives (transport-neutral)
    enqueueOutboxTx,
    claimOutboxBatch,
    requeueStuckOutbox,
    markOutboxSent,
    lookupOutbox,
    listOutbox,
    countOutboxBacklog,
    garbageCollectSent,

    -- * Inline escape hatch
    freshOutboxId,
    enqueueIntegrationEventTx,

    -- * Canonical producer-subscription helper
    IntegrationProducer (..),
    IntegrationProducerConfigError (..),
    IntegrationEventDraft (..),
    mkIntegrationProducer,
    mintIntegrationEvent,
    draftToEvent,
    enqueueProducerEventTx,

    -- * Publisher worker
    PublishOutcome (..),
    publishClaimedOutbox,
    outboxMaintenancePass,
    sampleOutboxBacklog,
)
where

import Data.ByteString (ByteString)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.TypeID qualified as TypeID
import Data.UUID.V7 qualified as V7
import Effectful (Eff, IOE, (:>))
import Effectful.Exception (displayException, trySync)
import Keiro.Integration.Event (
    IntegrationContentType,
    IntegrationEvent (..),
    SchemaReference,
    TraceContext,
 )
import Keiro.Outbox.Kafka (outboxRowToKafkaRecord)
import Keiro.Outbox.Schema
import Keiro.Outbox.Types
import Keiro.Prelude
import Keiro.Telemetry (
    KeiroMetrics,
    recordOutboxBacklog,
    recordOutboxDeadlettered,
    recordOutboxPublished,
    recordOutboxReclaimed,
    recordOutboxRetried,
    withProducerSpan,
 )
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId, GlobalPosition, RecordedEvent)
import OpenTelemetry.Attributes.Key (AttributeKey (..), unkey)
import OpenTelemetry.SemanticConventions (error_type)
import OpenTelemetry.Trace.Core (SpanStatus (..), addAttribute, setStatus)
import "hasql-transaction" Hasql.Transaction qualified as Tx

keiro_outbox_batch_size :: AttributeKey Int64
keiro_outbox_batch_size = AttributeKey "keiro.outbox.batch.size"

-- | Mint a fresh time-ordered UUIDv7 for use as an 'OutboxId'.
freshOutboxId :: (IOE :> es) => Eff es OutboxId
freshOutboxId = fmap OutboxId (liftIO V7.genUUID)

{- | Enqueue an 'IntegrationEvent' from a saga or process manager that is
already running inside a 'runCommandWithSqlEvents' transaction. The
caller supplies a stable 'OutboxId' so retried command attempts coalesce
on the @(source, message_id)@ unique constraint.

Ordering caveat: under 'PerKeyHeadOfLine' and 'PerSourceStream', the publisher
orders rows by @created_at@, which PostgreSQL sets to transaction-start time.
If two concurrent transactions enqueue the same key/source and commit in the
opposite order, a publisher can observe that order. Serialize same-key enqueues
when strict order matters.
-}
enqueueIntegrationEventTx ::
    OutboxId ->
    IntegrationEvent ->
    Tx.Transaction ()
enqueueIntegrationEventTx outboxId event =
    enqueueOutboxTx (OutboxMessage{outboxId, event})

-- ---------------------------------------------------------------------------
-- Producer-subscription helper
-- ---------------------------------------------------------------------------

{- | Configuration for the canonical producer subscription.

A service running 'IntegrationProducer' reads its private event stream,
decodes each event with a 'Keiro.Codec.Codec', calls 'mapEvent', and for
each 'Just' result writes one 'keiro_outbox' row. The helper mints
@messageId@ on each insert so the id is stable across publish retries.

* 'name' — subscription name used to checkpoint the producer's cursor
  in the @subscriptions@ table.
* 'source' — value written into @keiro_outbox.source@; identifies the
  producing bounded context.
* 'messageIdPrefix' — TypeID prefix used when minting @messageId@.
  Must be 1-63 lowercase Latin letters (e.g. @\"msg\"@, @\"order\"@).
  Prefer constructing producers with 'mkIntegrationProducer'; an invalid
  prefix passed directly to 'IntegrationProducer' raises when the first
  message id is minted.
* 'mapEvent' — pure mapper from a private 'RecordedEvent' and its
  decoded payload to an 'IntegrationEventDraft'. Returning 'Nothing'
  skips the event without enqueuing a row.
-}
data IntegrationProducer e = IntegrationProducer
    { name :: !Text
    , source :: !Text
    , messageIdPrefix :: !Text
    , mapEvent :: !(RecordedEvent -> e -> Maybe IntegrationEventDraft)
    }
    deriving stock (Generic)

data IntegrationProducerConfigError
    = InvalidMessageIdPrefix !Text !Text
    deriving stock (Generic, Eq, Show)

-- | Validate an integration producer before starting its subscription.
mkIntegrationProducer :: IntegrationProducer e -> Either IntegrationProducerConfigError (IntegrationProducer e)
mkIntegrationProducer producer =
    case TypeID.checkPrefix (producer ^. #messageIdPrefix) of
        Nothing -> Right producer
        Just err ->
            Left
                ( InvalidMessageIdPrefix
                    (producer ^. #messageIdPrefix)
                    (Text.pack (show err))
                )

{- | Everything in 'IntegrationEvent' except 'messageId' and 'source' —
those are filled in by 'mintIntegrationEvent' from the producer
configuration and the freshly minted TypeID.

@sourceEventId@ and @sourceGlobalPosition@ default to the values on the
underlying 'RecordedEvent' (see 'mintIntegrationEvent'); a mapper that
needs to override them can replace the draft fields directly.
-}
data IntegrationEventDraft = IntegrationEventDraft
    { destination :: !Text
    , key :: !(Maybe Text)
    , eventType :: !Text
    , schemaVersion :: !Int
    , contentType :: !IntegrationContentType
    , schemaReference :: !(Maybe SchemaReference)
    , sourceEventId :: !(Maybe EventId)
    , sourceGlobalPosition :: !(Maybe GlobalPosition)
    , payloadBytes :: !ByteString
    , occurredAt :: !UTCTime
    , causationId :: !(Maybe EventId)
    , correlationId :: !(Maybe EventId)
    , traceContext :: !(Maybe TraceContext)
    , attributes :: !(Maybe Value)
    }
    deriving stock (Generic, Eq, Show)

{- | Mint a fresh @messageId@ (TypeID with the producer's prefix) and build
the full 'IntegrationEvent' from the draft. Lives in 'IO' because TypeID
generation reads the global UUIDv7 sequence counter.
-}
mintIntegrationEvent ::
    (IOE :> es) =>
    IntegrationProducer e ->
    IntegrationEventDraft ->
    Eff es IntegrationEvent
mintIntegrationEvent producer draft = do
    typeId <- liftIO (TypeID.genTypeID (producer ^. #messageIdPrefix))
    pure (draftToEvent (producer ^. #source) (TypeID.toText typeId) draft)

-- | Build an 'IntegrationEvent' from a source, a minted message id, and a draft.
draftToEvent :: Text -> Text -> IntegrationEventDraft -> IntegrationEvent
draftToEvent source minted draft =
    IntegrationEvent
        { messageId = minted
        , source
        , destination = draft ^. #destination
        , key = draft ^. #key
        , eventType = draft ^. #eventType
        , schemaVersion = draft ^. #schemaVersion
        , contentType = draft ^. #contentType
        , schemaReference = draft ^. #schemaReference
        , sourceEventId = draft ^. #sourceEventId
        , sourceGlobalPosition = draft ^. #sourceGlobalPosition
        , payloadBytes = draft ^. #payloadBytes
        , occurredAt = draft ^. #occurredAt
        , causationId = draft ^. #causationId
        , correlationId = draft ^. #correlationId
        , traceContext = draft ^. #traceContext
        , attributes = draft ^. #attributes
        }

{- | Enqueue one drafted producer event inside an existing transaction.

This is the primitive a subscription worker calls per event. It mints a
fresh @messageId@ (TypeID), constructs the full envelope, and inserts
the row. The caller supplies the 'OutboxId' so retries from a known
subscription cursor coalesce on @(source, message_id)@.

The TypeID is minted before the insert; if the transaction rolls back
the message id is discarded (no observable effect) and the next attempt
mints a different id. Idempotency at the row level relies on a stable
'OutboxId', not the minted message id.

Ordering caveat: @created_at@ records transaction-start time. Under
'PerKeyHeadOfLine' or 'PerSourceStream', concurrent transactions for the same
key/source can commit in the opposite order and are therefore best-effort
unless the caller serializes them. The canonical producer subscription does
serialize same-key enqueues.
-}
enqueueProducerEventTx ::
    forall e es.
    (IOE :> es) =>
    IntegrationProducer e ->
    OutboxId ->
    IntegrationEventDraft ->
    Eff es (Tx.Transaction ())
enqueueProducerEventTx producer outboxId draft = do
    event <- mintIntegrationEvent producer draft
    pure (enqueueOutboxTx (OutboxMessage{outboxId, event}))

-- ---------------------------------------------------------------------------
-- Publisher worker
-- ---------------------------------------------------------------------------

-- | Result of one publish attempt as reported by the transport-specific publisher.
data PublishOutcome
    = -- | Kafka acknowledged the publish.
      PublishSucceeded
    | -- | Publish failed; will be retried after the configured backoff.
      PublishFailed !Text
    deriving stock (Generic, Eq, Show)

{- | Drain claimed outbox rows by handing the claimed batch to @publish@ and
reflecting the outcomes back into row statuses.

Claims rows in batches of @batchSize@ under the active 'OrderingPolicy',
calls @publish@ with the claimed rows in claim order, and marks every row
sent or — using 'markOutboxFailedTx' — failed/dead. The publish result must
contain one outcome per input row; a missing outcome is treated as
@PublishFailed "publisher returned no outcome"@. If the publisher throws,
every row in that call is treated as failed with the exception text.

For ordered policies, if a row fails then later rows in the same ordered group
are skipped and returned to @failed@ without consuming an attempt; these
skipped rows count as 'OutboxPublishSummary.retried'. A real Kafka transport
must not successfully deliver a later same-key record after reporting an
earlier same-key failure from the same call. On 'StopTheLine', the worker calls
@publish@ with singleton batches and halts after the first failed row, recording
the offending 'OutboxId' in 'haltedOn'.

Returns when one of:

* No rows are claimable.
* The active policy is 'StopTheLine' and a publish failed.

The worker does not loop indefinitely; the application is expected to
schedule it repeatedly (e.g. once per process-compose tick).
-}
publishClaimedOutbox ::
    forall es.
    (IOE :> es, Store :> es) =>
    ([OutboxRow] -> Eff es [(OutboxId, PublishOutcome)]) ->
    OutboxPublishOptions ->
    Maybe KeiroMetrics ->
    Eff es OutboxPublishSummary
publishClaimedOutbox publish options mMetrics = do
    now <- liftIO getCurrentTime
    rows <- claimOutboxBatch (options ^. #orderingPolicy) (options ^. #batchSize) now
    summary <- publishBatch rows
    -- Counters from the aggregated pass summary (each a no-op under 'Nothing';
    -- a zero delta is harmless).
    recordOutboxPublished mMetrics (fromIntegral (summary ^. #published))
    recordOutboxRetried mMetrics (fromIntegral (summary ^. #retried))
    recordOutboxDeadlettered mMetrics (fromIntegral (summary ^. #dead))
    pure summary
  where
    publishBatch :: [OutboxRow] -> Eff es OutboxPublishSummary
    publishBatch [] =
        pure OutboxPublishSummary{claimed = 0, published = 0, retried = 0, dead = 0, haltedOn = Nothing}
    publishBatch batch =
        case options ^. #orderingPolicy of
            StopTheLine -> publishStopTheLine batch batch [] Nothing
            policy -> do
                outcomes <- publishRows batch
                markProcessedOutcomes policy batch outcomes Nothing

    publishStopTheLine ::
        [OutboxRow] ->
        [OutboxRow] ->
        [(OutboxId, PublishOutcome)] ->
        Maybe OutboxId ->
        Eff es OutboxPublishSummary
    publishStopTheLine original [] outcomes halted =
        markProcessedOutcomes StopTheLine original (Map.fromList outcomes) halted
    publishStopTheLine original (row : rest) outcomes _ = do
        result <- publishRows [row]
        let outcome = outcomeFor result row
            outcomes' = outcomes <> [(row ^. #outboxId, outcome)]
        case outcome of
            PublishSucceeded -> publishStopTheLine original rest outcomes' Nothing
            PublishFailed _ -> markProcessedOutcomes StopTheLine original (Map.fromList outcomes') (Just (row ^. #outboxId))

    publishRows :: [OutboxRow] -> Eff es (Map.Map OutboxId PublishOutcome)
    publishRows [] = pure Map.empty
    publishRows batch@(firstRow : _) =
        Map.fromList
            <$> withBatchSpan
                batch
                firstRow
                ( do
                    attempted <- trySync (publish batch)
                    let normalized =
                            case attempted of
                                Left err ->
                                    let errMsg = Text.pack (displayException err)
                                     in [(row ^. #outboxId, PublishFailed errMsg) | row <- batch]
                                Right reported ->
                                    normalizeOutcomes batch reported
                    pure normalized
                )

    withBatchSpan ::
        [OutboxRow] ->
        OutboxRow ->
        Eff es [(OutboxId, PublishOutcome)] ->
        Eff es [(OutboxId, PublishOutcome)]
    withBatchSpan batch firstRow action =
        withProducerSpan
            (options ^. #tracer)
            (firstRow ^. #event)
            (outboxRowToKafkaRecord firstRow)
            $ \mSpan -> do
                for_ mSpan $ \sp ->
                    addAttribute sp (unkey keiro_outbox_batch_size) (fromIntegral (length batch) :: Int64)
                outcomes <- action
                case (mSpan, firstFailure outcomes) of
                    (Just sp, Just errMsg) -> do
                        addAttribute sp (unkey error_type) ("publish_failed" :: Text)
                        setStatus sp (Error errMsg)
                    _ -> pure ()
                pure outcomes

    normalizeOutcomes :: [OutboxRow] -> [(OutboxId, PublishOutcome)] -> [(OutboxId, PublishOutcome)]
    normalizeOutcomes batch reported =
        let reportedMap = Map.fromList reported
         in [ (row ^. #outboxId, outcomeFor reportedMap row)
            | row <- batch
            ]

    outcomeFor :: Map.Map OutboxId PublishOutcome -> OutboxRow -> PublishOutcome
    outcomeFor outcomes row =
        fromMaybe (PublishFailed "publisher returned no outcome") $
            Map.lookup (row ^. #outboxId) outcomes

    firstFailure :: [(OutboxId, PublishOutcome)] -> Maybe Text
    firstFailure [] = Nothing
    firstFailure ((_, PublishSucceeded) : rest) = firstFailure rest
    firstFailure ((_, PublishFailed errMsg) : _) = Just errMsg

    markProcessedOutcomes ::
        OrderingPolicy ->
        [OutboxRow] ->
        Map.Map OutboxId PublishOutcome ->
        Maybe OutboxId ->
        Eff es OutboxPublishSummary
    markProcessedOutcomes policy batch outcomes halted = do
        now <- liftIO getCurrentTime
        let marks = foldMap (groupMarks outcomes) (outcomeGroups policy batch)
            sentIds = marks ^. #sentIds
            failedRows = marks ^. #failedRows
            skippedRows = marks ^. #skippedRows
        failedStatuses <-
            if null failedRows && null skippedRows
                then pure []
                else runTransaction $ do
                    statuses <- traverse (markFailed now) failedRows
                    traverse_ (markSkipped now) skippedRows
                    pure statuses
        _ <- markOutboxSentBatch sentIds now
        let deadCount = length [() | OutboxDead <- failedStatuses]
            retriedFailures = length failedStatuses - deadCount
        pure
            OutboxPublishSummary
                { claimed = length batch
                , published = length sentIds
                , retried = retriedFailures + length skippedRows
                , dead = deadCount
                , haltedOn = halted
                }

    markFailed :: UTCTime -> (OutboxRow, Text) -> Tx.Transaction OutboxStatus
    markFailed now (row, errMsg) =
        markOutboxFailedTx
            (row ^. #outboxId)
            errMsg
            (options ^. #maxAttempts)
            (nextDelay (options ^. #backoff) (row ^. #attemptCount))
            now

    markSkipped :: UTCTime -> OutboxRow -> Tx.Transaction ()
    markSkipped now row =
        markOutboxSkippedTx
            (row ^. #outboxId)
            "skipped: earlier record for the same key failed"
            now

{- | Reclaim crashed publisher rows and record the outbox backlog gauge.

Schedule this pass independently from 'publishClaimedOutbox', typically on a
slower timer. It is the only library worker path that reclaims rows stranded in
@publishing@.
-}
outboxMaintenancePass ::
    (IOE :> es, Store :> es) =>
    OutboxMaintenanceOptions ->
    Maybe KeiroMetrics ->
    Eff es OutboxMaintenanceSummary
outboxMaintenancePass options mMetrics = do
    now <- liftIO getCurrentTime
    (requeued, deadLettered) <-
        requeueStuckOutbox
            (options ^. #maxAttempts)
            (options ^. #publishingTimeout)
            now
    recordOutboxReclaimed mMetrics (fromIntegral requeued)
    recordOutboxDeadlettered mMetrics (fromIntegral deadLettered)
    backlog <- countOutboxBacklog
    recordOutboxBacklog mMetrics (fromIntegral backlog)
    pure OutboxMaintenanceSummary{requeued, deadLettered, backlog}

-- | Count publishable rows and record the outbox backlog gauge when metrics are enabled.
sampleOutboxBacklog :: (IOE :> es, Store :> es) => Maybe KeiroMetrics -> Eff es ()
sampleOutboxBacklog Nothing = pure ()
sampleOutboxBacklog (Just metrics) = do
    backlog <- countOutboxBacklog
    recordOutboxBacklog (Just metrics) (fromIntegral backlog)

data OutcomeMarks = OutcomeMarks
    { sentIds :: ![OutboxId]
    , failedRows :: ![(OutboxRow, Text)]
    , skippedRows :: ![OutboxRow]
    }
    deriving stock (Generic)

instance Semigroup OutcomeMarks where
    left <> right =
        OutcomeMarks
            { sentIds = (left ^. #sentIds) <> (right ^. #sentIds)
            , failedRows = (left ^. #failedRows) <> (right ^. #failedRows)
            , skippedRows = (left ^. #skippedRows) <> (right ^. #skippedRows)
            }

instance Monoid OutcomeMarks where
    mempty = OutcomeMarks{sentIds = [], failedRows = [], skippedRows = []}

groupMarks :: Map.Map OutboxId PublishOutcome -> [OutboxRow] -> OutcomeMarks
groupMarks outcomes = go []
  where
    go sent [] = mempty{sentIds = sent}
    go sent (row : rest) =
        case fromMaybe (PublishFailed "publisher returned no outcome") (Map.lookup (row ^. #outboxId) outcomes) of
            PublishSucceeded -> go (sent <> [row ^. #outboxId]) rest
            PublishFailed errMsg ->
                OutcomeMarks
                    { sentIds = sent
                    , failedRows = [(row, errMsg)]
                    , skippedRows = rest
                    }

data OutcomeGroupKey
    = BatchGroup
    | SourceGroup !Text
    | RowGroup !OutboxId
    | KeyGroup !Text !Text
    deriving stock (Generic, Eq)

data OutcomeGroup = OutcomeGroup
    { groupKey :: !OutcomeGroupKey
    , groupRows :: ![OutboxRow]
    }
    deriving stock (Generic)

outcomeGroups :: OrderingPolicy -> [OutboxRow] -> [[OutboxRow]]
outcomeGroups policy =
    fmap (^. #groupRows) . foldl' addGroup []
  where
    addGroup groups row =
        appendGroup (keyFor row) row groups
    keyFor row =
        case policy of
            -- A claimed batch can hold runs from several independent sources;
            -- a failure in one source's run must not skip another source's rows.
            PerSourceStream -> SourceGroup (row ^. #event . #source)
            -- Deliberately one group: any failure halts the worker, and the
            -- entire remaining batch is skipped without consuming attempts.
            StopTheLine -> BatchGroup
            BestEffort -> RowGroup (row ^. #outboxId)
            _ ->
                case row ^. #event . #key of
                    Nothing -> RowGroup (row ^. #outboxId)
                    Just key -> KeyGroup (row ^. #event . #source) key

appendGroup :: OutcomeGroupKey -> OutboxRow -> [OutcomeGroup] -> [OutcomeGroup]
appendGroup key row [] = [OutcomeGroup{groupKey = key, groupRows = [row]}]
appendGroup key row (group : rest)
    | group ^. #groupKey == key =
        (group & #groupRows %~ (<> [row])) : rest
    | otherwise =
        group : appendGroup key row rest