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