packages feed

keiro-0.2.0.0: src/Keiro/Router.hs

{- | Stateless, content-based routing of events to commands.

A 'Router' is the stateless sibling of
'Keiro.ProcessManager.ProcessManager': for each incoming event it resolves
a data-dependent set of target streams /effectfully/ (typically via a
read-model query) and dispatches one command to each. Dispatch is
idempotent per resolved target identity: every command is appended under a
target-name-keyed deterministic id, and store-level duplicate rejections are
confirmed against that target before becoming a benign
'PMCommandDuplicate'. Redelivery therefore deduplicates every target resolved
again, regardless of target order. Because resolution is effectful, the target
set may drift between attempts; dispatches accumulate as the union of those
attempts, so callers that require one exact set must keep resolution stable for
a source event.

Use 'runRouterOnce' to dispatch a single event, or 'runRouterWorker' to run
the router as a live subscription draining a Shibuya adapter. Its retry and
source-event dead-letter contract is the same bounded Kiroku contract described
by "Keiro.ProcessManager": five total deliveries by default, followed by a
@kiroku.dead_letters@ write and atomic checkpoint advance.

A router's 'key' can join events from different source streams just as a
process manager's @correlate@ function can. The same ordering rule applies:
same-stream order is preserved, but different streams have no relative
business-order guarantee and may run concurrently under sharding. Keep routed
logic order-insensitive; see "Keiro.ProcessManager" for the worked example.
Each resolved target command (with its inline projections) commits in its own
transaction, so fan-out is idempotent rather than all-target atomic.
-}
module Keiro.Router (
    -- * Definition
    Router (..),
    RouterResult (..),

    -- * Idempotency
    deterministicRouterCommandId,

    -- * Running
    runRouterOnce,
    runRouterWorkerWith,
    runRouterWorker,
)
where

import Data.ByteString qualified as ByteString
import Data.ByteString.Char8 qualified as ByteString.Char8
import Data.Coerce (coerce)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Traversable (mapAccumL)
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 (..), RunCommandOptions)
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,
    confirmBenignDuplicate,
    decideForFailures,
    defaultWorkerOptions,
    deterministicCommandId,
    eventAlreadyIn,
 )
import Keiro.Projection (InlineProjection, runCommandWithProjections)
import Keiro.Stream (Stream)
import Keiro.Telemetry (recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError (..))
import Kiroku.Store.Types (EventId (..), RecordedEvent, StreamName (..))
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
import Prelude (filter, fromIntegral, length, snd, zip, (+))

{- | A stateless, content-based router (in the Enterprise Integration Patterns
sense): for each incoming event it resolves a data-dependent set of target
streams /effectfully/ and dispatches one command to each.

This is the stateless counterpart of 'Keiro.ProcessManager.ProcessManager'. It
has no manager state stream, no @correlate@, and no self-directed command. Its
sole new capability over the process manager is that target resolution runs in
@Eff es@ — typically a read-model query via 'Keiro.ReadModel.runQuery' — so the
fan-out set can be /looked up/ rather than computed purely from the event.

Dispatch is idempotent by construction: each target command is appended under a
deterministic identifier derived from @(name, key input, source event id,
resolved target stream name, occurrence)@ (see
'deterministicRouterCommandId'), pre-checked with 'eventAlreadyIn', and the
store's @DuplicateEvent@ rejection is confirmed against the target stream
before it is treated as benign. A redelivery deduplicates every target it
resolves again even if target order or membership changed. A target resolved
only on an earlier attempt keeps its immutable dispatch, and a newly resolved
target is dispatched on the later attempt; the cumulative set is therefore the
union of attempt outputs. Keep 'resolve' stable for a source event when the
exact recipient set matters.

Each dispatch also runs 'targetProjections' for the target aggregate in the same
append transaction. The function receives the concrete target stream so callers can
build projections closed over stream-local keys. Return @[]@ to preserve
append-only dispatch.
-}
data Router input targetPhi targetRs targetState targetCi targetCo es = Router
    { name :: !Text
    -- ^ Stable identifier; part of every dispatched command's deterministic id.
    , key :: !(input -> Text)
    -- ^ Correlation string for the source event (e.g. the transaction id).
    , resolve :: !(input -> Eff es [PMCommand targetCi])
    {- ^ The effectful seam: compute the data-dependent target set, typically
    @runQuery readModel q@.
    -}
    , targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo)
    -- ^ The aggregate every resolved command is dispatched to.
    , 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.
    -}
    }
    deriving stock (Generic)

{- | The outcome of a single 'runRouterOnce' invocation: one
'PMCommandResult' per resolved target, in resolution order.

Unlike 'Keiro.ProcessManager.ProcessManagerResult' there is no manager-state
result, because a router has no state stream. A failed dispatch surfaces as a
'PMCommandFailed' element rather than an outer 'Either'.
-}
newtype RouterResult target = RouterResult
    { commandResults :: [PMCommandResult target]
    }
    deriving stock (Generic, Eq, Show)

