| Safe Haskell | None |
|---|---|
| Language | GHC2024 |
Keiro.Outbox
Description
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
IntegrationProducerhelper maps durable private events to publicIntegrationEventvalues and enqueues one outbox row per mapped event. It mintsmessageIdas a prefixed UUIDv7 (TypeID) so the id is time-ordered, human-readable, and stable across publish retries. enqueueOutboxTxis 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'sTransaction.
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
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.
Synopsis
- module Keiro.Outbox.Types
- enqueueOutboxTx :: OutboxMessage -> Transaction ()
- claimOutboxBatch :: forall (es :: [Effect]). Store :> es => OrderingPolicy -> Int -> UTCTime -> Eff es [OutboxRow]
- requeueStuckOutbox :: forall (es :: [Effect]). Store :> es => Int -> NominalDiffTime -> UTCTime -> Eff es (Int, Int)
- markOutboxSent :: forall (es :: [Effect]). Store :> es => OutboxId -> UTCTime -> Eff es Bool
- lookupOutbox :: forall (es :: [Effect]). Store :> es => OutboxId -> Eff es (Maybe OutboxRow)
- listOutbox :: forall (es :: [Effect]). Store :> es => Text -> Eff es [OutboxRow]
- listStuckOutbox :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow]
- listSentOutboxGcCandidates :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow]
- countOutboxBacklog :: forall (es :: [Effect]). Store :> es => Eff es Int
- garbageCollectSent :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es Int
- freshOutboxId :: forall (es :: [Effect]). IOE :> es => Eff es OutboxId
- enqueueIntegrationEventTx :: OutboxId -> IntegrationEvent -> Transaction ()
- data IntegrationProducer e = IntegrationProducer {
- name :: !Text
- source :: !Text
- messageIdPrefix :: !Text
- mapEvent :: !(RecordedEvent -> e -> Maybe IntegrationEventDraft)
- data IntegrationProducerConfigError = InvalidMessageIdPrefix !Text !Text
- 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)
- mkIntegrationProducer :: IntegrationProducer e -> Either IntegrationProducerConfigError (IntegrationProducer e)
- mintIntegrationEvent :: forall (es :: [Effect]) e. IOE :> es => IntegrationProducer e -> IntegrationEventDraft -> Eff es IntegrationEvent
- draftToEvent :: Text -> Text -> IntegrationEventDraft -> IntegrationEvent
- enqueueProducerEventTx :: forall e (es :: [Effect]). IOE :> es => IntegrationProducer e -> OutboxId -> IntegrationEventDraft -> Eff es (Transaction ())
- data PublishOutcome
- publishClaimedOutbox :: forall (es :: [Effect]). (IOE :> es, Store :> es) => ([OutboxRow] -> Eff es [(OutboxId, PublishOutcome)]) -> OutboxPublishOptions -> Maybe KeiroMetrics -> Eff es OutboxPublishSummary
- outboxMaintenancePass :: forall (es :: [Effect]). (IOE :> es, Store :> es) => OutboxMaintenanceOptions -> Maybe KeiroMetrics -> Eff es OutboxMaintenanceSummary
- sampleOutboxBacklog :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Maybe KeiroMetrics -> Eff es ()
Re-exports
module Keiro.Outbox.Types
Storage primitives (transport-neutral)
enqueueOutboxTx :: OutboxMessage -> Transaction () Source #
Enqueue one integration event inside an existing transaction.
The (source, message_id) unique constraint catches duplicate retries
from a saga/process-manager. Callers that mint a fresh messageId per
attempt should also mint a fresh outboxId; callers that want
idempotent retries should reuse both.
The row's created_at value is the PostgreSQL transaction-start time. The
per-key and per-source publisher policies therefore provide only best-effort
ordering when concurrent transactions enqueue the same key/source and commit
in the opposite order. Serialize those transactions when strict order matters;
the canonical producer subscription already does so.
claimOutboxBatch :: forall (es :: [Effect]). Store :> es => OrderingPolicy -> Int -> UTCTime -> Eff es [OutboxRow] Source #
Claim up to limit rows ready for publish.
Rows in pending or failed status whose next_attempt_at has passed
become candidates. The selection is filtered by OrderingPolicy:
PerKeyHeadOfLine— a row is claimed only if every earlier non-terminal row with the same(source, message_key)is also claimed by the same statement. Rows withmessage_key IS NULLbypass the per-key check.PerSourceStream— a row is claimed only if every earlier non-terminal row in the samesourceis also claimed by the same statement, regardless of key.StopTheLine— same asPerKeyHeadOfLineat claim time; the worker halts on the first failure (decided at the worker level).BestEffort— no head-of-line predicate.
The returned list preserves (created_at, outbox_id) order. Per-key and
per-source subsequences are therefore gapless ordered runs. The LIMIT
applies to the locked candidate set before the post-filter; under
concurrent claimers or a limit cut through the middle of a run, a pass
can return fewer than limit rows even when more rows are ready.
Claimed rows are transitioned to publishing and have their
attempt_count incremented atomically.
requeueStuckOutbox :: forall (es :: [Effect]). Store :> es => Int -> NominalDiffTime -> UTCTime -> Eff es (Int, Int) Source #
Reclaim rows stranded in publishing longer than olderThan.
Rows whose claim already consumed the attempt budget are dead-lettered; the
rest return to failed so the regular claim query can retry them. Returns
(requeued, deadLettered).
markOutboxSent :: forall (es :: [Effect]). Store :> es => OutboxId -> UTCTime -> Eff es Bool Source #
Mark a row as successfully published. Sets published_at and clears
last_error. Returns False if the row left publishing before the mark,
for example because a stale-row sweeper or operator changed it while the
transport publish was in flight. The publish may still have happened; callers
must treat this as at-least-once delivery.
lookupOutbox :: forall (es :: [Effect]). Store :> es => OutboxId -> Eff es (Maybe OutboxRow) Source #
Read a single outbox row by id. Used by tests and inspection tooling.
listOutbox :: forall (es :: [Effect]). Store :> es => Text -> Eff es [OutboxRow] Source #
List outbox rows for a source, ordered by created_at. Used by tests;
not intended for application traffic.
listStuckOutbox :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow] Source #
List rows a stale-publisher recovery pass would consider, oldest first.
This is a read-only operator preview for requeueStuckOutbox.
listSentOutboxGcCandidates :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow] Source #
List sent rows a retention pass would delete, oldest first.
This is a read-only operator preview for garbageCollectSent.
countOutboxBacklog :: forall (es :: [Effect]). Store :> es => Eff es Int Source #
Count outbox rows awaiting publish (backlog gauge source).
Backlog = rows in a claimable, non-terminal state. Mirrors the claim
query's status IN ( predicate so the gauge measures
exactly the rows a publisher still has to drain (rows held mid-pass in
pending,failed)publishing, and the terminal sent/dead rows, are excluded).
garbageCollectSent :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es Int Source #
Delete sent rows whose published_at is older than keepFor before
now.
Returns the number of rows deleted. dead rows are never deleted: they are
operator action items proving an event was not published. The retention window
only bounds how long successful publish history remains queryable; consumer
dedupe lives in the inbox, not here.
Inline escape hatch
freshOutboxId :: forall (es :: [Effect]). IOE :> es => Eff es OutboxId Source #
Mint a fresh time-ordered UUIDv7 for use as an OutboxId.
enqueueIntegrationEventTx :: OutboxId -> IntegrationEvent -> Transaction () Source #
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.
Canonical producer-subscription helper
data IntegrationProducer e Source #
Configuration for the canonical producer subscription.
A service running IntegrationProducer reads its private event stream,
decodes each event with a 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 thesubscriptionstable.source— value written intokeiro_outbox.source; identifies the producing bounded context.messageIdPrefix— TypeID prefix used when mintingmessageId. Must be 1-63 lowercase Latin letters (e.g."msg","order"). Prefer constructing producers withmkIntegrationProducer; an invalid prefix passed directly toIntegrationProducerraises when the first message id is minted.mapEvent— pure mapper from a privateRecordedEventand its decoded payload to anIntegrationEventDraft. ReturningNothingskips the event without enqueuing a row.
Constructors
| IntegrationProducer | |
Fields
| |
Instances
data IntegrationProducerConfigError Source #
Constructors
| InvalidMessageIdPrefix !Text !Text |
Instances
| Generic IntegrationProducerConfigError Source # | |||||
Defined in Keiro.Outbox Associated Types
| |||||
| Show IntegrationProducerConfigError Source # | |||||
Defined in Keiro.Outbox Methods showsPrec :: Int -> IntegrationProducerConfigError -> ShowS # show :: IntegrationProducerConfigError -> String # showList :: [IntegrationProducerConfigError] -> ShowS # | |||||
| Eq IntegrationProducerConfigError Source # | |||||
Defined in Keiro.Outbox | |||||
| type Rep IntegrationProducerConfigError Source # | |||||
Defined in Keiro.Outbox type Rep IntegrationProducerConfigError = D1 ('MetaData "IntegrationProducerConfigError" "Keiro.Outbox" "keiro-0.12.0.0-inplace" 'False) (C1 ('MetaCons "InvalidMessageIdPrefix" 'PrefixI 'False) (S1 ('MetaSel ('Nothing :: Maybe Symbol) 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Nothing :: Maybe Symbol) 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text))) | |||||
data IntegrationEventDraft Source #
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.
Constructors
| IntegrationEventDraft | |
Fields
| |
Instances
| Generic IntegrationEventDraft Source # | |||||
Defined in Keiro.Outbox Associated Types
Methods from :: IntegrationEventDraft -> Rep IntegrationEventDraft x # to :: Rep IntegrationEventDraft x -> IntegrationEventDraft # | |||||
| Show IntegrationEventDraft Source # | |||||
Defined in Keiro.Outbox Methods showsPrec :: Int -> IntegrationEventDraft -> ShowS # show :: IntegrationEventDraft -> String # showList :: [IntegrationEventDraft] -> ShowS # | |||||
| Eq IntegrationEventDraft Source # | |||||
Defined in Keiro.Outbox Methods (==) :: IntegrationEventDraft -> IntegrationEventDraft -> Bool # (/=) :: IntegrationEventDraft -> IntegrationEventDraft -> Bool # | |||||
| type Rep IntegrationEventDraft Source # | |||||
Defined in Keiro.Outbox type Rep IntegrationEventDraft = D1 ('MetaData "IntegrationEventDraft" "Keiro.Outbox" "keiro-0.12.0.0-inplace" 'False) (C1 ('MetaCons "IntegrationEventDraft" 'PrefixI 'True) (((S1 ('MetaSel ('Just "destination") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text) :*: (S1 ('MetaSel ('Just "key") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe Text)) :*: S1 ('MetaSel ('Just "eventType") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text))) :*: ((S1 ('MetaSel ('Just "schemaVersion") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Int) :*: S1 ('MetaSel ('Just "contentType") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 IntegrationContentType)) :*: (S1 ('MetaSel ('Just "schemaReference") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe SchemaReference)) :*: S1 ('MetaSel ('Just "sourceEventId") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe EventId))))) :*: ((S1 ('MetaSel ('Just "sourceGlobalPosition") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe GlobalPosition)) :*: (S1 ('MetaSel ('Just "payloadBytes") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 ByteString) :*: S1 ('MetaSel ('Just "occurredAt") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 UTCTime))) :*: ((S1 ('MetaSel ('Just "causationId") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe EventId)) :*: S1 ('MetaSel ('Just "correlationId") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe EventId))) :*: (S1 ('MetaSel ('Just "traceContext") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe TraceContext)) :*: S1 ('MetaSel ('Just "attributes") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (Maybe Value))))))) | |||||
mkIntegrationProducer :: IntegrationProducer e -> Either IntegrationProducerConfigError (IntegrationProducer e) Source #
Validate an integration producer before starting its subscription.
mintIntegrationEvent :: forall (es :: [Effect]) e. IOE :> es => IntegrationProducer e -> IntegrationEventDraft -> Eff es IntegrationEvent Source #
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.
draftToEvent :: Text -> Text -> IntegrationEventDraft -> IntegrationEvent Source #
Build an IntegrationEvent from a source, a minted message id, and a draft.
enqueueProducerEventTx :: forall e (es :: [Effect]). IOE :> es => IntegrationProducer e -> OutboxId -> IntegrationEventDraft -> Eff es (Transaction ()) Source #
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.
Publisher worker
data PublishOutcome Source #
Result of one publish attempt as reported by the transport-specific publisher.
Constructors
| PublishSucceeded | Kafka acknowledged the publish. |
| PublishFailed !Text | Publish failed; will be retried after the configured backoff. |
Instances
| Generic PublishOutcome Source # | |||||
Defined in Keiro.Outbox Associated Types
Methods from :: PublishOutcome -> Rep PublishOutcome x # to :: Rep PublishOutcome x -> PublishOutcome # | |||||
| Show PublishOutcome Source # | |||||
Defined in Keiro.Outbox Methods showsPrec :: Int -> PublishOutcome -> ShowS # show :: PublishOutcome -> String # showList :: [PublishOutcome] -> ShowS # | |||||
| Eq PublishOutcome Source # | |||||
Defined in Keiro.Outbox Methods (==) :: PublishOutcome -> PublishOutcome -> Bool # (/=) :: PublishOutcome -> PublishOutcome -> Bool # | |||||
| type Rep PublishOutcome Source # | |||||
Defined in Keiro.Outbox type Rep PublishOutcome = D1 ('MetaData "PublishOutcome" "Keiro.Outbox" "keiro-0.12.0.0-inplace" 'False) (C1 ('MetaCons "PublishSucceeded" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "PublishFailed" 'PrefixI 'False) (S1 ('MetaSel ('Nothing :: Maybe Symbol) 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text))) | |||||
publishClaimedOutbox :: forall (es :: [Effect]). (IOE :> es, Store :> es) => ([OutboxRow] -> Eff es [(OutboxId, PublishOutcome)]) -> OutboxPublishOptions -> Maybe KeiroMetrics -> Eff es OutboxPublishSummary Source #
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 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
StopTheLineand a publish failed.
The worker does not loop indefinitely; the application is expected to schedule it repeatedly (e.g. once per process-compose tick).
outboxMaintenancePass :: forall (es :: [Effect]). (IOE :> es, Store :> es) => OutboxMaintenanceOptions -> Maybe KeiroMetrics -> Eff es OutboxMaintenanceSummary Source #
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.