packages feed

keiro-0.17.0.0: src/Keiro/ProcessManager/Reaction.hs

{-# LANGUAGE CPP #-}

-- | Additive process-manager reactions with explicit typed outcomes.
--
-- A reaction may advance a private saga stream, run timer SQL atomically with
-- that append, and then dispatch commands to independent target streams. The
-- timer and target phases are deliberately separate transactions. Accepted
-- redelivery validates the recorded saga witness, skips timer SQL, and retries
-- target fan-out with deterministic target-keyed ids.
--
-- 'NoAdvance' and silent domain decisions have no durable receipt. Their
-- unconditional timer effects may therefore run again, including around a
-- concurrent accepted delivery; effects that must be tied to acceptance belong
-- in @onAccepted@. Inputs to 'react', including command order and payloads, must
-- be stable for a source event. Dispatches are attempted in declared order, but
-- independent transactions, failures, and replay do not guarantee that commit
-- order. Switching an existing manager to this identity family requires a
-- drain; there is no positional-id fallback.
module Keiro.ProcessManager.Reaction
  ( -- * Definition
    ReactiveProcessManager (..),
    ReactionPlan (..),
    FollowUp (..),
    ScheduleMode (..),

    -- * Results
    ReactionStateResult (..),
    ReactionTimerEffects (..),
    ReactionError (..),
    ReactiveProcessManagerResult (..),

    -- * Running
    runReactiveProcessManagerOnce,
    runReactiveProcessManagerWorkerWith,
    runReactiveProcessManagerWorker,

    -- * Identity
    deterministicReactionCommandId,
  )
where

import Control.Monad (foldM)
#ifdef KEIRO_REACTION_HYDRATION_PROBE
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as LazyByteString
#endif
import Data.ByteString qualified as ByteString
import Data.ByteString.Char8 qualified as ByteString.Char8
import Data.Coerce (coerce)
import Data.List qualified as List
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.UUID qualified as UUID
import Data.UUID.V5 qualified as UUID.V5
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error, tryError)
import GHC.Stack (HasCallStack)
import Keiki.Core (BoolAlg, RegFile)
import Keiro.Codec (decodeRecorded)
import Keiro.Command
  ( CommandError (..),
    DomainCommandHandler,
    DomainCommandOutcome (..),
    DomainDecision (..),
    RunCommandOptions,
    runDomainCommandWithSqlEvents,
  )
import Keiro.DeadLetter (DispatcherKind (..))
import Keiro.EventStream (EventStream)
import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)
import Keiro.Prelude
import Keiro.ProcessManager
  ( DispatchFailure (..),
    PMCommand (..),
    PMCommandResult (..),
    PoisonPolicy (..),
    WorkerOptions (..),
    ackForCommandError,
    decideForFailures,
    defaultWorkerOptions,
    deterministicCommandIdProbes,
    dispatchDeduplicatedCommand,
    firstExistingEventId,
  )
import Keiro.Projection (InlineProjection, runCommandWithProjections)
import Keiro.Stream (Stream)
import Keiro.Telemetry (recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)
import Keiro.Timer
  ( TimerId,
    TimerRequest,
    cancelTimerTx,
    scheduleTimerOnceTx,
    scheduleTimerTx,
  )
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Read (getStream, readStreamForward)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..), RecordedEvent, StreamName (..), StreamVersion (..))
import Shibuya.Adapter (Adapter (..))
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..))
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
#ifdef KEIRO_REACTION_HYDRATION_PROBE
import System.IO (stderr)
#endif
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified

-- | A pure reaction selected from one decoded input.
--
-- 'NoAdvance' has no saga command and therefore cannot carry accepted-only
-- effects. 'AdvanceReaction' always runs @followUps@ for the observed outcome;
-- @onAccepted@ is added only when this invocation appends or recovers the
-- deterministic accepted witness.
data ReactionPlan ci targetCi
  = NoAdvance ![FollowUp targetCi]
  | AdvanceReaction
      { command :: !ci,
        followUps :: ![FollowUp targetCi],
        onAccepted :: ![FollowUp targetCi]
      }
  deriving stock (Generic, Eq, Show)