{- | Derive a stable, collision-resistant 'EventId' for a router dispatch from
@(router name, key input, source event id, resolved target stream name,
occurrence)@ via a v5 UUID.

Unlike 'Keiro.ProcessManager.deterministicCommandId' (which the process manager
still uses, soundly, because its command list is a pure function of the input),
the router keys the id by the target's identity rather than its position in the
resolved list: 'resolve' is effectful, so a redelivery may see the same targets
in a different order or a drifted set, and a positional id would then point at
the wrong target. The @occurrence@ is the index among commands in the same
resolve batch that address the same target stream (0 for the first), so
resolving the same target twice in one batch still yields distinct ids.

The v5 name encodes every text field as length-prefixed UTF-8. This avoids both
delimiter ambiguity when names contain colons and character truncation for
non-ASCII names.
-}
deterministicRouterCommandId :: Text -> Text -> EventId -> StreamName -> Int -> EventId
deterministicRouterCommandId routerName correlationId sourceEventId targetStreamName occurrence =
    EventId
        $ UUID.V5.generateNamed UUID.V5.namespaceURL
        $ ByteString.unpack
        $ ByteString.concat
        $ fmap
            encodeField
            [ "keiro"
            , "router"
            , routerName
            , 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
                ]

{- | Resolve the targets for one source event, then dispatch one command per
target with crash-safe, target-identity idempotency.

Unlike 'Keiro.ProcessManager.runProcessManagerOnce', whose pure command list
can safely use positional ids, a router derives each id from the resolved
target stream name and its same-stream occurrence. It skips ids already in the
target stream, otherwise runs 'Keiro.Projection.runCommandWithProjections', and
folds a @DuplicateEvent@ rejection only after confirming the attempted id is in
that target stream.

Returns 'RouterResult' directly (no outer @Either CommandError@) because — unlike
the process manager — there is no manager-state append that can fail before
dispatch.
-}
runRouterOnce ::
    forall input targetPhi targetRs targetState targetCi targetCo es.
    ( HasCallStack
    , IOE :> es
    , Store :> es
    , Error StoreError :> es
    , KirokuStoreResource :> es
    , BoolAlg targetPhi (RegFile targetRs, targetCi)
    , Eq targetCo
    ) =>
    RunCommandOptions ->
    Router input targetPhi targetRs targetState targetCi targetCo es ->
    RecordedEvent ->
    input ->
    Eff es (RouterResult (EventStream targetPhi targetRs targetState targetCi targetCo))
