packages feed

keiro-0.2.0.0: src/Keiro/ProcessManager.hs

{- | Stateful coordination across aggregates: the process manager (saga).

A 'ProcessManager' reacts to an incoming event by stepping its /own/ state
machine — a private \"manager\" event stream, keyed by a correlation id — and,
in the same turn, dispatching commands to /target/ aggregates and scheduling
timers. It is the stateful counterpart of "Keiro.Router": where a router
resolves targets from a read model and holds no state, a process manager
folds the events it has seen into durable manager state and decides what to
do next from that state.

Every write — the manager-state append, each dispatched command, each timer
— is keyed by a 'deterministicCommandId' derived from
@(name, correlation, source event id, emit index)@. The manager pre-checks
each id with 'eventAlreadyIn' and folds the store's duplicate rejection into
a benign 'PMCommandDuplicate' \/ 'PMStateDuplicate'. Replaying the same
source event therefore appends nothing new, which is what makes the worker
crash-safe under at-least-once delivery.

Use 'runProcessManagerOnce' to react to a single event, or
'runProcessManagerWorker' to run the manager as a live subscription over a
Shibuya adapter. Worker acks are finalized exactly once: successful and duplicate
dispatches ack 'AckOk', transient store failures retry, systemic deterministic
failures halt, rejection-class failures follow 'RejectedCommandPolicy', and
undecodable messages follow the configured 'PoisonPolicy'.

=== Rejected commands and saga history

'RejectedHalt' is the safe default: the subscription stops without advancing,
so an operator cannot miss the failure. 'RejectedDeadLetter' instead writes a
durable "Keiro.DeadLetter.DispatchDeadLetter" and acknowledges the source
event; 'RejectedSkip' acknowledges and records only the metric. Prefer making a
target command total in its Keiki transducer (for example, an explicit no-op
transition for a benign business rejection) so no policy escape hatch is
needed.

For a process manager, dead-lettering a target dispatch creates an important
history split: the manager's own state stream has recorded its reaction, but
the target command never applied. Keiro cannot append a generic correction to
the manager stream because an event unknown to the manager's transducer would
make that stream fail replay. If saga history must reflect the failure, model a
domain-specific @DispatchFailed@-style command and event in the manager and
drive it from 'Keiro.DeadLetter.listDispatchDeadLetters' through an operator
runbook or automation. Otherwise, the dead-letter row is the durable witness.
Manager-state rejection itself has no such split because no manager event was
appended; its record uses emit index @-1@.

=== Bounded retries and source-event dead letters

On a Kiroku-backed Shibuya adapter, a transient failure finalizes 'AckRetry',
but retries are bounded by the Kiroku subscription's @RetryPolicy@. Its
@retryMaxAttempts@ counts total deliveries and defaults to five. When the bound
is exhausted, Kiroku records the /source event/ in @kiroku.dead_letters@ with
structured reason kind @max_attempts_exceeded@ and atomically advances the
checkpoint. The manager will not see that event again unless an operator
replays it through @Keiro.DeadLetter.Replay@. Install
'Keiro.Telemetry.kirokuEventBridge' on Kiroku's @eventHandler@ to observe the
terminal transition.

The adapter's @KirokuAdapterConfig@ does not currently expose @retryPolicy@, so
that path uses Kiroku's default bound. The sharded path is configurable:
'Keiro.Subscription.Shard.Worker.runShardedSubscriptionGroupAck' forwards
'Keiro.Subscription.Shard.Worker.ShardedWorkerOptions.retryPolicy' into the
same Kiroku acknowledgement ladder. A shard handler that dispatches commands
can use 'decideForFailures' and 'isRejectionClass' to classify outcomes, then
map the decision to its existing @ShardAck@ reply.

=== Correlation, ordering, and transaction boundaries

Events from one originating stream retain that stream's append order. Kiroku
consumer groups hash the originating stream id to a stable member, so sharding
does not split one stream across readers. Events from /different/ streams that
'correlate' to the same manager instance have no business-order guarantee,
however. Their append transactions race for global positions and, under
sharding, the streams may be processed concurrently by different members. A
retry on one member does not stop another member from advancing.

For example, both @payment-ORD1@'s @PaymentCaptured@ and @shipment-ORD1@'s
@ShipmentAllocated@ may correlate to order @ORD1@. The manager must accept
@PaymentCaptured@ then @ShipmentAllocated@ /and/ the reverse, normally with
states that record one fact while waiting for the other. An unsharded Kiroku
subscription processes its observed global order serially, including retries,
but that order still reflects append timing rather than a domain sequence.

Rule of thumb: 'correlate' may join streams freely, but every such join must be
order-insensitive. When a strict sequence is required, enforce it in the
manager's own state machine — for example with an explicit no-op/waiting state
and a timer-driven retry, or a modeled rejection handled by the dead-letter
policy — never by assuming delivery order.

The persistence boundary is also intentionally smaller than one whole saga
reaction. A manager event and its timers commit together when the manager
command appends; a timer-only no-op reaction schedules its timers in a separate
transaction. Each target command and its inline projections then commit in
their own transaction. A crash or rejected target can therefore leave durable
manager history without every target write. Deterministic ids make source-event
replay fill the missing writes without duplicating completed ones; the rejected
command section above describes the case that is deliberately acknowledged
instead.
-}
module Keiro.ProcessManager (
    -- * Definition
    ProcessManager (..),
    ProcessManagerAction (..),
    PMCommand (..),

    -- * Results
    ProcessManagerResult (..),
    PMCommandResult (..),
    PMStateResult (..),

    -- * Running
    PoisonPolicy (..),
    RejectedCommandPolicy (..),
    DispatchFailure (..),
    WorkerOptions (..),
    defaultWorkerOptions,
    isTransientStoreError,
    isTransientCommandError,
    isRejectionClass,
    decideForFailures,
    ackForCommandError,
    runProcessManagerOnce,
    runProcessManagerWorkerWith,
    runProcessManagerWorker,

    -- * Idempotency primitives
    deterministicCommandId,
    eventAlreadyIn,
    confirmBenignDuplicate,
)
where