-- | One ordered reaction effect. Timer operations retain their relative order
-- in the timer transaction and dispatches retain theirs in the later target
-- phase; the two kinds are not one cross-stream transaction. 'Once' is
-- insert-only for the timer id, while 'Rearm' updates only a still-scheduled
-- row. Cancellation cannot revoke a callback that has already claimed a timer.
data FollowUp targetCi
  = FollowDispatch !(PMCommand targetCi)
  | FollowSchedule !ScheduleMode !TimerRequest
  | FollowCancel !TimerId
  deriving stock (Generic, Eq, Show)

-- | Whether scheduling may move an existing still-scheduled row or is strictly
-- insert-only while any row with the timer id exists.
data ScheduleMode = Rearm | Once
  deriving stock (Generic, Eq, Show)

-- | The saga-state portion of a reaction result.
data ReactionStateResult target co rejection noOp
  = ReactionNotAdvanced
  | ReactionEvaluated !(DomainCommandOutcome target co rejection noOp)
  | ReactionDuplicate !EventId
  deriving stock (Generic, Eq, Show)

-- | Honest accounting for the timer transaction that committed.
--
-- @statementsCommitted@ includes no-op statements. @onceInserted@ and
-- @timersCancelled@ count only rows actually changed. Rearm intentionally has
-- no changed-row counter because the underlying SQL does not return one.
data ReactionTimerEffects = ReactionTimerEffects
  { statementsCommitted :: !Int,
    onceInserted :: !Int,
    timersCancelled :: !Int
  }
  deriving stock (Generic, Eq, Show)

-- | Reaction failures that are not infrastructure failures. Store errors stay
-- in the ambient @Error StoreError@ effect.
data ReactionError
  = ReactionCommandFailed !CommandError
  | ReactionWitnessMissing !StreamName !EventId
  | ReactionWitnessUndecodable !StreamName !EventId
  deriving stock (Generic, Eq, Show)

-- | Detailed result for a one-shot caller. Worker entry points use a strict
-- payload-free reduction instead of retaining this value through fan-out.
data ReactiveProcessManagerResult managerTarget co rejection noOp commandTarget = ReactiveProcessManagerResult
  { managerResult :: !(ReactionStateResult managerTarget co rejection noOp),
    commandResults :: ![PMCommandResult commandTarget],
    timerEffects :: !ReactionTimerEffects
  }
  deriving stock (Generic, Eq, Show)

-- | Runtime wiring for one reactive process manager.
data ReactiveProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp = ReactiveProcessManager
  { name :: !Text,
    correlate :: !(input -> Text),
    sagaHandler :: !(DomainCommandHandler phi rs s ci co rejection noOp),
    streamFor :: !(Text -> Stream (EventStream phi rs s ci co)),
    targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo),
    targetProjections :: !(Stream targetCi -> [InlineProjection targetCo]),
    react :: !(input -> ReactionPlan ci targetCi)
  }
  deriving stock (Generic)

zeroTimerEffects :: ReactionTimerEffects
zeroTimerEffects = ReactionTimerEffects 0 0 0

data EngineReducer managerTarget co rejection noOp commandTarget summary = EngineReducer
  { beginReduction :: !(ReactionStateResult managerTarget co rejection noOp -> ReactionTimerEffects -> summary),
    addDispatchReduction :: !(Int -> PMCommandResult commandTarget -> summary -> summary),
    finishReduction :: !(summary -> summary)
  }
  deriving stock (Generic)

data ReactionWorkerSummary = ReactionWorkerSummary
  { workerDuplicates :: !Int64,
    workerFailures :: ![DispatchFailure]
  }
  deriving stock (Generic, Eq, Show)

-- | Run one reaction. Accepted saga events and their timer effects commit in
-- one transaction; target commands are then attempted independently in source
-- order. Duplicate accepted recovery validates the exact saga witness before
-- skipping timer SQL and retrying target dispatches.
runReactiveProcessManagerOnce ::
  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp 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 ->
  ReactiveProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->
  RecordedEvent ->
  input ->
  Eff
    es
    ( Either
        ReactionError
        ( ReactiveProcessManagerResult
            (EventStream phi rs s ci co)
            co
            rejection
            noOp
            (EventStream targetPhi targetRs targetState targetCi targetCo)
        )
    )