runRouterOnce options router sourceEvent input = do
    let correlationId = (router ^. #key) input
    commands <- (router ^. #resolve) input
    let named =
            [ (streamNameOf command, command)
            | command <- commands
            ]
        annotated = snd (mapAccumL occurrenceStep Map.empty (zip [0 ..] named))
        occurrenceStep seen (legacyIndex, (targetStreamName, command)) =
            let occurrence = Map.findWithDefault 0 targetStreamName seen
             in ( Map.insert targetStreamName (occurrence + 1) seen
                , (legacyIndex, occurrence, targetStreamName, command)
                )
    results <-
        traverse
            (dispatchCommand correlationId (sourceEvent ^. #eventId))
            annotated
    pure (RouterResult results)
  where
    streamNameOf command =
        ((unvalidated (router ^. #targetEventStream)) ^. #resolveStreamName)
            (retarget (command ^. #target))

    dispatchCommand correlationId sourceEventId (legacyIndex, occurrence, targetStreamName, command) = do
        let commandId =
                deterministicRouterCommandId
                    (router ^. #name)
                    correlationId
                    sourceEventId
                    targetStreamName
                    occurrence
            -- Transition: dispatches written by keiro versions that derived
            -- positional ids must still dedup across the upgrade. Remove in a
            -- later release after the compatibility window closes.
            legacyCommandId =
                deterministicCommandId
                    (router ^. #name)
                    correlationId
                    sourceEventId
                    legacyIndex
            targetOptions = options & #eventIds .~ [commandId]
            targetEventStream = router ^. #targetEventStream
            targetStream = retarget (command ^. #target)
        commandAlreadyProcessed <- eventAlreadyIn options targetStreamName commandId
        legacyAlreadyProcessed <-
            if commandAlreadyProcessed
                then pure False
                else eventAlreadyIn options targetStreamName legacyCommandId
        if commandAlreadyProcessed
            then pure (PMCommandDuplicate commandId)
            else
                if legacyAlreadyProcessed
                    then pure (PMCommandDuplicate legacyCommandId)
                    else do
                        outcome <-
                            runCommandWithProjections
                                targetOptions
                                targetEventStream
                                targetStream
                                (command ^. #command)
                                ((router ^. #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 'Router' as a live subscription over a Shibuya 'Adapter'.

Mirrors 'Keiro.ProcessManager.runProcessManagerWorker': it drains the adapter's
message stream, decoding each message to a @(RecordedEvent, input)@ pair and
dispatching it through 'runRouterOnce'.

Ack policy (see this plan's Decision Log):

  * a message that fails to decode follows the configured 'PoisonPolicy'
    (default: 'AckHalt' @HaltFatal@);
  * otherwise, after dispatch, if every 'PMCommandResult' is
    'PMCommandAppended' or 'PMCommandDuplicate' the message finalizes 'AckOk';
  * if any dispatch is 'PMCommandFailed', transient failures finalize
    'AckRetry', systemic deterministic failures finalize @AckHalt
    (HaltFatal …)@, and rejection-class failures follow
    'RejectedCommandPolicy'.

Benign domain rejections (a target aggregate refusing a "check" command because
no edge matches) must be modeled as /total/ transitions in the keiki transducer
(an ε-complement self-loop) so they never surface as 'PMCommandFailed' and
therefore never wedge the worker. When the rejection is genuinely
data-dependent, 'RejectedDeadLetter' persists a queryable
"Keiro.DeadLetter.DispatchDeadLetter" and acknowledges the source event;
'RejectedSkip' acknowledges and records only the metric. 'RejectedHalt' remains
the default.

The worker invokes each ingested message's 'Shibuya.Core.AckHandle.AckHandle'
@finalize@ exactly once with the decision, so the decision reaches the adapter.
Use 'runRouterWorkerWith' to override poison-message handling, rejected-command
handling, transient retry delay, or dispatch metrics. On a Kiroku-backed
adapter, 'AckRetry' remains bounded by the subscription @RetryPolicy@ (five
total deliveries by default). Exhaustion records the source event in
@kiroku.dead_letters@ with reason kind @max_attempts_exceeded@ and advances the
checkpoint. @KirokuAdapterConfig@ does not currently expose @retryPolicy@;
install 'Keiro.Telemetry.kirokuEventBridge' on Kiroku's @eventHandler@ to observe
the terminal event. The configurable sharded path forwards
@ShardedWorkerOptions.retryPolicy@ through the same Kiroku ladder; see
"Keiro.Subscription.Shard.Worker".
-}
runRouterWorker ::
    forall msg input targetPhi targetRs targetState targetCi targetCo es.
    ( HasCallStack
    , IOE :> es
    , Store :> es
    , Error StoreError :> es
    , KirokuStoreResource :> es
    , BoolAlg targetPhi (RegFile targetRs, targetCi)
    , Eq targetCo
    ) =>
    RunCommandOptions ->
    Router input targetPhi targetRs targetState targetCi targetCo es ->
    Adapter es msg ->
    (msg -> Maybe (RecordedEvent, input)) ->
    Eff es ()
runRouterWorker =
    runRouterWorkerWith defaultWorkerOptions

runRouterWorkerWith ::
    forall msg input targetPhi targetRs targetState targetCi targetCo es.
    ( HasCallStack
    , IOE :> es
    , Store :> es
    , Error StoreError :> es
    , KirokuStoreResource :> es
    , BoolAlg targetPhi (RegFile targetRs, targetCi)
    , Eq targetCo
    ) =>
    WorkerOptions es msg ->
    RunCommandOptions ->
    Router input targetPhi targetRs targetState targetCi targetCo es ->
    Adapter es msg ->
    (msg -> Maybe (RecordedEvent, input)) ->
    Eff es ()
runRouterWorkerWith workerOptions options router 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 "router worker could not decode message" env
            Just (recorded, input) -> do
                let correlationId = (router ^. #key) input
                    attemptCount = envelopeAttemptCount env
                outcome <- tryError @StoreError (runRouterOnce options router recorded input)
                case outcome of
                    Left (_, storeErr) -> do
                        recordDispatchFailed (workerOptions ^. #metrics) 1
                        pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeErr))
                    Right (RouterResult results) -> ackDecisionFor recorded correlationId attemptCount results
        finalizeAck decision
        pure decision

    ackDecisionFor :: RecordedEvent -> Text -> Int -> [PMCommandResult target] -> Eff es AckDecision
    ackDecisionFor sourceEvent correlationId attemptCount results = do
        let duplicateCount = commandDuplicateCount results
            failures =
                [ DispatchFailure emitIndex targetStreamName err
                | (emitIndex, PMCommandFailed targetStreamName err) <- zip [0 ..] results
                ]
        recordDispatchDuplicate (workerOptions ^. #metrics) duplicateCount
        recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length failures))
        decideForFailures
            workerOptions
            DispatcherRouter
            (router ^. #name)
            correlationId
            sourceEvent
            attemptCount
            failures

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

    decideForPoison :: Text -> Envelope msg -> Eff es AckDecision
    decideForPoison 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))

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