import Data.Coerce (coerce)
import Data.Text qualified as Text
import Data.UUID qualified as UUID
import Data.UUID.V5 qualified as UUID.V5
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error, tryError)
import GHC.Stack (HasCallStack)
import Keiki.Core (BoolAlg, RegFile)
import Keiro.Command (CommandError (..), CommandResult, RunCommandOptions, commandErrorClass, runCommandWithSql)
import Keiro.DeadLetter (DispatchDeadLetter (..), DispatcherKind (..), recordDispatchDeadLetter)
import Keiro.EventStream (EventStream)
import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)
import Keiro.Prelude
import Keiro.Projection (InlineProjection, runCommandWithProjections)
import Keiro.Stream (Stream)
import Keiro.Telemetry (KeiroMetrics, recordDispatchDeadLettered, recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)
import Keiro.Timer (TimerRequest, scheduleTimerTx)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError (..))
import Kiroku.Store.Read (eventExistsInStream)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..), RecordedEvent)
import Kiroku.Store.Types qualified as StoreTypes
import Shibuya.Adapter (Adapter (..))
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..))
import Shibuya.Core.Types (Attempt (..), Envelope (..))
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Streamly
import Prelude (any, filter, fromIntegral, length, not, uncurry, zip, (&&), (+))

{- | A process manager wiring together a manager state machine and the target
aggregate it drives.

* 'name' — stable identity; part of every deterministic write id.
* 'correlate' — derives the correlation key for an input event, selecting
  which manager instance handles it.
* 'eventStream' — the manager's own 'EventStream'; its events record the
  saga's progress.
* 'streamFor' — maps a correlation key to the manager's 'Stream' handle.
* 'targetEventStream' — the aggregate that dispatched commands are sent to.
* 'targetProjections' — inline projections for the target aggregate, run in
  the same transaction as each dispatched command's append. Return @[]@ for
  append-only dispatch.
* 'handle' — the pure reaction: given an input event, produce the
  manager-state command, the target commands to dispatch, and any timers to
  schedule.
-}
data ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo = ProcessManager
    { name :: !Text
    , correlate :: !(input -> Text)
    , eventStream :: !(ValidatedEventStream phi rs s ci co)
    , streamFor :: !(Text -> Stream (EventStream phi rs s ci co))
    , targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo)
    , targetProjections :: !(Stream targetCi -> [InlineProjection targetCo])
    {- ^ Inline projections for the target aggregate, run in the same transaction
    as each dispatched command's append. Return @[]@ for append-only dispatch.
    -}
    , handle :: !(input -> ProcessManagerAction ci targetCi)
    }
    deriving stock (Generic)