runReactiveProcessManagerOnce options manager sourceEvent input =
  runReactiveProcessManagerEngine onceReducer options manager sourceEvent input
  where
    onceReducer =
      EngineReducer
        { beginReduction = \managerResult timerEffects ->
            ReactiveProcessManagerResult managerResult [] timerEffects,
          addDispatchReduction = \_ commandResult result ->
            result {commandResults = commandResult : result ^. #commandResults},
          finishReduction = \result ->
            result {commandResults = Prelude.reverse (result ^. #commandResults)}
        }

runReactiveProcessManagerEngine ::
  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp summary 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
  ) =>
  EngineReducer
    (EventStream phi rs s ci co)
    co
    rejection
    noOp
    (EventStream targetPhi targetRs targetState targetCi targetCo)
    summary ->
  RunCommandOptions ->
  ReactiveProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->
  RecordedEvent ->
  input ->
  Eff es (Either ReactionError summary)
runReactiveProcessManagerEngine reducer options manager sourceEvent input =
  case (manager ^. #react) input of
    NoAdvance unconditional -> do
      timers <- runTimerPhase unconditional
      finish ReactionNotAdvanced timers unconditional
    AdvanceReaction sagaCommand unconditional acceptedOnly -> do
      existing <- firstExistingEventId options sagaStreamName managerProbes
      case existing of
        Just matchedId -> recoverAndFinish matchedId (unconditional <> acceptedOnly)
        Nothing -> do
          outcome <-
            runDomainCommandWithSqlEvents
              managerOptions
              (manager ^. #sagaHandler)
              sagaStream
              sagaCommand
              (\_ _ -> runTimerPhaseTx (unconditional <> acceptedOnly))
          case outcome of
            Left commandError -> do
              raced <- firstExistingEventId options sagaStreamName managerProbes
              case raced of
                Just matchedId -> recoverAndFinish matchedId (unconditional <> acceptedOnly)
                Nothing -> pure (Left (ReactionCommandFailed commandError))
            Right (domainOutcome@DomainCommandOutcome {decision = DomainAccepted {}}, Just timers) ->
              finish (ReactionEvaluated domainOutcome) timers (unconditional <> acceptedOnly)
            Right (DomainCommandOutcome {decision = DomainAccepted {}}, Nothing) ->
              Prelude.error "runReactiveProcessManagerOnce: accepted append omitted timer callback result"
            Right (domainOutcome, Nothing) -> do
              raced <- firstExistingEventId options sagaStreamName managerProbes
              case raced of
                Just matchedId -> recoverAndFinish matchedId (unconditional <> acceptedOnly)
                Nothing -> do
                  timers <- runTimerPhase unconditional
                  finish (ReactionEvaluated domainOutcome) timers unconditional
            Right (_, Just _) ->
              Prelude.error "runReactiveProcessManagerOnce: silent decision returned a timer callback result"
  where
    correlationId = (manager ^. #correlate) input
    sourceId = sourceEvent ^. #eventId
    sagaStream = (manager ^. #streamFor) correlationId
    sagaEventStream = (manager ^. #sagaHandler) ^. #eventStream
    sagaStreamName = ((unvalidated sagaEventStream) ^. #resolveStreamName) sagaStream
    managerProbes = deterministicCommandIdProbes (manager ^. #name) correlationId sourceId (-1)
    managerId = NonEmpty.head managerProbes
    managerOptions = options & #eventIds .~ [managerId]

    recoverAndFinish matchedId selected = do
      recovered <- recoverWitness sagaEventStream sagaStreamName matchedId
      case recovered of
        Left err -> pure (Left err)
        Right () -> finish (ReactionDuplicate matchedId) zeroTimerEffects selected

    finish state timers selected = do
      let initial = (reducer ^. #beginReduction) state timers
      initial `Prelude.seq` do
        reduced <-
          dispatchReactionCommandsWith
            (reducer ^. #addDispatchReduction)
            initial
            options
            manager
            correlationId
            sourceId
            selected
        pure (Right ((reducer ^. #finishReduction) reduced))

-- | Drain a Shibuya adapter with the configured poison, rejection, retry, and
-- telemetry policies. Each normally resolved delivery is finalized exactly
-- once. The worker reduces accepted saga payloads before target fan-out and
-- retains only duplicate and failure accounting while dispatching.
runReactiveProcessManagerWorkerWith ::
  forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp 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 ->
  ReactiveProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->
  Adapter es msg ->
  (msg -> Maybe (RecordedEvent, input)) ->
  Eff es ()
runReactiveProcessManagerWorkerWith 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 -> decidePoison env
        Just (recorded, input) -> decideReaction env recorded input
      finalizeAck decision
      pure decision

    decideReaction env recorded input = do
      let correlationId = (manager ^. #correlate) input
          sagaStream = (manager ^. #streamFor) correlationId
          sagaEventStream = (manager ^. #sagaHandler) ^. #eventStream
          sagaStreamName = ((unvalidated sagaEventStream) ^. #resolveStreamName) sagaStream
          attemptCount = envelopeAttemptCount env
      outcome <-
        tryError @StoreError
          (runReactiveProcessManagerEngine workerReducer options manager recorded input)
      case outcome of
        Left (_, storeError) -> do
          recordDispatchFailed (workerOptions ^. #metrics) 1
          pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeError))
        Right (Left (ReactionCommandFailed commandError)) -> do
          recordDispatchFailed (workerOptions ^. #metrics) 1
          decideForFailures
            workerOptions
            DispatcherProcessManager
            (manager ^. #name)
            correlationId
            recorded
            attemptCount
            [DispatchFailure (-1) sagaStreamName commandError]
        Right (Left witnessError) -> do
          recordDispatchFailed (workerOptions ^. #metrics) 1
          pure (AckHalt (HaltFatal (witnessReason witnessError)))
        Right (Right summary) -> do
          recordDispatchDuplicate (workerOptions ^. #metrics) (summary ^. #workerDuplicates)
          recordDispatchFailed
            (workerOptions ^. #metrics)
            (Prelude.fromIntegral (Prelude.length (summary ^. #workerFailures)))
          decideForFailures
            workerOptions
            DispatcherProcessManager
            (manager ^. #name)
            correlationId
            recorded
            attemptCount
            (summary ^. #workerFailures)

    decidePoison env = do
      recordDispatchPoison (workerOptions ^. #metrics) 1
      case workerOptions ^. #poisonPolicy of
        PoisonHalt -> pure (AckHalt (HaltFatal "process-reaction-worker-decode-failed"))
        PoisonSkip callback -> do
          callback env
          pure AckOk
        PoisonDeadLetter callback -> do
          callback env
          pure (AckDeadLetter (InvalidPayload "process-reaction-worker-decode-failed"))

    workerReducer =
      EngineReducer
        { beginReduction = \state _ ->
            ReactionWorkerSummary
              { workerDuplicates = case state of
                  ReactionDuplicate {} -> 1
                  ReactionNotAdvanced -> 0
                  ReactionEvaluated {} -> 0,
                workerFailures = []
              },
          addDispatchReduction = \emitIndex result summary ->
            case result of
              PMCommandAppended {} -> summary
              PMCommandDuplicate {} ->
                summary {workerDuplicates = summary ^. #workerDuplicates Prelude.+ 1}
              PMCommandFailed targetStreamName commandError ->
                summary
                  { workerFailures =
                      DispatchFailure emitIndex targetStreamName commandError
                        : summary ^. #workerFailures
                  },
          finishReduction = \summary ->
            summary {workerFailures = Prelude.reverse (summary ^. #workerFailures)}
        }

    envelopeAttemptCount env =
      case env ^. #attempt of
        Nothing -> 1
        Just (Attempt attempt) -> Prelude.fromIntegral attempt Prelude.+ 1

    witnessReason = \case
      ReactionWitnessMissing {} -> "process-reaction-witness-missing"
      ReactionWitnessUndecodable {} -> "process-reaction-witness-undecodable"
      ReactionCommandFailed {} -> "process-reaction-command-failed"

-- | Run a reactive process-manager worker with 'defaultWorkerOptions'.
runReactiveProcessManagerWorker ::
  forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp 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 ->
  ReactiveProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->
  Adapter es msg ->
  (msg -> Maybe (RecordedEvent, input)) ->
  Eff es ()
runReactiveProcessManagerWorker =
  runReactiveProcessManagerWorkerWith defaultWorkerOptions

-- | Execute just the timer subsequence in one transaction.
runTimerPhase :: (IOE :> es, Store :> es) => [FollowUp targetCi] -> Eff es ReactionTimerEffects
runTimerPhase followUps
  | List.null timerFollowUps = pure zeroTimerEffects
  | otherwise = do
      emitTimerPhaseProbe (List.length timerFollowUps)
      runTransaction (runTimerPhaseTx followUps)
  where
    timerFollowUps = List.filter isTimerFollowUp followUps
    isTimerFollowUp FollowSchedule {} = True
    isTimerFollowUp FollowCancel {} = True
    isTimerFollowUp FollowDispatch {} = False

#ifdef KEIRO_REACTION_HYDRATION_PROBE
emitTimerPhaseProbe :: (IOE :> es) => Int -> Eff es ()
emitTimerPhaseProbe statementCount =
  liftIO
    ( ByteString.Char8.hPutStrLn stderr
        ( LazyByteString.toStrict
            ( Aeson.encode
                ( Aeson.object
                    [ "marker" Aeson..= ("reaction-probe" :: Text.Text),
                      "operation" Aeson..= ("timer-phase" :: Text.Text),
                      "statements" Aeson..= statementCount
                    ]
                )
            )
        )
    )
#else
emitTimerPhaseProbe :: Int -> Eff es ()
emitTimerPhaseProbe _ = pure ()
#endif

runTimerPhaseTx :: [FollowUp targetCi] -> Tx.Transaction ReactionTimerEffects
runTimerPhaseTx = foldM step zeroTimerEffects
  where
    step summary = \case
      FollowDispatch {} -> pure summary
      FollowSchedule Rearm request -> do
        scheduleTimerTx request
        pure summary {statementsCommitted = summary ^. #statementsCommitted Prelude.+ 1}
      FollowSchedule Once request -> do
        inserted <- scheduleTimerOnceTx request
        pure
          summary
            { statementsCommitted = summary ^. #statementsCommitted Prelude.+ 1,
              onceInserted = summary ^. #onceInserted Prelude.+ if inserted then 1 else 0
            }
      FollowCancel timerId -> do
        cancelled <- cancelTimerTx timerId
        pure
          summary
            { statementsCommitted = summary ^. #statementsCommitted Prelude.+ 1,
              timersCancelled = summary ^. #timersCancelled Prelude.+ if cancelled then 1 else 0
            }

-- | Validate the exact accepted event while holding only one page at a time.
-- The stream version captured after the positive point probe is a finite read
-- ceiling, so a vanished witness cannot chase concurrent appends forever.
recoverWitness ::
  (IOE :> es, Store :> es) =>
  ValidatedEventStream phi rs s ci co ->
  StreamName ->
  EventId ->
  Eff es (Either ReactionError ())
#ifdef KEIRO_REACTION_HYDRATION_PROBE
recoverWitness validated streamName witnessId =
  do
    let probeStreamName = case streamName of StreamName name -> name
    liftIO
      ( ByteString.Char8.hPutStrLn stderr
          ( LazyByteString.toStrict
              ( Aeson.encode
                  ( Aeson.object
                      [ "marker" Aeson..= ("reaction-probe" :: Text.Text),
                        "operation" Aeson..= ("witness" :: Text.Text),
                        "stream" Aeson..= probeStreamName
                      ]
                  )
              )
          )
      )
    recoverWitnessAfterProbe validated streamName witnessId
#else
recoverWitness validated streamName witnessId =
  recoverWitnessAfterProbe validated streamName witnessId
#endif

recoverWitnessAfterProbe ::
  (Store :> es) =>
  ValidatedEventStream phi rs s ci co ->
  StreamName ->
  EventId ->
  Eff es (Either ReactionError ())
recoverWitnessAfterProbe validated streamName witnessId = do
  streamInfo <- getStream streamName
  case streamInfo of
    Nothing -> pure (Left missing)
    Just info -> scan (info ^. #id) (info ^. #version) (StreamVersion 0)
  where
    missing = ReactionWitnessMissing streamName witnessId
    codec = (unvalidated validated) ^. #eventCodec
    pageSize = 256

    scan expectedStreamId ceiling cursor = do
      page <- readStreamForward streamName cursor pageSize
      let withinCeiling = Vector.takeWhile (\event -> event ^. #streamVersion <= ceiling) page
          found = Vector.find (\event -> event ^. #eventId == witnessId) withinCeiling
      case found of
        Just witness
          | witness ^. #originalStreamId /= expectedStreamId -> pure (Left missing)
          | otherwise ->
              pure
                $ case decodeRecorded codec witness of
                  Left _ -> Left (ReactionWitnessUndecodable streamName witnessId)
                  Right _ -> Right ()
        Nothing
          | Vector.null withinCeiling -> pure (Left missing)
          | otherwise ->
              let next = (Vector.last withinCeiling) ^. #streamVersion
               in if next >= ceiling
                    then pure (Left missing)
                    else scan expectedStreamId ceiling next

dispatchReactionCommandsWith ::
  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp summary es.
  ( HasCallStack,
    IOE :> es,
    Store :> es,
    Error StoreError :> es,
    KirokuStoreResource :> es,
    BoolAlg targetPhi (RegFile targetRs, targetCi),
    Eq targetCo
  ) =>
  (Int -> PMCommandResult (EventStream targetPhi targetRs targetState targetCi targetCo) -> summary -> summary) ->
  summary ->
  RunCommandOptions ->
  ReactiveProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->
  Text ->
  EventId ->
  [FollowUp targetCi] ->
  Eff es summary
dispatchReactionCommandsWith reduce initial options manager correlationId sourceId =
  go Map.empty 0 initial
  where
    go _ _ summary [] = pure summary
    go occurrences dispatchIndex summary (followUp : rest) =
      case followUp of
        FollowDispatch dispatched -> do
          let targetStream = retarget (dispatched ^. #target)
              targetName = ((unvalidated (manager ^. #targetEventStream)) ^. #resolveStreamName) targetStream
              occurrence = Map.findWithDefault 0 targetName occurrences
              nextOccurrences = Map.insert targetName (occurrence Prelude.+ 1) occurrences
              commandId =
                deterministicReactionCommandId
                  (manager ^. #name)
                  correlationId
                  sourceId
                  targetName
                  occurrence
          result <- dispatchOne targetStream targetName commandId dispatched
          let nextSummary = reduce dispatchIndex result summary
          nextSummary `Prelude.seq` go nextOccurrences (dispatchIndex Prelude.+ 1) nextSummary rest
        _ -> go occurrences dispatchIndex summary rest

    dispatchOne targetStream targetName commandId dispatched = do
      let targetOptions = options & #eventIds .~ [commandId]
      dispatchedInitial <-
        dispatchDeduplicatedCommand
          options
          targetName
          (commandId :| [])
          PMCommandDuplicate
          (PMCommandFailed targetName)
          PMCommandAppended
          ( runCommandWithProjections
              targetOptions
              (manager ^. #targetEventStream)
              targetStream
              (dispatched ^. #command)
              ((manager ^. #targetProjections) (dispatched ^. #target))
          )
      case dispatchedInitial of
        PMCommandFailed {} -> reconcile dispatchedInitial
        PMCommandAppended commandResult
          | commandResult ^. #eventsAppended == 0 -> reconcile dispatchedInitial
        _ -> pure dispatchedInitial
      where
        reconcile preserved = do
          raced <- firstExistingEventId options targetName (commandId :| [])
          pure (maybe preserved PMCommandDuplicate raced)

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

-- | Derive the stable first-event id for one reaction target command.
--
-- Every field is encoded as its decimal UTF-8 byte length, a colon, and the
-- bytes. The fields are, in order: @keiro@, @process-reaction@, manager name,
-- correlation id, canonical source UUID text, physical target stream name, and
-- decimal zero-based occurrence among commands to that target.
deterministicReactionCommandId :: Text -> Text -> EventId -> StreamName -> Int -> EventId
deterministicReactionCommandId managerName correlationId sourceEventId targetStreamName occurrence =
  EventId
    $ UUID.V5.generateNamed UUID.V5.namespaceURL
    $ ByteString.unpack
    $ ByteString.concat
    $ fmap
      encodeField
      [ "keiro",
        "process-reaction",
        managerName,
        correlationId,
        UUID.toText (coerce sourceEventId),
        coerce targetStreamName,
        Text.pack (show occurrence)
      ]
  where
    encodeField field =
      let bytes = Text.Encoding.encodeUtf8 field
       in ByteString.concat
            [ ByteString.Char8.pack (show (ByteString.length bytes)),
              ByteString.singleton 58,
              bytes
            ]