packages feed

keiro-0.2.0.0: src/Keiro/Command.hs

{- | The command side of the framework: hydrate an aggregate, transduce, append.

Running a command against an 'EventStream' follows one pipeline:

1. /Hydrate/ — replay the stream's stored events (optionally fast-forwarding
   from a snapshot) through the keiki transducer to recover the current
   @(state, registers)@ and stream version.
2. /Transduce/ — step the transducer with the command. A rejected transition
   yields 'CommandRejected', while multiple matching transitions yield
   'CommandAmbiguous'; a transition that emits no events yields a no-op
   'CommandResult'.
3. /Append/ — encode the emitted events with the stream's 'Codec' and append
   them at the expected version. An optimistic-concurrency conflict is
   retried up to 'retryLimit' times by rehydrating and replaying; exhausting
   the budget yields 'RetryExhausted'.

Three runners expose this pipeline at increasing levels of integration:

* 'runCommand' — append only.
* 'runCommandWithSql' — run an extra @afterAppend@ action in the /same/
  transaction as the append (e.g. update an inline read model).
* 'runCommandWithSqlEvents' — same, but the callback also receives the
  emitted events paired with their 'RecordedEvent's. This is the primitive
  the projection, process-manager, and router layers build on.

The transactional runners apply Kiroku's configured @enrichEvent@ hook before
event preparation, exactly like 'runCommand'. They therefore require a
'KirokuStoreResource' in the effect stack; install it with @withKirokuStore@
and interpret 'Store' with @runStoreResource@.

Per-stream hydration honors Kiroku's stream-truncation marker. A retained
snapshot must cover every hidden event (snapshot version at least marker minus
one); otherwise hydration fails with 'HydrationGapDetected'. A marker above
the stream head can instead appear as an empty stream and repeated append
conflicts end in 'ConflictFixpoint'. Keiro never truncates streams itself.
Kiroku's @$all@ and category/subscription reads are unaffected by per-stream
truncation: the marker hides events from stream reads rather than deleting
them from the global log.

Every successful append is replayed immediately from its pre-command state so
an unreplayable batch is witnessed at the moment it poisons the stream. The
post-commit witness is counted and attached to the command span without
changing the successful result. The same replay fold feeds transparent
snapshot writes when the stream's 'Keiro.EventStream.SnapshotPolicy' fires;
post-commit snapshot failures are likewise swallowed and counted. Every runner
accepts a tracer for optional OpenTelemetry spans.
-}
module Keiro.Command (
    -- * Results and errors
    CommandResult (..),
    CommandError (..),
    HydrationReplayReason (..),
    commandErrorClass,

    -- * Options
    RunCommandOptions (..),
    defaultRunCommandOptions,

    -- * Running commands
    runCommand,
    runCommandWithSql,
    runCommandWithSqlEvents,
)
where

import Control.Concurrent (threadDelay)
import Data.Functor (($>))
import Data.Int (Int32)
import Data.Text qualified as Text
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error, tryError)
import GHC.Clock (getMonotonicTimeNSec)
import GHC.Stack (HasCallStack)
import Keiki.Core (BoolAlg, RegFile)
import Keiki.Core qualified as Keiki
import Keiro.Codec (Codec, CodecError, decodeRecorded, encodeForAppendWithMetadata)
import Keiro.EventStream (EventStream, Terminality (..))
import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)
import Keiro.Prelude
import Keiro.Snapshot (
    SnapshotLookup (..),
    SnapshotMissReason (..),
    encodeSnapshotStrict,
    lookupSnapshotSeed,
    writeSnapshotEncoded,
 )
import Keiro.Snapshot.Policy (shouldSnapshotSpan)
import Keiro.Stream (Stream)
import Keiro.Telemetry (
    KeiroMetrics,
    keiro_events_appended,
    keiro_replay_divergence,
    keiro_retry_attempt,
    recordCommandConflicts,
    recordCommandDuplicates,
    recordCommandRetries,
    recordSnapshotApplyDivergence,
    recordSnapshotDecodeFailures,
    recordSnapshotEncodeFailures,
    recordSnapshotReadHits,
    recordSnapshotReadMisses,
    recordSnapshotWriteFailures,
    withCommandSpan,
 )
import Kiroku.Store.Append (appendToStream)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)
import Kiroku.Store.Error (StoreError (..))
import Kiroku.Store.Read (readStreamForwardStream)
import Kiroku.Store.Transaction (
    PreparedEvent,
    appendConflictToStoreError,
    appendToStreamTx,
    enrichEventsIO,
    prepareEventsIO,
    runTransaction,
 )