{- | What a process manager decides to do for one input event: advance its own
state with 'command', dispatch zero or more target 'commands', and schedule
zero or more 'timers'. All three are applied atomically with crash-safe
idempotency by 'runProcessManagerOnce'.
-}
data ProcessManagerAction ci targetCi = ProcessManagerAction
    { command :: !ci
    , commands :: ![PMCommand targetCi]
    , timers :: ![TimerRequest]
    }
    deriving stock (Generic)

-- | A single command addressed to a specific target stream.
data PMCommand targetCi = PMCommand
    { target :: !(Stream targetCi)
    , command :: !targetCi
    }
    deriving stock (Generic, Eq, Show)

-- | Outcome of one dispatched target command.
data PMCommandResult target
    = -- | The command appended events (carries the 'CommandResult').
      PMCommandAppended !(CommandResult target)
    | {- | The command was already applied (idempotent replay); carries the
      deterministic id that already existed.
      -}
      PMCommandDuplicate !EventId
    | {- | The command failed in the named target stream; the worker classifies
      the error. Transient failures retry, rejection-class failures follow
      'RejectedCommandPolicy', and systemic deterministic failures halt.
      -}
      PMCommandFailed !StoreTypes.StreamName !CommandError
    deriving stock (Generic, Eq, Show)

{- | Outcome of the manager's own state append. Unlike 'PMCommandResult' there
is no failure case — a manager-state append that genuinely errors aborts the
whole reaction via an outer @Left@ 'CommandError'.
-}
data PMStateResult target
    = PMStateAppended !(CommandResult target)
    | PMStateDuplicate !EventId
    deriving stock (Generic, Eq, Show)

{- | The complete result of reacting to one event: how the manager state
advanced, the outcome of each dispatched command in order, and how many
timers were scheduled.
-}
data ProcessManagerResult managerTarget commandTarget = ProcessManagerResult
    { managerResult :: !(PMStateResult managerTarget)
    , commandResults :: ![PMCommandResult commandTarget]
    , timersScheduled :: !Int
    }
    deriving stock (Generic, Eq, Show)

-- | What a worker does with a message its decoder cannot parse.
data PoisonPolicy es msg
    = PoisonHalt
    | PoisonSkip !(Envelope msg -> Eff es ())
    | PoisonDeadLetter !(Envelope msg -> Eff es ())

-- | What a worker does when every failed dispatch is a rejection-class error.
data RejectedCommandPolicy
    = -- | Halt without acknowledging so the source event replays. This is the default.
      RejectedHalt
    | -- | Persist a durable dispatch dead letter and acknowledge the source event.
      RejectedDeadLetter
    | -- | Acknowledge and count the rejection without persisting a record.
      RejectedSkip
    deriving stock (Generic, Eq, Show)

-- | One failed dispatch with the target identity needed by worker policy.
data DispatchFailure = DispatchFailure
    { emitIndex :: !Int
    , targetStreamName :: !StoreTypes.StreamName
    , commandError :: !CommandError
    }
    deriving stock (Generic, Eq, Show)

-- | Worker-level knobs shared by the process-manager and router workers.
data WorkerOptions es msg = WorkerOptions
    { poisonPolicy :: !(PoisonPolicy es msg)
    , rejectedCommandPolicy :: !RejectedCommandPolicy
    , transientRetryDelay :: !RetryDelay
    , metrics :: !(Maybe KeiroMetrics)
    }
    deriving stock (Generic)

defaultWorkerOptions :: WorkerOptions es msg
defaultWorkerOptions =
    WorkerOptions
        { poisonPolicy = PoisonHalt
        , rejectedCommandPolicy = RejectedHalt
        , transientRetryDelay = RetryDelay 5
        , metrics = Nothing
        }

