keiro-0.6.0.0: src/Keiro/Outbox/Types.hs
-- | Shared types for the durable integration-event outbox.
--
-- The outbox is the durable handoff between "this service has decided to
-- publish this integration event" and "this service has actually published
-- it". The producer subscription writes one outbox row per mapped private
-- event; the publisher worker drains rows into Kafka and marks each one
-- sent, retryable, or dead.
module Keiro.Outbox.Types
( OutboxId (..),
OutboxStatus (..),
OrderingPolicy (..),
BackoffSchedule (..),
ExponentialBackoffOptions (..),
OutboxMessage (..),
OutboxRow (..),
OutboxPublishOptions (..),
OutboxPublishConfigError (..),
OutboxPublishSummary (..),
OutboxMaintenanceOptions (..),
OutboxMaintenanceSummary (..),
defaultPublishOptions,
defaultMaintenanceOptions,
mkOutboxPublishOptions,
statusText,
parseStatus,
nextDelay,
)
where
import Data.Time.Clock (NominalDiffTime)
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Keiro.Integration.Event (IntegrationEvent)
import Keiro.Prelude
import OpenTelemetry.Trace.Core (Tracer)
-- | Primary key of a 'keiro_outbox' row. Stable across publish retries.
newtype OutboxId = OutboxId {unOutboxId :: UUID}
deriving stock (Generic, Eq, Ord, Show)
instance ToJSON OutboxId where
toJSON = toJSON . UUID.toText . unOutboxId
instance FromJSON OutboxId where
parseJSON v = do
text <- parseJSON v
case UUID.fromText text of
Nothing -> fail ("OutboxId: not a UUID: " <> show text)
Just uuid -> pure (OutboxId uuid)
-- | Lifecycle state of an outbox row.
--
-- * 'OutboxPending' — never attempted.
-- * 'OutboxPublishing' — currently held by a publisher worker (between
-- claim and the call to mark sent/failed/dead). Rows left in this state
-- after a worker crash are reclaimed by 'Keiro.Outbox.outboxMaintenancePass'
-- after 'publishingTimeout'.
-- * 'OutboxSent' — Kafka acknowledged the publish; terminal.
-- * 'OutboxFailed' — last attempt failed; will be retried after
-- 'next_attempt_at'.
-- * 'OutboxDead' — terminal failure after 'maxAttempts' consecutive
-- failures. Stays in the table for operator inspection.
data OutboxStatus
= OutboxPending
| OutboxPublishing
| OutboxSent
| OutboxFailed
| OutboxDead
deriving stock (Generic, Eq, Show)
-- | Ordering policy enforced by the publisher worker's claim query.
--
-- * 'PerKeyHeadOfLine' (default) — within a @source@, a non-terminal row
-- with key @k@ blocks every later row with the same key. Rows with
-- 'Nothing' key bypass the block (Kafka does not promise cross-key order
-- for null-keyed records). One stuck aggregate cannot stall traffic on
-- other aggregates. Ordering is based on @created_at@, which PostgreSQL
-- sets to transaction-start time; callers that concurrently enqueue the
-- same key through escape hatches must serialize those enqueues themselves
-- if commit order matters.
-- * 'PerSourceStream' — within a @source@, any non-terminal row blocks
-- every later row. Use when ordering matters across keys (rare). This has
-- the same @created_at@ concurrency caveat as 'PerKeyHeadOfLine'.
-- * 'StopTheLine' — any failure halts the worker until operator
-- intervention. Use when correctness requires manual review on every
-- failure.
-- * 'BestEffort' — failed rows do not block; explicit opt-in only. Safe
-- only when published events have no per-key/causal relationship.
data OrderingPolicy
= PerKeyHeadOfLine
| PerSourceStream
| StopTheLine
| BestEffort
deriving stock (Generic, Eq, Show)
-- | Knobs for 'ExponentialBackoff'. @delay = min maxDelay (initial * multiplier ^ (attempt - 1))@.
data ExponentialBackoffOptions = ExponentialBackoffOptions
{ initial :: !NominalDiffTime,
maxDelay :: !NominalDiffTime,
multiplier :: !Double
}
deriving stock (Generic, Eq, Show)
-- | Backoff curve used to compute 'next_attempt_at' after a failure.
--
-- * 'ConstantBackoff' — fixed delay between retries.
-- * 'ExponentialBackoff' — exponential growth capped at @maxDelay@.
data BackoffSchedule
= ConstantBackoff !NominalDiffTime
| ExponentialBackoff !ExponentialBackoffOptions
deriving stock (Generic, Eq, Show)
-- | Compute the retry delay for an attempt number (1-based: 1 = first
-- failure, 2 = second failure, …). Used by 'Keiro.Outbox.Schema.markOutboxFailedTx'
-- to derive @next_attempt_at@.
nextDelay :: BackoffSchedule -> Int -> NominalDiffTime
nextDelay (ConstantBackoff delay) _ = delay
nextDelay (ExponentialBackoff opts) attempt =
let raw = (opts ^. #initial) * realToFrac ((opts ^. #multiplier) ** fromIntegral (max 0 (attempt - 1)))
in min (opts ^. #maxDelay) raw
-- | A request to enqueue one integration event into the outbox. Callers
-- generate 'outboxId' (use a random UUID for ad-hoc enqueues, a
-- deterministic UUID for idempotent retries from a saga/process manager).
data OutboxMessage = OutboxMessage
{ outboxId :: !OutboxId,
event :: !IntegrationEvent
}
deriving stock (Generic, Eq, Show)
-- | A row read back from @keiro_outbox@. Worker code consumes these to
-- publish to Kafka; tests use them to assert state transitions.
data OutboxRow = OutboxRow
{ outboxId :: !OutboxId,
event :: !IntegrationEvent,
status :: !OutboxStatus,
attemptCount :: !Int,
nextAttemptAt :: !UTCTime,
lastError :: !(Maybe Text),
publishedAt :: !(Maybe UTCTime),
createdAt :: !UTCTime,
updatedAt :: !UTCTime
}
deriving stock (Generic, Eq, Show)
-- | Knobs that govern one invocation of 'Keiro.Outbox.publishClaimedOutbox'.
--
-- The optional 'tracer' field opts the publisher into OpenTelemetry
-- instrumentation: when present, the publisher opens a @Producer@-kind
-- span around each publish call, attributing the first row's
-- 'IntegrationEvent.destination' (topic), 'IntegrationEvent.messageId',
-- and Kafka key per the messaging semantic conventions. When 'tracer' is
-- 'Nothing' (the default) the publisher emits no spans.
data OutboxPublishOptions = OutboxPublishOptions
{ batchSize :: !Int,
maxAttempts :: !Int,
backoff :: !BackoffSchedule,
orderingPolicy :: !OrderingPolicy,
publishingTimeout :: !NominalDiffTime,
tracer :: !(Maybe Tracer)
}
deriving stock (Generic)
data OutboxPublishConfigError
= InvalidOutboxBatchSize !Int
| InvalidOutboxMaxAttempts !Int
| InvalidOutboxPublishingTimeout !NominalDiffTime
| InvalidConstantBackoff !NominalDiffTime
| InvalidExponentialBackoffInitial !NominalDiffTime
| InvalidExponentialBackoffMultiplier !Double
| InvalidExponentialBackoffMaxDelay !NominalDiffTime !NominalDiffTime
deriving stock (Generic, Eq, Show)
-- | Aggregate result of one publisher pass.
--
-- @published + retried + dead@ equals the number of rows claimed. 'retried'
-- includes rows that were skipped because an earlier row in the same ordered
-- publish group failed; those rows are returned to @failed@ without consuming
-- an attempt. 'haltedOn' is populated only by 'StopTheLine' policy and names
-- the failed pivot row, which is already counted in 'retried' or 'dead'.
data OutboxPublishSummary = OutboxPublishSummary
{ claimed :: !Int,
published :: !Int,
retried :: !Int,
dead :: !Int,
haltedOn :: !(Maybe OutboxId)
}
deriving stock (Generic, Eq, Show)
-- | Knobs for 'Keiro.Outbox.outboxMaintenancePass'.
--
-- Schedule maintenance less frequently than publish passes; it owns crash
-- reclamation and backlog gauge sampling.
data OutboxMaintenanceOptions = OutboxMaintenanceOptions
{ maxAttempts :: !Int,
publishingTimeout :: !NominalDiffTime
}
deriving stock (Generic, Eq, Show)
-- | Result of one outbox maintenance pass.
data OutboxMaintenanceSummary = OutboxMaintenanceSummary
{ requeued :: !Int,
deadLettered :: !Int,
backlog :: !Int
}
deriving stock (Generic, Eq, Show)
-- | Sensible defaults: batch of 32, ten retry attempts, two-second
-- constant backoff, per-key head-of-line ordering.
defaultPublishOptions :: OutboxPublishOptions
defaultPublishOptions =
OutboxPublishOptions
{ batchSize = 32,
maxAttempts = 10,
backoff = ConstantBackoff 2,
orderingPolicy = PerKeyHeadOfLine,
publishingTimeout = 300,
tracer = Nothing
}
-- | Maintenance defaults match the publisher's retry ceiling and stale-row timeout.
defaultMaintenanceOptions :: OutboxMaintenanceOptions
defaultMaintenanceOptions =
OutboxMaintenanceOptions
{ maxAttempts = defaultPublishOptions ^. #maxAttempts,
publishingTimeout = defaultPublishOptions ^. #publishingTimeout
}
-- | Validate outbox publisher options before starting a worker.
mkOutboxPublishOptions :: OutboxPublishOptions -> Either OutboxPublishConfigError OutboxPublishOptions
mkOutboxPublishOptions opts
| opts ^. #batchSize < 1 = Left (InvalidOutboxBatchSize (opts ^. #batchSize))
| opts ^. #maxAttempts < 1 = Left (InvalidOutboxMaxAttempts (opts ^. #maxAttempts))
| opts ^. #publishingTimeout <= 0 = Left (InvalidOutboxPublishingTimeout (opts ^. #publishingTimeout))
| otherwise = opts <$ validateBackoff (opts ^. #backoff)
validateBackoff :: BackoffSchedule -> Either OutboxPublishConfigError ()
validateBackoff = \case
ConstantBackoff delay
| delay < 0 -> Left (InvalidConstantBackoff delay)
| otherwise -> Right ()
ExponentialBackoff backoff
| backoff ^. #initial <= 0 -> Left (InvalidExponentialBackoffInitial (backoff ^. #initial))
| backoff ^. #multiplier < 1 -> Left (InvalidExponentialBackoffMultiplier (backoff ^. #multiplier))
| backoff ^. #maxDelay < backoff ^. #initial ->
Left (InvalidExponentialBackoffMaxDelay (backoff ^. #initial) (backoff ^. #maxDelay))
| otherwise -> Right ()
-- | Wire representation of 'OutboxStatus' used in the @status@ column.
statusText :: OutboxStatus -> Text
statusText = \case
OutboxPending -> "pending"
OutboxPublishing -> "publishing"
OutboxSent -> "sent"
OutboxFailed -> "failed"
OutboxDead -> "dead"
-- | Inverse of 'statusText'. Unknown database values are decode failures.
parseStatus :: Text -> Either Text OutboxStatus
parseStatus = \case
"pending" -> Right OutboxPending
"publishing" -> Right OutboxPublishing
"sent" -> Right OutboxSent
"failed" -> Right OutboxFailed
"dead" -> Right OutboxDead
bad -> Left ("unknown keiro_outbox.status: " <> bad)