packages feed

keiro-0.2.0.0: src/Keiro/DeadLetter/Replay.hs

{- | Operator replay for source events parked in Kiroku's
@kiroku.dead_letters@ table.

The rows remain Kiroku-owned: replay neither deletes nor marks them. Instead it
re-runs a caller-supplied handler and relies on that handler's idempotency. This
is safe for "Keiro.ProcessManager" and "Keiro.Router" handlers because their
writes use deterministic event identifiers derived from the source event;
already-applied writes collapse to duplicate outcomes on every later replay.

Kiroku exposes dead-letter rows by event id and global position, but does not
currently expose an exact point-read by either value. Global positions are
opaque cursors and must never be decremented. 'replaySubscriptionDeadLetters'
therefore scans the global stream backward using only @0@ and cursors returned
by Kiroku, matches both event id and position, and scans once for the whole
batch. A source event removed by hard deletion is reported as
'ReplaySourceMissing' and is not handed to the caller.

A process-manager handler normally decodes the recorded event, calls
@runProcessManagerOnce@, and classifies a result whose manager state and every
target command are duplicates as 'ReplayedDuplicate'; any append makes it
'ReplayedFresh'. Return @Left detail@ for a decode or command failure. For
example, the classification has this shape:

> case result of
>   Left err -> Left (showText err)
>   Right pmResult
>     | managerAndEveryCommandAreDuplicates pmResult -> Right ReplayedDuplicate
>     | otherwise -> Right ReplayedFresh

Because rows are retained, an operator may safely run the same replay command
again after a partial failure or uncertain client disconnect.
-}
module Keiro.DeadLetter.Replay (
    ReplayOutcome (..),
    ReplayResult (..),
    DeadLetterRecord (..),
    listSubscriptionDeadLetters,
    replaySubscriptionDeadLetters,
)
where

import Data.Int (Int32)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Effectful (Eff, (:>))
import Keiro.Prelude
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Read (readAllBackward)
import Kiroku.Store.SQL (DeadLetterRecord (..), readDeadLettersStmt)
import Kiroku.Store.Subscription.Types (SubscriptionName (..))
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..), GlobalPosition (..), RecordedEvent)
import "hasql-transaction" Hasql.Transaction qualified as Tx

data ReplayOutcome = ReplayOutcome
    { replayGlobalPosition :: !GlobalPosition
    , replayEventId :: !EventId
    , replayResult :: !ReplayResult
    }
    deriving stock (Generic, Eq, Show)

data ReplayResult
    = ReplayedFresh
    | ReplayedDuplicate
    | ReplayFailed !Text
    | ReplaySourceMissing
    deriving stock (Generic, Eq, Show)

-- | List one Kiroku subscription member's dead letters, newest first.
listSubscriptionDeadLetters ::
    (Store :> es) => SubscriptionName -> Int32 -> Eff es (Vector DeadLetterRecord)
listSubscriptionDeadLetters (SubscriptionName name) member =
    runTransaction (Tx.statement (name, member) readDeadLettersStmt)

{- | Replay every dead letter currently listed for one subscription member.

The handler controls domain-specific decoding and decides whether its writes
were fresh or duplicates. A @Left detail@ is recorded in the corresponding
'ReplayOutcome' as 'ReplayFailed'; replay continues with later rows. Store
errors still surface through the surrounding Kiroku 'Store' interpreter.
-}
replaySubscriptionDeadLetters ::
    (Store :> es) =>
    SubscriptionName ->
    Int32 ->
    (RecordedEvent -> Eff es (Either Text ReplayResult)) ->
    Eff es [ReplayOutcome]
replaySubscriptionDeadLetters subscriptionName member handler = do
    rows <- listSubscriptionDeadLetters subscriptionName member
    sources <- findSources rows
    traverse (replayOne sources) (Vector.toList rows)
  where
    replayOne sources row = do
        let position = deadLetterPosition row
            eventId = deadLetterEventIdValue row
        result <- case Map.lookup eventId sources of
            Just event
                | event ^. #globalPosition == position -> do
                    handled <- handler event
                    pure (either ReplayFailed id handled)
            _ -> pure ReplaySourceMissing
        pure
            ReplayOutcome
                { replayGlobalPosition = position
                , replayEventId = eventId
                , replayResult = result
                }

-- Scan once for the complete replay batch. Pages are descending, and the next
-- cursor is always a position Kiroku returned; no global-position arithmetic.
findSources ::
    (Store :> es) => Vector DeadLetterRecord -> Eff es (Map EventId RecordedEvent)
findSources rows = go (GlobalPosition 0) Map.empty
  where
    wanted =
        Map.fromList
            [ (deadLetterEventIdValue row, deadLetterPosition row)
            | row <- Vector.toList rows
            ]

    go cursor found
        | Map.size found == Map.size wanted = pure found
        | otherwise = do
            page <- readAllBackward cursor replayReadPageSize
            if Vector.null page
                then pure found
                else do
                    let found' = Vector.foldl' remember found page
                        nextCursor = Vector.last page ^. #globalPosition
                    go nextCursor found'

    remember found event =
        case Map.lookup (event ^. #eventId) wanted of
            Just expectedPosition
                | event ^. #globalPosition == expectedPosition ->
                    Map.insert (event ^. #eventId) event found
            _ -> found

replayReadPageSize :: Int32
replayReadPageSize = 256

deadLetterPosition :: DeadLetterRecord -> GlobalPosition
deadLetterPosition row = GlobalPosition (row ^. #deadLetterGlobalPosition)

deadLetterEventIdValue :: DeadLetterRecord -> EventId
deadLetterEventIdValue row = EventId (row ^. #deadLetterEventId)