isTransientStoreError :: StoreError -> Bool
isTransientStoreError = \case
    ConnectionLost{} -> True
    PoolAcquisitionTimeout -> True
    ConnectionError{} -> True
    WrongExpectedVersion{} -> True
    StreamAlreadyExists{} -> True
    EmptyAppendBatch{} -> False
    StreamNotFound{} -> False
    ReservedStreamName{} -> False
    StreamNameTooLong{} -> False
    DuplicateEvent{} -> False
    EventAlreadyLinked{} -> False
    LinkSourceEventMissing{} -> False
    UnexpectedServerError{} -> False

isTransientCommandError :: CommandError -> Bool
isTransientCommandError = \case
    StoreFailed err -> isTransientStoreError err
    RetryExhausted _ err -> isTransientStoreError err
    ConflictFixpoint _ err -> isTransientStoreError err
    HydrationDecodeFailed{} -> False
    HydrationReplayFailed{} -> False
    HydrationGapDetected{} -> False
    CommandRejected -> False
    CommandAmbiguous{} -> False
    EncodeFailed{} -> False

-- | Whether a command error is a per-command rejection covered by worker policy.
isRejectionClass :: CommandError -> Bool
isRejectionClass = \case
    CommandRejected -> True
    CommandAmbiguous{} -> True
    _ -> False

{- | Classify a group of failed dispatches and choose one acknowledgement.

Systemic deterministic errors always halt. Any transient error retries the
whole source event. Only an all-rejection group reaches the configured
'RejectedCommandPolicy'. Dead-letter writes are idempotent under redelivery.
-}
decideForFailures ::
    (IOE :> es, Store :> es) =>
    WorkerOptions es msg ->
    DispatcherKind ->
    Text ->
    Text ->
    RecordedEvent ->
    Int ->
    [DispatchFailure] ->
    Eff es AckDecision
