keiro
Safe HaskellNone
LanguageGHC2024

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 IntegrationProducer helper maps durable private events to public 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 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 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

Re-exports

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 with message_key IS NULL bypass the per-key check.
  • PerSourceStream — a row is claimed only if every earlier non-terminal row in the same source is also claimed by the same statement, regardless of key.
  • StopTheLine — same as PerKeyHeadOfLine at 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 (pending,failed) predicate so the gauge measures exactly the rows a publisher still has to drain (rows held mid-pass in 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 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.

Instances

Instances details
Generic (IntegrationProducer e) Source # 
Instance details

Defined in Keiro.Outbox

Associated Types

type Rep (IntegrationProducer e) 
Instance details

Defined in Keiro.Outbox

type Rep (IntegrationProducer e) = D1 ('MetaData "IntegrationProducer" "Keiro.Outbox" "keiro-0.12.0.0-inplace" 'False) (C1 ('MetaCons "IntegrationProducer" 'PrefixI 'True) ((S1 ('MetaSel ('Just "name") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Just "source") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text)) :*: (S1 ('MetaSel ('Just "messageIdPrefix") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Just "mapEvent") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (RecordedEvent -> e -> Maybe IntegrationEventDraft)))))
type Rep (IntegrationProducer e) Source # 
Instance details

Defined in Keiro.Outbox

type Rep (IntegrationProducer e) = D1 ('MetaData "IntegrationProducer" "Keiro.Outbox" "keiro-0.12.0.0-inplace" 'False) (C1 ('MetaCons "IntegrationProducer" 'PrefixI 'True) ((S1 ('MetaSel ('Just "name") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Just "source") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text)) :*: (S1 ('MetaSel ('Just "messageIdPrefix") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 Text) :*: S1 ('MetaSel ('Just "mapEvent") 'NoSourceUnpackedness 'SourceStrict 'DecidedStrict) (Rec0 (RecordedEvent -> e -> Maybe IntegrationEventDraft)))))

data IntegrationProducerConfigError Source #

Instances

Instances details
Generic IntegrationProducerConfigError Source # 
Instance details

Defined in Keiro.Outbox

Associated Types

type Rep IntegrationProducerConfigError 
Instance details

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)))
Show IntegrationProducerConfigError Source # 
Instance details

Defined in Keiro.Outbox

Eq IntegrationProducerConfigError Source # 
Instance details

Defined in Keiro.Outbox

type Rep IntegrationProducerConfigError Source # 
Instance details

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.

Instances

Instances details
Generic IntegrationEventDraft Source # 
Instance details

Defined in Keiro.Outbox

Associated Types

type Rep IntegrationEventDraft 
Instance details

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)))))))
Show IntegrationEventDraft Source # 
Instance details

Defined in Keiro.Outbox

Eq IntegrationEventDraft Source # 
Instance details

Defined in Keiro.Outbox

type Rep IntegrationEventDraft Source # 
Instance details

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

Instances details
Generic PublishOutcome Source # 
Instance details

Defined in Keiro.Outbox

Associated Types

type Rep PublishOutcome 
Instance details

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)))
Show PublishOutcome Source # 
Instance details

Defined in Keiro.Outbox

Eq PublishOutcome Source # 
Instance details

Defined in Keiro.Outbox

type Rep PublishOutcome Source # 
Instance details

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 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).

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.

sampleOutboxBacklog :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Maybe KeiroMetrics -> Eff es () Source #

Count publishable rows and record the outbox backlog gauge when metrics are enabled.