import Kiroku.Store.Types (
    AppendResult,
    EventData,
    EventId (..),
    ExpectedVersion (..),
    GlobalPosition (..),
    RecordedEvent (..),
    StreamName (..),
    StreamVersion (..),
 )
import OpenTelemetry.Attributes.Key (unkey)
import OpenTelemetry.SemanticConventions (db_system_name, error_type)
import OpenTelemetry.Trace.Core (Span, SpanStatus (..), Tracer, addAttribute, setStatus)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Streamly
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified

{- | The outcome of a successfully handled command.

Reports the target 'Stream', the stream version after the command, the global
log position only when this command appended and the store assigned a real
one, and how many events were appended. A no-op reports @0@ events and
@Nothing@ for its global position because per-stream reads cannot recover a
true global position.
-}
data CommandResult target = CommandResult
    { target :: !(Stream target)
    , streamVersion :: !StreamVersion
    , globalPosition :: !(Maybe GlobalPosition)
    -- ^ 'Just' only when this command appended; 'Nothing' for a no-op.
    , eventsAppended :: !Int
    }
    deriving stock (Generic, Eq, Show)

-- | Why a command did not complete.
data CommandError
    = -- | A stored event could not be decoded while rehydrating the aggregate.
      HydrationDecodeFailed !CodecError
    | {- | Replay of the stored events through the transducer stalled. The
      version identifies the failing stored event; for
      'HydrationTruncatedChain' it identifies the last stored event, after
      which the expected multi-event chain remained incomplete.
      -}
      HydrationReplayFailed !StreamVersion !HydrationReplayReason
    | {- | Hydration observed a non-contiguous stream version. Carries the
      expected version followed by the observed version. The store writes
      contiguous versions, so this indicates that stream truncation hid
      events not covered by the hydration seed. Restore visibility with
      @clearStreamTruncateBefore@ or provide a covering snapshot before
      retrying the command.
      -}
      HydrationGapDetected !StreamVersion !StreamVersion
    | -- | No transducer edge matched the command in the hydrated state.
      CommandRejected
    | {- | Two or more transducer edges matched the command in the hydrated
      state. This is a deterministic aggregate-definition bug rather than a
      business rejection; the list contains the zero-based matched edge
      indices in declaration order.
      -}
      CommandAmbiguous ![Int]
    | -- | An emitted event could not be encoded for append.
      EncodeFailed !CodecError
    | -- | The underlying store rejected the append.
      StoreFailed !StoreError
    | {- | Optimistic-concurrency retries were exhausted (carries the total
      attempts made and the last store error).
      -}
      RetryExhausted !Int !StoreError
    | {- | Retrying after a 'StreamAlreadyExists' conflict re-observed the same
      stream version: the store says the stream exists but reading it shows no
      progress. The typical cause is a soft-deleted stream, where reads return
      nothing but appends still collide. Carries the observed version and the
      conflict.
      -}
      ConflictFixpoint !StreamVersion !StoreError
    deriving stock (Generic, Eq, Show)

{- | Why replay of stored events stalled, projected from keiki's structured
failure types onto a monomorphic vocabulary suitable for 'CommandError'.
-}
data HydrationReplayReason
    = -- | No edge's first output template could have produced the event.
      HydrationNoInvertingEdge
    | -- | More than one edge could have produced the event.
      HydrationAmbiguousInversion
    | -- | An event did not match the next expected event in a chain.
      HydrationQueueMismatch
    | -- | The stream ended in the middle of a multi-event chain.
      HydrationTruncatedChain
    deriving stock (Generic, Eq, Show)

{- | Knobs controlling a single command invocation.

* 'retryLimit' — how many times to rehydrate-and-replay after an
  optimistic-concurrency conflict before giving up with 'RetryExhausted'.
* 'pageSize' — batch size when reading the stream during hydration.
* 'eventIds' — caller-supplied ids assigned to the emitted events in order;
  the basis for deterministic, idempotent appends (see 'Keiro.Router' and
  'Keiro.ProcessManager').
* 'beforeAppend' — a hook run immediately before each append attempt,
  primarily a test seam for injecting concurrent writes.
* 'retryBackoffMicros' — base delay before the k-th OCC retry, capped at
  100 ms and jittered. Set to 0 to disable backoff.
* 'metrics' — optional metrics handle for command and snapshot counters.
* 'verifyReplayOnAppend' — replay every just-appended batch from the
  pre-command state. Divergence is a post-commit advisory: it is counted and
  attached to the command span, but the already-successful command still
  succeeds. Snapshot-enabled streams always run the fold because snapshots
  consume its result.
-}
data RunCommandOptions = RunCommandOptions
    { retryLimit :: !Int
    , pageSize :: !Int32
    , eventIds :: ![EventId]
    , beforeAppend :: !(IO ())
    , retryBackoffMicros :: !Int
    , metrics :: !(Maybe KeiroMetrics)
    , verifyReplayOnAppend :: !Bool
    , tracer :: !(Maybe Tracer)
    {- ^ Optional OpenTelemetry tracer. When 'Just', the command runner
    opens an 'Internal'-kind span around each invocation, named after
    the resolved stream identifier and decorated with the messaging /
    error semantic-conventions attributes audited in
    'docs/research/opentelemetry-semconv-audit.md'. When 'Nothing',
    the runner emits no spans.
    -}
    , metadata :: !(Maybe Value)
    {- ^ Optional JSON merged into every event's metadata for this command
    invocation. Carries ambient context such as actor type, agent id,
    and session id. The codec always adds a @schemaVersion@ key; the
    keys here are merged on top (see 'Keiro.Codec.metadataFor'). When
    'Nothing', events carry only the schema-version marker, exactly as
    before this field existed.
    -}
    }
    deriving stock (Generic)