decideForFailures workerOptions dispatcherKind dispatcherName correlationId sourceEvent attemptCount failures =
    case filter isSystemicDeterministic failures of
        failure : _ -> pure (haltFor failure)
        []
            | any (isTransientCommandError . (^. #commandError)) failures ->
                pure (AckRetry (workerOptions ^. #transientRetryDelay))
            | otherwise ->
                case failures of
                    [] -> pure AckOk
                    _ -> decideRejected
  where
    isSystemicDeterministic failure =
        let err = failure ^. #commandError
         in not (isTransientCommandError err) && not (isRejectionClass err)

    haltFor failure =
        AckHalt (HaltFatal (Text.pack (show (failure ^. #commandError))))

    decideRejected =
        case workerOptions ^. #rejectedCommandPolicy of
            RejectedHalt -> pure (haltFor (headFailure failures))
            RejectedDeadLetter -> do
                traverse_ recordFailure failures
                recordHandled
                pure AckOk
            RejectedSkip -> do
                recordHandled
                pure AckOk

    recordHandled =
        recordDispatchDeadLettered
            (workerOptions ^. #metrics)
            (fromIntegral (length failures))

    recordFailure failure =
        let err = failure ^. #commandError
         in recordDispatchDeadLetter
                DispatchDeadLetter
                    { dispatcherKind = dispatcherKind
                    , dispatcherName = dispatcherName
                    , correlationId = correlationId
                    , sourceEventId = sourceEvent ^. #eventId
                    , sourceGlobalPosition = sourceEvent ^. #globalPosition
                    , emitIndex = failure ^. #emitIndex
                    , targetStreamName = failure ^. #targetStreamName
                    , errorClass = commandErrorClass err
                    , errorDetail = Text.pack (show err)
                    , attemptCount = max 1 attemptCount
                    }

    headFailure = \case
        failure : _ -> failure
        [] -> DispatchFailure (-1) (StoreTypes.StreamName "unknown") CommandRejected

ackForCommandError :: RetryDelay -> CommandError -> AckDecision
ackForCommandError delay err
    | isTransientCommandError err = AckRetry delay
    | otherwise = AckHalt (HaltFatal (Text.pack (show err)))

{- | Derive a stable, collision-resistant 'EventId' for a manager write from
@(manager name, correlation id, source event id, emit index)@ via a v5 UUID.

The same inputs always yield the same id, so a replayed source event
produces the same write ids and the store's uniqueness constraint collapses
the duplicate. The manager-state append uses an emit index of @-1@ to keep
it distinct from the dispatched commands (which start at @0@). This positional
index is sound because 'handle' is pure and therefore returns the same command
order for the same input. The effectful router uses
'Keiro.Router.deterministicRouterCommandId' instead, retaining this positional
id only as a transition probe for pre-upgrade router dispatches.
-}
deterministicCommandId :: Text -> Text -> EventId -> Int -> EventId
deterministicCommandId managerName correlationId sourceEventId emitIndex =
    EventId
        $ UUID.V5.generateNamed UUID.V5.namespaceURL
        $ fmap (fromIntegral . fromEnum)
        $ Text.unpack
        $ Text.intercalate
            ":"
            [ "keiro"
            , "process-manager"
            , managerName
            , correlationId
            , UUID.toText (eventIdToUuid sourceEventId)
            , Text.pack (show emitIndex)
            ]

{- | React to a single source event: advance the manager's state, dispatch
its target commands, and schedule its timers — each under a deterministic,
idempotent write id.

The manager-state append and its timers commit in one transaction; each
target command is then dispatched (with its inline projections) in its own.
A duplicate manager append short-circuits to 'PMStateDuplicate' but still
re-runs the dispatch loop, so a crash between the state append and a
command dispatch is recovered on replay. Returns @Left@ only when the
manager-state append fails for a non-duplicate reason; per-command failures
are reported inside 'commandResults'.
-}
runProcessManagerOnce ::
    forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.
    ( HasCallStack
    , IOE :> es
    , Store :> es
    , Error StoreError :> es
    , KirokuStoreResource :> es
    , BoolAlg phi (RegFile rs, ci)
    , BoolAlg targetPhi (RegFile targetRs, targetCi)
    , Eq co
    , Eq targetCo
    ) =>
    RunCommandOptions ->
    ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->
    RecordedEvent ->
    input ->
    Eff es (Either CommandError (ProcessManagerResult (EventStream phi rs s ci co) (EventStream targetPhi targetRs targetState targetCi targetCo)))
runProcessManagerOnce options manager sourceEvent input = do
    let correlationId = (manager ^. #correlate) input
        action = (manager ^. #handle) input
        managerStream = (manager ^. #streamFor) correlationId
        managerEventId = deterministicCommandId (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)
        managerOptions = options & #eventIds .~ [managerEventId]
        managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream
    managerAlreadyProcessed <- eventAlreadyIn options managerStreamName managerEventId
    if managerAlreadyProcessed
        then finish correlationId (PMStateDuplicate managerEventId) action
        else do
            managerOutcome <-
                runCommandWithSql
                    managerOptions
                    (manager ^. #eventStream)
                    managerStream
                    (action ^. #command)
                    (\_ -> traverse_ scheduleTimerTx (action ^. #timers))
            case managerOutcome of
                Left err -> do
                    benign <- confirmBenignDuplicate managerStreamName managerEventId err
                    if benign
                        then finish correlationId (PMStateDuplicate managerEventId) action
                        else pure (Left err)
                Right (managerResult, scheduledInAppend) -> do
                    -- No-op manager commands do not execute runCommandWithSql's callback,
                    -- so schedule timer-only reactions explicitly.
                    case scheduledInAppend of
                        Nothing -> runTransaction (traverse_ scheduleTimerTx (action ^. #timers))
                        Just () -> pure ()
                    finish correlationId (PMStateAppended managerResult) action
  where
    finish correlationId managerResult action = do
        commandResults <- dispatchCommands correlationId (sourceEvent ^. #eventId) (action ^. #commands)
        pure
            $ Right
                ProcessManagerResult
                    { managerResult = managerResult
                    , commandResults = commandResults
                    , timersScheduled = length (action ^. #timers)
                    }

    dispatchCommands correlationId sourceEventId commands =
        traverse
            (uncurry (dispatchCommand correlationId sourceEventId))
            (zip [0 ..] commands)

    dispatchCommand correlationId sourceEventId emitIndex command = do
        let commandId = deterministicCommandId (manager ^. #name) correlationId sourceEventId emitIndex
            targetOptions = options & #eventIds .~ [commandId]
            targetStream = retarget (command ^. #target)
            targetStreamName = ((unvalidated (manager ^. #targetEventStream)) ^. #resolveStreamName) targetStream
        commandAlreadyProcessed <- eventAlreadyIn options targetStreamName commandId
        if commandAlreadyProcessed
            then pure (PMCommandDuplicate commandId)
            else do
                outcome <-
                    runCommandWithProjections
                        targetOptions
                        (manager ^. #targetEventStream)
                        targetStream
                        (command ^. #command)
                        ((manager ^. #targetProjections) (command ^. #target))
                case outcome of
                    Right result -> pure (PMCommandAppended result)
                    Left err -> do
                        benign <- confirmBenignDuplicate targetStreamName commandId err
                        pure $ if benign then PMCommandDuplicate commandId else PMCommandFailed targetStreamName err

    retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)
    retarget = coerce

{- | Run a process manager as a live subscription draining a Shibuya adapter with
'defaultWorkerOptions'.

Use 'runProcessManagerWorkerWith' to override poison-message handling, rejected
command handling, transient retry delay, or dispatch metrics. Every ingested message's ack handle is
finalized exactly once. Successful and duplicate dispatches finalize 'AckOk';
transient store failures finalize 'AckRetry'; rejection-class failures follow
'RejectedCommandPolicy'; other deterministic failures finalize 'AckHalt';
undecodable messages follow the configured 'PoisonPolicy'. Under
'RejectedDeadLetter', see the module-level saga-history contract before opting
in. On a Kiroku-backed adapter, each 'AckRetry' redelivery is bounded by the
subscription @RetryPolicy@ (five total deliveries by default). Exhaustion
dead-letters the source event in @kiroku.dead_letters@ and advances the
checkpoint; @KirokuAdapterConfig@ does not currently expose that bound. Observe
the terminal event with 'Keiro.Telemetry.kirokuEventBridge' and replay it with
@Keiro.DeadLetter.Replay@ when appropriate.
-}
runProcessManagerWorker ::
    forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.
    ( HasCallStack
    , IOE :> es
    , Store :> es
    , Error StoreError :> es
    , KirokuStoreResource :> es
    , BoolAlg phi (RegFile rs, ci)
    , BoolAlg targetPhi (RegFile targetRs, targetCi)
    , Eq co
    , Eq targetCo
    ) =>
    RunCommandOptions ->
    ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->
    Adapter es msg ->
    (msg -> Maybe (RecordedEvent, input)) ->
    Eff es ()
runProcessManagerWorker =
    runProcessManagerWorkerWith defaultWorkerOptions

runProcessManagerWorkerWith ::
    forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.
    ( HasCallStack
    , IOE :> es
    , Store :> es
    , Error StoreError :> es
    , KirokuStoreResource :> es
    , BoolAlg phi (RegFile rs, ci)
    , BoolAlg targetPhi (RegFile targetRs, targetCi)
    , Eq co
    , Eq targetCo
    ) =>
    WorkerOptions es msg ->
    RunCommandOptions ->
    ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->
    Adapter es msg ->
    (msg -> Maybe (RecordedEvent, input)) ->
    Eff es ()
runProcessManagerWorkerWith workerOptions options manager Adapter{source = adapterSource} decodeMessage =
    Streamly.fold Fold.drain
        $ Streamly.mapM handleIngested adapterSource
  where
    handleIngested :: Ingested es msg -> Eff es AckDecision
    handleIngested Ingested{envelope = env@Envelope{payload = message}, ack = AckHandle finalizeAck} = do
        decision <- case decodeMessage message of
            Nothing -> decideForPoison workerOptions "process-manager worker could not decode message" env
            Just (recorded, input) -> do
                let correlationId = (manager ^. #correlate) input
                    managerStream = (manager ^. #streamFor) correlationId
                    managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream
                    attemptCount = envelopeAttemptCount env
                outcome <- tryError @StoreError (runProcessManagerOnce options manager recorded input)
                case outcome of
                    Left (_, storeErr) -> do
                        recordDispatchFailed (workerOptions ^. #metrics) 1
                        pure (ackForThrownStoreError (workerOptions ^. #transientRetryDelay) storeErr)
                    Right (Left err) -> do
                        recordDispatchFailed (workerOptions ^. #metrics) 1
                        decideForFailures
                            workerOptions
                            DispatcherProcessManager
                            (manager ^. #name)
                            correlationId
                            recorded
                            attemptCount
                            [DispatchFailure (-1) managerStreamName err]
                    Right (Right result) ->
                        ackForResults
                            workerOptions
                            (manager ^. #name)
                            correlationId
                            recorded
                            attemptCount
                            (result ^. #managerResult)
                            (result ^. #commandResults)
        finalizeAck decision
        pure decision

ackForThrownStoreError :: RetryDelay -> StoreError -> AckDecision
ackForThrownStoreError delay = ackForCommandError delay . StoreFailed

ackForResults ::
    (IOE :> es, Store :> es) =>
    WorkerOptions es msg ->
    Text ->
    Text ->
    RecordedEvent ->
    Int ->
    PMStateResult managerTarget ->
    [PMCommandResult commandTarget] ->
    Eff es AckDecision
ackForResults workerOptions managerName correlationId sourceEvent attemptCount managerResult commandResults = do
    let duplicateCount = stateDuplicateCount managerResult + commandDuplicateCount commandResults
        failures =
            [ DispatchFailure emitIndex targetStreamName err
            | (emitIndex, PMCommandFailed targetStreamName err) <- zip [0 ..] commandResults
            ]
    recordDispatchDuplicate (workerOptions ^. #metrics) duplicateCount
    recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length failures))
    decideForFailures
        workerOptions
        DispatcherProcessManager
        managerName
        correlationId
        sourceEvent
        attemptCount
        failures

stateDuplicateCount :: PMStateResult target -> Int64
stateDuplicateCount = \case
    PMStateDuplicate{} -> 1
    PMStateAppended{} -> 0

commandDuplicateCount :: [PMCommandResult target] -> Int64
commandDuplicateCount =
    fromIntegral . length . filter isDuplicateResult
  where
    isDuplicateResult = \case
        PMCommandDuplicate{} -> True
        _ -> False

envelopeAttemptCount :: Envelope msg -> Int
envelopeAttemptCount env =
    case env ^. #attempt of
        Nothing -> 1
        Just (Attempt attempt) -> fromIntegral attempt + 1

decideForPoison ::
    (IOE :> es) =>
    WorkerOptions es msg ->
    Text ->
    Envelope msg ->
    Eff es AckDecision
decideForPoison workerOptions reason env = do
    recordDispatchPoison (workerOptions ^. #metrics) 1
    case workerOptions ^. #poisonPolicy of
        PoisonHalt -> pure (AckHalt (HaltFatal reason))
        PoisonSkip callback -> do
            callback env
            pure AckOk
        PoisonDeadLetter callback -> do
            callback env
            pure (AckDeadLetter (InvalidPayload reason))

eventIdToUuid :: EventId -> UUID.UUID
eventIdToUuid (EventId uuid) = uuid

{- | Check whether an event with the given id is already present in a live stream.
Used as the pre-dispatch idempotency guard so a
command that was already applied on a prior (possibly crashed) attempt is
recognized as a duplicate before re-running it.
-}
eventAlreadyIn ::
    (Store :> es) =>
    RunCommandOptions ->
    StoreTypes.StreamName ->
    EventId ->
    Eff es Bool
eventAlreadyIn _options streamName eventId =
    eventExistsInStream streamName eventId

{- | Decide whether a failed append is a benign duplicate of the write just
attempted: whether @ourId@ is genuinely present in @streamName@.

Kiroku's @DuplicateEvent@ carries 'Just' the colliding id only when
PostgreSQL's detail string parses ('Nothing' otherwise), and because the
store's event-id uniqueness is global, even a matching id does not prove the
event landed in our stream. A mismatched id is never ours; a matching or
missing id is confirmed against the target stream with a point lookup. Callers
fold 'True' into their duplicate result and surface 'False' as the original
failure.
-}
confirmBenignDuplicate ::
    (Store :> es) =>
    StoreTypes.StreamName ->
    EventId ->
    CommandError ->
    Eff es Bool
confirmBenignDuplicate streamName ourId = \case
    StoreFailed (DuplicateEvent (Just duplicateId))
        | duplicateId == ourId -> eventExistsInStream streamName ourId
    StoreFailed (DuplicateEvent Nothing) -> eventExistsInStream streamName ourId
    _ -> pure False