{- | Sensible defaults: 3 retries, 256-event read pages, no caller-assigned
event ids, a no-op pre-append hook, 5ms retry backoff, no metrics, post-append
replay verification enabled, no tracer, and no extra metadata.
-}
defaultRunCommandOptions :: RunCommandOptions
defaultRunCommandOptions =
    RunCommandOptions
        { retryLimit = 3
        , pageSize = 256
        , eventIds = []
        , beforeAppend = pure ()
        , retryBackoffMicros = 5000
        , metrics = Nothing
        , verifyReplayOnAppend = True
        , tracer = Nothing
        , metadata = Nothing
        }

data Hydrated rs s = Hydrated
    { state :: !s
    , registers :: !(RegFile rs)
    , streamVersion :: !StreamVersion
    }
    deriving stock (Generic)

data CommandPlan target rs s co
    = CommandNoOp !(CommandResult target)
    | CommandAppend !(Hydrated rs s) ![co] ![EventData]
    deriving stock (Generic)

hydrate ::
    forall phi rs s ci co es.
    (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
    RunCommandOptions ->
    EventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    Eff es (Either CommandError (Hydrated rs s))
hydrate options eventStream targetStream =
    snapshotSeed >>= \case
        Nothing -> hydrateFull options eventStream targetStream
        Just seed -> do
            replayed <-
                hydrateSeeded
                    options
                    eventStream
                    targetStream
                    (seed ^. #state)
                    (seed ^. #registers)
                    (seed ^. #streamVersion)
            case replayed of
                Left _ -> hydrateFull options eventStream targetStream
                Right hydrated -> pure (Right hydrated)
  where
    snapshotSeed =
        case eventStream ^. #stateCodec of
            Nothing -> pure Nothing
            Just codec -> do
                lookupSnapshotSeed ((eventStream ^. #resolveStreamName) targetStream) codec >>= \case
                    SnapshotHit seed -> do
                        recordSnapshotReadHits (options ^. #metrics) 1
                        pure (Just seed)
                    SnapshotUnavailable reason -> do
                        recordSnapshotReadMisses (options ^. #metrics) 1
                        case reason of
                            SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1
                            _ -> pure ()
                        pure Nothing

hydrateFull ::
    forall phi rs s ci co es.
    (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
    RunCommandOptions ->
    EventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    Eff es (Either CommandError (Hydrated rs s))
hydrateFull options eventStream targetStream =
    hydrateSeeded
        options
        eventStream
        targetStream
        (eventStream ^. #initialState)
        (eventStream ^. #initialRegisters)
        (StreamVersion 0)

{- | Replay a stored stream from an arbitrary snapshot or initial-state seed.

The store stream is grouped into bounded lists and each decoded prefix is
handed to keiki's 'Keiki.replayEvents'. Decoding stops at the first bad event in
a group, but the valid prefix is replayed first so an earlier replay failure
retains precedence over a later codec failure.
-}
hydrateSeeded ::
    forall phi rs s ci co es.
    (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
    RunCommandOptions ->
    EventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    s ->
    RegFile rs ->
    StreamVersion ->
    Eff es (Either CommandError (Hydrated rs s))
hydrateSeeded options eventStream targetStream seedState seedRegisters seedVersion = do
    replayed <-
        Streamly.fold
            (Fold.foldlM' replayPage (pure (Right initialReplay)))
            recordedPages
    pure (finishReplay replayed)
  where
    readPageSize = Prelude.max 1 (options ^. #pageSize)
    groupSize = Prelude.fromIntegral readPageSize
    recordedPages =
        Streamly.foldMany
            (Fold.take groupSize Fold.toList)
            (readStreamForwardStream resolvedName seedVersion readPageSize)
    resolvedName = (eventStream ^. #resolveStreamName) targetStream
    initialReplay = (Keiki.Settled seedState, seedRegisters, Nothing)

    replayPage ::
        Either CommandError (Keiki.InFlight s co, RegFile rs, Maybe RecordedEvent) ->
        [RecordedEvent] ->
        Eff es (Either CommandError (Keiki.InFlight s co, RegFile rs, Maybe RecordedEvent))
    replayPage (Left err) _ = pure (Left err)
    replayPage (Right (wrapper, registers, previousRecorded)) page =
        pure $ case Keiki.replayEvents (eventStream ^. #transducer) (wrapper, registers) decodedEvents of
            Left replayFailure ->
                Left (hydrationReplayError previousRecorded decodedRecorded replayFailure)
            Right (nextWrapper, nextRegisters) ->
                case pendingInputFailure of
                    Just err -> Left err
                    Nothing ->
                        Right
                            ( nextWrapper
                            , nextRegisters
                            , latestRecorded decodedRecorded previousRecorded
                            )
      where
        (decodedRecorded, decodedEvents, pendingInputFailure) = decodePrefix previousRecorded page

    decodePrefix :: Maybe RecordedEvent -> [RecordedEvent] -> ([RecordedEvent], [co], Maybe CommandError)
    decodePrefix previousRecorded = go [] [] startingVersion
      where
        startingVersion = maybe seedVersion (^. #streamVersion) previousRecorded

        go recordedAcc eventAcc lastSeen = \case
            [] -> (Prelude.reverse recordedAcc, Prelude.reverse eventAcc, Nothing)
            recorded : rest ->
                let observed = recorded ^. #streamVersion
                    expected = nextStreamVersion lastSeen
                 in if observed /= expected
                        then
                            ( Prelude.reverse recordedAcc
                            , Prelude.reverse eventAcc
                            , Just (HydrationGapDetected expected observed)
                            )
                        else case decodeRecorded (eventStream ^. #eventCodec) recorded of
                            Left err ->
                                ( Prelude.reverse recordedAcc
                                , Prelude.reverse eventAcc
                                , Just (HydrationDecodeFailed err)
                                )
                            Right event ->
                                go (recorded : recordedAcc) (event : eventAcc) observed rest

        nextStreamVersion (StreamVersion version) = StreamVersion (version Prelude.+ 1)

    hydrationReplayError ::
        Maybe RecordedEvent ->
        [RecordedEvent] ->
        Keiki.ReplayFailure s co ->
        CommandError
    hydrationReplayError previousRecorded decodedRecorded replayFailure =
        HydrationReplayFailed failureVersion (toHydrationReason (Keiki.replayFailureReason replayFailure))
      where
        failureVersion =
            maybe seedVersion (^. #streamVersion) failureRecorded
        failureRecorded =
            case Keiki.replayFailureReason replayFailure of
                Keiki.ReplayLogTruncated{} -> latestRecorded decodedRecorded previousRecorded
                Keiki.ReplayEventFailed{} ->
                    case recordedAt (Keiki.replayFailedIndex replayFailure) decodedRecorded of
                        Just recorded -> Just recorded
                        Nothing -> latestRecorded decodedRecorded previousRecorded

    finishReplay = \case
        Left err -> Left err
        Right (wrapper, finalRegisters, lastRecorded) ->
            case wrapper of
                Keiki.Settled finalState ->
                    Right
                        Hydrated
                            { state = finalState
                            , registers = finalRegisters
                            , streamVersion = maybe seedVersion (^. #streamVersion) lastRecorded
                            }
                Keiki.InFlight{} ->
                    Left
                        ( HydrationReplayFailed
                            (maybe seedVersion (^. #streamVersion) lastRecorded)
                            HydrationTruncatedChain
                        )

    toHydrationReason = \case
        Keiki.ReplayEventFailed stepFailure -> case stepFailure of
            Keiki.ReplayNoInvertingEdge{} -> HydrationNoInvertingEdge
            Keiki.ReplayAmbiguousInversions{} -> HydrationAmbiguousInversion
            Keiki.ReplayQueueMismatch{} -> HydrationQueueMismatch
        Keiki.ReplayLogTruncated{} -> HydrationTruncatedChain

    latestRecorded recorded fallback =
        case lastMaybe recorded of
            Just latest -> Just latest
            Nothing -> fallback

    lastMaybe = \case
        [] -> Nothing
        first : rest -> Just (Prelude.foldl (\_ current -> current) first rest)

    recordedAt eventIndex recorded =
        case Prelude.drop eventIndex recorded of
            found : _ -> Just found
            [] -> Nothing

{- | Hydrate the target stream, transduce the command, and append any emitted
events. Retries optimistic-concurrency conflicts up to 'retryLimit'. This
is the plain runner with no in-transaction side effects.
-}
runCommand ::
    forall phi rs s ci co es.
    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
    RunCommandOptions ->
    ValidatedEventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    ci ->
    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co)))
runCommand options validatedEventStream targetStream command =
    withCommandSpan (options ^. #tracer) (resolvedStreamName eventStream targetStream) Nothing $ \mSpan -> do
        (result, attemptNo) <- attempt mSpan 1 Nothing
        recordCommandOutcome mSpan (^. #eventsAppended) attemptNo result
        pure result
  where
    eventStream = unvalidated validatedEventStream

    attempt mSpan attemptNo lastConflict = do
        hydrated <- hydrate options eventStream targetStream
        either (\err -> pure (Left err, attemptNo)) (runPlan mSpan attemptNo lastConflict) hydrated

    runPlan mSpan attemptNo lastConflict current =
        case conflictFixpoint lastConflict (current ^. #streamVersion) of
            Just err -> pure (Left err, attemptNo)
            Nothing ->
                case prepareCommandPlan options eventStream targetStream current command of
                    Left err -> pure (Left err, attemptNo)
                    Right (CommandNoOp result) -> pure (Right result, attemptNo)
                    Right (CommandAppend current' events encoded) ->
                        appendOnce mSpan attemptNo current' events encoded

    appendOnce mSpan attemptNo current events encoded = do
        liftIO (options ^. #beforeAppend)
        appended <-
            tryError @StoreError
                $ appendToStream
                    ((eventStream ^. #resolveStreamName) targetStream)
                    (expectedVersion (current ^. #streamVersion))
                    encoded
        case appended of
            Right appendResult -> do
                verifyAndSnapshot options mSpan eventStream current events appendResult
                pure (Right (appendedResult targetStream appendResult (Prelude.length encoded)), attemptNo)
            Left (_, storeError) ->
                retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError

{- | Like 'runCommand', but run @afterAppend@ inside the /same/ transaction
as the append, so a read-model write commits atomically with the events.
The callback's result is returned as @Just@ on append (and 'Nothing' for a
no-op command that appended nothing).

Requires 'KirokuStoreResource' so the transactional append applies the
configured @enrichEvent@ hook. See 'runCommandWithSqlEvents' for the callback's
locking and latency implications.
-}
runCommandWithSql ::
    forall phi rs s ci co a es.
    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
    RunCommandOptions ->
    ValidatedEventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    ci ->
    (AppendResult -> Tx.Transaction a) ->
    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co), Maybe a))
runCommandWithSql options eventStream targetStream command afterAppend =
    runCommandWithSqlEvents options eventStream targetStream command (\_ appendResult -> afterAppend appendResult)

{- | The most general runner: like 'runCommandWithSql', but the
in-transaction callback also receives every emitted event paired with the
'RecordedEvent' the store persisted for it, in append order. Inline
projections, process managers, and routers are all built on this.

The runner requires 'KirokuStoreResource' and applies the configured
@enrichEvent@ hook before preparing the append. The callback therefore sees
the enriched metadata that was persisted.

The append updates Kiroku's global @$all@ stream and holds its PostgreSQL row
lock until this transaction commits. Every SQL operation in the callback
therefore extends the store-wide append serialization window. Keep the
callback small: precompute outside the transaction where possible, batch
writes, and minimize database round trips.
-}
runCommandWithSqlEvents ::
    forall phi rs s ci co a es.
    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
    RunCommandOptions ->
    ValidatedEventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    ci ->
    ([(co, RecordedEvent)] -> AppendResult -> Tx.Transaction a) ->
    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co), Maybe a))
runCommandWithSqlEvents options validatedEventStream targetStream command afterAppend =
    withCommandSpan (options ^. #tracer) (resolvedStreamName eventStream targetStream) Nothing $ \mSpan -> do
        (result, attemptNo) <- attempt mSpan 1 Nothing
        recordCommandOutcome mSpan (\(r, _) -> r ^. #eventsAppended) attemptNo result
        pure result
  where
    eventStream = unvalidated validatedEventStream

    attempt mSpan attemptNo lastConflict = do
        hydrated <- hydrate options eventStream targetStream
        either (\err -> pure (Left err, attemptNo)) (runPlan mSpan attemptNo lastConflict) hydrated

    runPlan mSpan attemptNo lastConflict current =
        case conflictFixpoint lastConflict (current ^. #streamVersion) of
            Just err -> pure (Left err, attemptNo)
            Nothing ->
                case prepareCommandPlan options eventStream targetStream current command of
                    Left err -> pure (Left err, attemptNo)
                    Right (CommandNoOp result) -> pure (Right (result, Nothing), attemptNo)
                    Right (CommandAppend current' events encoded) ->
                        appendWithSqlOnce mSpan attemptNo current' events encoded

    appendWithSqlOnce mSpan attemptNo current events encoded = do
        liftIO (options ^. #beforeAppend)
        store <- getKirokuStore
        enriched <- liftIO (enrichEventsIO store encoded)
        prepared <- prepareEventsIO enriched
        now <- liftIO getCurrentTime
        let streamName = (eventStream ^. #resolveStreamName) targetStream
            expected = expectedVersion (current ^. #streamVersion)
            body = do
                appended <- appendToStreamTx streamName expected prepared now
                case appended of
                    Left conflict ->
                        Tx.condemn $> Left (appendConflictToStoreError conflict)
                    Right appendResult -> do
                        let recordeds = reconstructRecorded appendResult now prepared
                        userValue <- afterAppend (Prelude.zip events recordeds) appendResult
                        pure (Right (appendResult, userValue))
        outcome <- tryError @StoreError (runTransaction body)
        case outcome of
            Right (Right (appendResult, userValue)) -> do
                verifyAndSnapshot options mSpan eventStream current events appendResult
                pure (Right (appendedResult targetStream appendResult (Prelude.length encoded), Just userValue), attemptNo)
            Right (Left storeError) ->
                retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError
            Left (_, storeError) ->
                retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError

prepareCommandPlan ::
    (BoolAlg phi (RegFile rs, ci)) =>
    RunCommandOptions ->
    EventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    Hydrated rs s ->
    ci ->
    Either CommandError (CommandPlan (EventStream phi rs s ci co) rs s co)
prepareCommandPlan options eventStream targetStream current command =
    case evaluateCommand eventStream current command of
        Left err -> Left err
        Right events -> toPlan events
  where
    toPlan [] =
        Right (CommandNoOp (noOpResult targetStream current))
    toPlan events =
        CommandAppend current events
            . assignEventIds (options ^. #eventIds)
            <$> encodeEvents (eventStream ^. #eventCodec) (options ^. #metadata) events

{- | Render the stream that the command targets as plain 'Text', for use
as a span name.
-}
resolvedStreamName ::
    EventStream phi rs s ci co ->
    Stream (EventStream phi rs s ci co) ->
    Text
resolvedStreamName eventStream targetStream =
    case (eventStream ^. #resolveStreamName) targetStream of
        StreamName n -> n

{- | Attach the command-span outcome attributes after the runner returns.

On success: 'db.system.name' and 'keiro.events.appended'.
On failure: 'error.type' (low-cardinality classifier) and span status
'Error' (carrying the rendered 'CommandError' as the description).

Pure no-op when no span is active ('Nothing' tracer, etc).
-}
recordCommandOutcome ::
    (IOE :> es) =>
    Maybe Span ->
    (a -> Int) ->
    Int ->
    Either CommandError a ->
    Eff es ()
recordCommandOutcome Nothing _ _ _ = pure ()
recordCommandOutcome (Just sp) eventsOf attemptNo result = do
    addAttribute sp (unkey db_system_name) ("postgresql" :: Text)
    addAttribute sp (unkey keiro_retry_attempt) (Prelude.fromIntegral attemptNo :: Int64)
    case result of
        Right v ->
            addAttribute sp (unkey keiro_events_appended) (Prelude.fromIntegral (eventsOf v) :: Int64)
        Left err -> do
            addAttribute sp (unkey error_type) (commandErrorClass err)
            setStatus sp (Error (Text.take 256 (Text.pack (show err))))

{- | Low-cardinality classifier for a 'CommandError'. Used as the
@error.type@ attribute value on the command span.
-}
commandErrorClass :: CommandError -> Text
commandErrorClass = \case
    HydrationDecodeFailed{} -> "hydration_decode_failed"
    HydrationReplayFailed _ HydrationNoInvertingEdge -> "hydration_replay_no_inverting_edge"
    HydrationReplayFailed _ HydrationAmbiguousInversion -> "hydration_replay_ambiguous_inversion"
    HydrationReplayFailed _ HydrationQueueMismatch -> "hydration_replay_queue_mismatch"
    HydrationReplayFailed _ HydrationTruncatedChain -> "hydration_replay_truncated_chain"
    HydrationGapDetected{} -> "hydration_gap_detected"
    CommandRejected -> "command_rejected"
    CommandAmbiguous{} -> "command_ambiguous"
    EncodeFailed{} -> "encode_failed"
    StoreFailed{} -> "store_failed"
    RetryExhausted{} -> "retry_exhausted"
    ConflictFixpoint{} -> "conflict_fixpoint"

verifyAndSnapshot ::
    forall phi rs s ci co es.
    (BoolAlg phi (RegFile rs, ci), IOE :> es, Store :> es, Error StoreError :> es, Eq co) =>
    RunCommandOptions ->
    Maybe Span ->
    EventStream phi rs s ci co ->
    Hydrated rs s ->
    [co] ->
    AppendResult ->
    Eff es ()
verifyAndSnapshot options mSpan eventStream current events appendResult
    | Prelude.not (options ^. #verifyReplayOnAppend)
    , Nothing <- eventStream ^. #stateCodec =
        pure ()
    | otherwise =
        case Keiki.applyEventsEither (eventStream ^. #transducer) (state current, registers current) events of
            Left failure -> do
                recordSnapshotApplyDivergence (options ^. #metrics) 1
                for_ mSpan $ \sp ->
                    addAttribute
                        sp
                        (unkey keiro_replay_divergence)
                        (Text.take 256 (renderReplayFailure failure))
            Right finalState ->
                case eventStream ^. #stateCodec of
                    Nothing -> pure ()
                    Just codec -> do
                        let finalVersion = appendResult ^. #streamVersion
                            terminality =
                                if Keiki.isFinal (eventStream ^. #transducer) (Prelude.fst finalState)
                                    then Terminal
                                    else NotTerminal
                        when (shouldSnapshotSpan (eventStream ^. #snapshotPolicy) terminality finalState (current ^. #streamVersion) finalVersion)
                            $ do
                                encoded <- liftIO (encodeSnapshotStrict codec finalState)
                                case encoded of
                                    Left _ -> recordSnapshotEncodeFailures (options ^. #metrics) 1
                                    Right value -> do
                                        outcome <- tryError @StoreError (writeSnapshotEncoded (appendResult ^. #streamId) finalVersion codec value)
                                        case outcome of
                                            Right () -> pure ()
                                            Left _ -> recordSnapshotWriteFailures (options ^. #metrics) 1

renderReplayFailure :: Keiki.ReplayFailure s co -> Text
renderReplayFailure failure =
    "event_index="
        <> Text.pack (show (Keiki.replayFailedIndex failure))
        <> ";reason="
        <> case Keiki.replayFailureReason failure of
            Keiki.ReplayEventFailed stepFailure -> case stepFailure of
                Keiki.ReplayNoInvertingEdge{} -> "no_inverting_edge"
                Keiki.ReplayAmbiguousInversions{} -> "ambiguous_inversions"
                Keiki.ReplayQueueMismatch{} -> "queue_mismatch"
            Keiki.ReplayLogTruncated{} -> "log_truncated"

retryOrFail ::
    (IOE :> es) =>
    RunCommandOptions ->
    (Int -> Maybe (StoreError, StreamVersion) -> Eff es (Either CommandError a, Int)) ->
    Int ->
    StreamVersion ->
    StoreError ->
    Eff es (Either CommandError a, Int)
retryOrFail options retry attemptNo observedVersion storeError
    | isRetryableConflict storeError
    , attemptNo <= options ^. #retryLimit = do
        recordCommandConflicts (options ^. #metrics) 1
        backoffDelay options attemptNo
        recordCommandRetries (options ^. #metrics) 1
        retry (attemptNo Prelude.+ 1) (Just (storeError, observedVersion))
    | isRetryableConflict storeError = do
        recordCommandConflicts (options ^. #metrics) 1
        pure (Left (RetryExhausted attemptNo storeError), attemptNo)
    | otherwise = do
        case storeError of
            DuplicateEvent{} -> recordCommandDuplicates (options ^. #metrics) 1
            _ -> pure ()
        pure (Left (StoreFailed storeError), attemptNo)

backoffDelay :: (IOE :> es) => RunCommandOptions -> Int -> Eff es ()
backoffDelay options attemptNo
    | base <= 0 = pure ()
    | otherwise = do
        nanos <- liftIO getMonotonicTimeNSec
        let exponential = min 100000 (base Prelude.* (2 Prelude.^ (attemptNo Prelude.- 1 :: Int)))
            jitter =
                Prelude.fromIntegral (nanos `Prelude.mod` Prelude.fromIntegral exponential)
                    Prelude.- (exponential `Prelude.div` 2)
        liftIO (threadDelay (max 0 (exponential Prelude.+ jitter)))
  where
    base = options ^. #retryBackoffMicros

conflictFixpoint :: Maybe (StoreError, StreamVersion) -> StreamVersion -> Maybe CommandError
conflictFixpoint (Just (previousError@StreamAlreadyExists{}, previousVersion)) currentVersion
    | currentVersion == previousVersion = Just (ConflictFixpoint currentVersion previousError)
conflictFixpoint _ _ = Nothing

evaluateCommand ::
    (BoolAlg phi (RegFile rs, ci)) =>
    EventStream phi rs s ci co ->
    Hydrated rs s ->
    ci ->
    Either CommandError [co]
evaluateCommand eventStream current command =
    case Keiki.stepEither (eventStream ^. #transducer) (state current, registers current) command of
        Left Keiki.NoOutgoingEdges{} -> Left CommandRejected
        Left Keiki.NoMatchingEdge{} -> Left CommandRejected
        Left (Keiki.AmbiguousEdges _ matches) ->
            Left
                ( CommandAmbiguous
                    [ Keiki.edgeIndex (Keiki.matchedEdge matched)
                    | matched <- matches
                    ]
                )
        Right (_, _, events) -> Right events

encodeEvents :: Codec co -> Maybe Value -> [co] -> Either CommandError [EventData]
encodeEvents codec md =
    Prelude.mapM (mapLeft EncodeFailed . encodeForAppendWithMetadata codec md)

assignEventIds :: [EventId] -> [EventData] -> [EventData]
assignEventIds [] events = events
assignEventIds _ [] = []
assignEventIds (supplied : suppliedRest) (event : eventRest) =
    (event & #eventId .~ Just supplied) : assignEventIds suppliedRest eventRest

expectedVersion :: StreamVersion -> ExpectedVersion
expectedVersion (StreamVersion 0) = NoStream
expectedVersion version = ExactVersion version

noOpResult ::
    Stream target ->
    Hydrated rs s ->
    CommandResult target
noOpResult targetStream current =
    CommandResult
        { target = targetStream
        , streamVersion = current ^. #streamVersion
        , globalPosition = Nothing
        , eventsAppended = 0
        }

appendedResult ::
    Stream target ->
    AppendResult ->
    Int ->
    CommandResult target
appendedResult targetStream appendResult count =
    CommandResult
        { target = targetStream
        , streamVersion = appendResult ^. #streamVersion
        , globalPosition = Just (appendResult ^. #globalPosition)
        , eventsAppended = count
        }

{- | Rebuild the per-event 'RecordedEvent' values for a just-appended batch.

The store assigns each event in a batch a contiguous stream version and
global position: event @i@ (1-based) gets @last - count + i@ for both
counters, where @last@ is the position the 'AppendResult' reports for the
final event and @count@ is the batch size. (The kiroku append SQL numbers
events with @WITH ORDINALITY@ and inserts @initial + idx@; see EP-27's
Surprises & Discoveries.) We therefore reconstruct each 'RecordedEvent'
exactly, rather than reading the batch back. The @createdAt@ is the same
timestamp 'prepareEventsIO'/'appendToStreamTx' used for the insert.

This is a source append (events are written to their own stream), so
@streamVersion == originalVersion@ and @originalStreamId@ is the appended
stream's id, per the 'RecordedEvent' contract.
-}
reconstructRecorded :: AppendResult -> UTCTime -> [PreparedEvent] -> [RecordedEvent]
reconstructRecorded appendResult now prepared =
    Prelude.zipWith mk [0 ..] prepared
  where
    count = Prelude.length prepared
    StreamVersion lastSv = appendResult ^. #streamVersion
    GlobalPosition lastGp = appendResult ^. #globalPosition
    firstSv = lastSv Prelude.- Prelude.fromIntegral count Prelude.+ 1
    firstGp = lastGp Prelude.- Prelude.fromIntegral count Prelude.+ 1
    mk :: Int64 -> PreparedEvent -> RecordedEvent
    mk i prepared' =
        RecordedEvent
            { eventId = EventId (prepared' ^. #peEventId)
            , eventType = prepared' ^. #peEventType
            , streamVersion = StreamVersion (firstSv Prelude.+ i)
            , globalPosition = GlobalPosition (firstGp Prelude.+ i)
            , originalStreamId = appendResult ^. #streamId
            , originalVersion = StreamVersion (firstSv Prelude.+ i)
            , payload = prepared' ^. #pePayload
            , metadata = prepared' ^. #peMetadata
            , causationId = prepared' ^. #peCausationId
            , correlationId = prepared' ^. #peCorrelationId
            , createdAt = now
            }

isRetryableConflict :: StoreError -> Bool
isRetryableConflict = \case
    WrongExpectedVersion{} -> True
    StreamAlreadyExists{} -> True
    _ -> False

mapLeft :: (e -> e') -> Either e a -> Either e' a
mapLeft f = \case
    Left err -> Left (f err)
    Right value -> Right value