packages feed

keiro-0.12.0.0: src/Keiro/ReadModel/Rebuild/Stream.hs

{-# OPTIONS_HADDOCK hide #-}

-- | Transactional targeted repair for explicitly stream-scoped projections.
-- Applications own row selection and event semantics; Keiro owns admission,
-- locking, history completeness, ordering, rollback, and redelivery evidence.
module Keiro.ReadModel.Rebuild.Stream
  ( StreamReprojectionRequest (..),
    StreamReprojectionError (..),
    StreamReprojectionReport (..),
    validateStreamReprojectionAdmission,
    reprojectStream,
    reprojectStreamTx,
  )
where

import Data.Functor (($>))
import Data.Int (Int32)
import Data.List qualified as List
import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (listToMaybe)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.UUID (UUID)
import Data.Vector qualified as Vector
import Effectful (Eff, (:>))
import Keiro.Prelude
import Keiro.Projection.Catalog
  ( CatalogAsyncDedupSpec (..),
    DedupKeyId,
    PhysicalTargets,
    ProjectionId,
    ProjectionRevisionId,
    RebuildGroupId,
    ReplayDecodeError,
    SourceId,
    SourceScope (..),
    StreamClearCount (..),
    StreamScopedReplay,
    TargetId,
    ValidatedProjectionCatalog,
    catalogAsyncIdempotencyKeys,
    catalogInventory,
    catalogStreamScopedReplay,
    resolvePhysicalTarget,
  )
import Keiro.ReadModel.Rebuild.Group
  ( ProjectionRepairFence (..),
    RebuildRunId,
    insertProjectionDedupBatchStmt,
    lockProjectionGroupForRepairTx,
  )
import Kiroku.Store.Effect (Store)
import Kiroku.Store.HistoryRetention
  ( StreamHistoryUnavailable,
    lockStreamHistoryForReplayTx,
    readStreamForwardTx,
  )
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types
  ( CategoryName (..),
    EventId (..),
    RecordedEvent,
    StreamName (..),
    StreamVersion (..),
  )
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude (any, filter, fromIntegral, last, length, map, not, null, zip, (+), (-), (||))
import Prelude qualified

data StreamReprojectionRequest = StreamReprojectionRequest
  { rebuildGroupId :: !RebuildGroupId,
    projectionId :: !ProjectionId,
    streamName :: !StreamName,
    pageSize :: !Int32,
    maxEvents :: !Int64
  }
  deriving stock (Eq, Show, Generic)

data StreamReprojectionError
  = StreamReprojectionInvalidPageSize !Int32
  | StreamReprojectionInvalidMaxEvents !Int64
  | StreamReprojectionEventLimitExceeded !StreamName !Int64 !Int64
  | StreamReprojectionGroupUnregistered !RebuildGroupId
  | StreamReprojectionActiveRebuild !RebuildGroupId !RebuildRunId
  | StreamReprojectionGroupUnavailable !RebuildGroupId !Text !Bool !Bool
  | StreamReprojectionSliceDrift !RebuildGroupId !Text !Text
  | StreamReprojectionServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId
  | StreamReprojectionServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text
  | StreamReprojectionUnknownProjection !ProjectionId
  | StreamReprojectionProjectionGroupMismatch !ProjectionId !RebuildGroupId !RebuildGroupId
  | StreamReprojectionPolicyUnavailable !ProjectionRevisionId !ProjectionId
  | StreamReprojectionSourceMismatch !SourceId !StreamName
  | StreamReprojectionHistoryUnavailable !StreamHistoryUnavailable
  | StreamReprojectionSoftDeleted !StreamName
  | StreamReprojectionTruncated !StreamName !StreamVersion
  | StreamReprojectionForeignEvent !StreamName !StreamVersion
  | StreamReprojectionClearFailed !Text
  | StreamReprojectionClearEvidenceInvalid ![TargetId] ![TargetId]
  | StreamReprojectionDecodeFailed !StreamVersion !ReplayDecodeError
  | StreamReprojectionVerificationFailed !Text
  | StreamReprojectionDedupIdentityUnavailable !DedupKeyId
  | StreamReprojectionHistoryIncomplete !StreamVersion !StreamVersion
  deriving stock (Eq, Show, Generic)

data StreamReprojectionReport = StreamReprojectionReport
  { rebuildGroupId :: !RebuildGroupId,
    projectionId :: !ProjectionId,
    streamName :: !StreamName,
    servingRevisionId :: !ProjectionRevisionId,
    streamVersion :: !StreamVersion,
    maxEvents :: !Int64,
    clearedRows :: ![StreamClearCount],
    replayedEvents :: !Int64,
    appliedEvents :: !Int64,
    dedupInserted :: !Int64,
    dedupExisting :: !Int64,
    verified :: !Bool
  }
  deriving stock (Eq, Show, Generic)

reprojectStream ::
  (Store :> es) =>
  ValidatedProjectionCatalog ->
  StreamReprojectionRequest ->
  Eff es (Either StreamReprojectionError StreamReprojectionReport)
reprojectStream catalog request =
  runTransaction (reprojectStreamTx catalog request)

reprojectStreamTx ::
  ValidatedProjectionCatalog ->
  StreamReprojectionRequest ->
  Tx.Transaction (Either StreamReprojectionError StreamReprojectionReport)
reprojectStreamTx catalog request
  | request ^. #pageSize <= 0 =
      pure (Left (StreamReprojectionInvalidPageSize (request ^. #pageSize)))
  | request ^. #maxEvents <= 0 =
      pure (Left (StreamReprojectionInvalidMaxEvents (request ^. #maxEvents)))
  | otherwise =
      case validateStreamReprojectionAdmission catalog request of
        Left err -> pure (Left err)
        Right () -> lockHistoryAndRepair
  where
    lockHistoryAndRepair = do
      lockedHistory <- lockStreamHistoryForReplayTx (request ^. #streamName)
      case lockedHistory of
        Left unavailable ->
          pure (Left (StreamReprojectionHistoryUnavailable unavailable))
        Right streamInfo
          | isJust (streamInfo ^. #deletedAt) ->
              pure (Left (StreamReprojectionSoftDeleted (request ^. #streamName)))
          | streamInfo ^. #truncateBefore > StreamVersion 0 ->
              pure
                ( Left
                    ( StreamReprojectionTruncated
                        (request ^. #streamName)
                        (streamInfo ^. #truncateBefore)
                    )
                )
          | streamEventCount streamInfo > request ^. #maxEvents ->
              pure
                ( Left
                    ( StreamReprojectionEventLimitExceeded
                        (request ^. #streamName)
                        (streamEventCount streamInfo)
                        (request ^. #maxEvents)
                    )
                )
          | otherwise -> lockGroupAndRepair streamInfo

    streamEventCount streamInfo =
      case streamInfo ^. #version of
        StreamVersion value -> value

    -- Catalog command writers append first and acquire the group shared lock in
    -- their continuation. Taking the stream guard before the exclusive group
    -- fence preserves that global order and lets an already-appended writer
    -- finish instead of forming a row-lock cycle.
    lockGroupAndRepair streamInfo = do
      locked <- lockProjectionGroupForRepairTx catalog (request ^. #rebuildGroupId)
      case locked of
        ProjectionRepairGroupUnregistered groupId ->
          pure (Left (StreamReprojectionGroupUnregistered groupId))
        ProjectionRepairActiveRebuild groupId runId ->
          pure (Left (StreamReprojectionActiveRebuild groupId runId))
        ProjectionRepairGroupUnavailable groupId status readsAllowed writesAllowed ->
          pure (Left (StreamReprojectionGroupUnavailable groupId status readsAllowed writesAllowed))
        ProjectionRepairSliceDrift groupId expected actual ->
          pure (Left (StreamReprojectionSliceDrift groupId expected actual))
        ProjectionRepairServingRevisionUnavailable groupId revisionId ->
          pure (Left (StreamReprojectionServingRevisionUnavailable groupId revisionId))
        ProjectionRepairServingBindingInvalid groupId revisionId detail ->
          pure (Left (StreamReprojectionServingBindingInvalid groupId revisionId detail))
        ProjectionRepairAllowed binding ->
          case binding ^. #writeRevisionId of
            Nothing ->
              pure
                ( Left
                    ( StreamReprojectionGroupUnavailable
                        (request ^. #rebuildGroupId)
                        "unversioned"
                        True
                        True
                    )
                )
            Just revisionId ->
              case catalogStreamScopedReplay catalog revisionId (request ^. #projectionId) of
                Nothing ->
                  pure (Left (StreamReprojectionPolicyUnavailable revisionId (request ^. #projectionId)))
                Just policy -> runPolicy streamInfo binding revisionId policy

    runPolicy streamInfo binding revisionId policy = do
      cleared <-
        (policy ^. #clearStreamRows)
          (binding ^. #writePhysicalTargets)
          (request ^. #streamName)
      case cleared of
        Left detail -> condemned (StreamReprojectionClearFailed detail)
        Right clearCounts ->
          case validateClearEvidence policy (binding ^. #writePhysicalTargets) clearCounts of
            Left err -> condemned err
            Right normalizedCounts ->
              case policyDedupSpecs catalog (request ^. #rebuildGroupId) policy of
                Left err -> condemned err
                Right dedupSpecs -> do
                  replayed <-
                    replayPages
                      policy
                      (binding ^. #writePhysicalTargets)
                      streamInfo
                      dedupSpecs
                      (StreamVersion 0)
                      0
                      0
                      0
                      0
                  case replayed of
                    Left err -> pure (Left err)
                    Right (replayedEvents, appliedEvents, inserted, existing) -> do
                      verification <-
                        (policy ^. #verifyStreamRows)
                          (binding ^. #writePhysicalTargets)
                          (request ^. #streamName)
                      case verification of
                        Left detail -> condemned (StreamReprojectionVerificationFailed detail)
                        Right () ->
                          pure
                            ( Right
                                StreamReprojectionReport
                                  { rebuildGroupId = request ^. #rebuildGroupId,
                                    projectionId = request ^. #projectionId,
                                    streamName = request ^. #streamName,
                                    servingRevisionId = revisionId,
                                    streamVersion = streamInfo ^. #version,
                                    maxEvents = request ^. #maxEvents,
                                    clearedRows = normalizedCounts,
                                    replayedEvents,
                                    appliedEvents,
                                    dedupInserted = inserted,
                                    dedupExisting = existing,
                                    verified = True
                                  }
                            )

    replayPages policy targets streamInfo dedupSpecs cursor replayed applied inserted existing = do
      page <-
        readStreamForwardTx
          (request ^. #streamName)
          cursor
          (request ^. #pageSize)
      if Vector.null page
        then
          if cursor == streamInfo ^. #version
            then pure (Right (replayed, applied, inserted, existing))
            else condemned (StreamReprojectionHistoryIncomplete (streamInfo ^. #version) cursor)
        else do
          let events = Vector.toList page
          case List.find (foreignEvent streamInfo) events of
            Just recorded ->
              condemned
                ( StreamReprojectionForeignEvent
                    (request ^. #streamName)
                    (recorded ^. #streamVersion)
                )
            Nothing -> do
              outcomes <- traverse ((policy ^. #replayStreamEvent) targets) events
              case firstDecodeFailure events outcomes of
                Just err -> condemned err
                Nothing -> do
                  let pairs = dedupPairs dedupSpecs events
                  insertedNow <-
                    if null pairs
                      then pure 0
                      else Tx.statement (Prelude.unzip pairs) insertProjectionDedupBatchStmt
                  let attempted = fromIntegral (length pairs)
                      appliedNow = fromIntegral (length (filter (== Right True) outcomes))
                      nextCursor = last events ^. #streamVersion
                  replayPages
                    policy
                    targets
                    streamInfo
                    dedupSpecs
                    nextCursor
                    (replayed + fromIntegral (length events))
                    (applied + appliedNow)
                    (inserted + insertedNow)
                    (existing + attempted - insertedNow)

    foreignEvent streamInfo recorded =
      recorded ^. #originalStreamId /= streamInfo ^. #id
        || recorded ^. #originalVersion /= recorded ^. #streamVersion

    firstDecodeFailure events outcomes =
      listToMaybe
        [ StreamReprojectionDecodeFailed (recorded ^. #streamVersion) decodeError
        | (recorded, Left decodeError) <- zip events outcomes
        ]

policySource ::
  ValidatedProjectionCatalog ->
  ProjectionId ->
  Maybe (SourceId, SourceScope)
policySource catalog wantedProjection = do
  projection <-
    List.find
      ((== wantedProjection) . (^. #projectionId))
      (catalogInventory catalog ^. #inventoryProjections)
  source <-
    List.find
      ((== projection ^. #sourceId) . (^. #sourceId))
      (catalogInventory catalog ^. #inventorySources)
  pure (source ^. #sourceId, source ^. #sourceScope)

streamMatchesSource :: SourceScope -> StreamName -> Bool
streamMatchesSource AllStreams _ = True
streamMatchesSource (CategorySource category) (StreamName name) =
  categoryText category == Text.takeWhile (/= '-') name
  where
    categoryText = \case
      CategoryName value -> value

validateClearEvidence ::
  StreamScopedReplay ->
  PhysicalTargets ->
  [StreamClearCount] ->
  Either StreamReprojectionError [StreamClearCount]
validateClearEvidence policy targets counts
  | expected /= actual
      || length actualList /= Set.size actual
      || any ((< 0) . (^. #clearedRows)) counts =
      Left
        ( StreamReprojectionClearEvidenceInvalid
            (Set.toAscList expected)
            actualList
        )
  | any (isNothing . (`resolvePhysicalTarget` targets)) actualList =
      Left
        ( StreamReprojectionClearEvidenceInvalid
            (Set.toAscList expected)
            actualList
        )
  | otherwise = Right (List.sortOn (^. #targetId) counts)
  where
    expected = Set.fromList (NonEmpty.toList (policy ^. #streamOwnedTargets))
    actualList = map (^. #targetId) counts
    actual = Set.fromList actualList

policyDedupSpecs ::
  ValidatedProjectionCatalog ->
  RebuildGroupId ->
  StreamScopedReplay ->
  Either StreamReprojectionError [CatalogAsyncDedupSpec]
policyDedupSpecs catalog groupId policy =
  traverse resolve (policy ^. #affectedAsyncDedup)
  where
    available = catalogAsyncIdempotencyKeys catalog groupId
    resolve dedupId =
      maybe
        (Left (StreamReprojectionDedupIdentityUnavailable dedupId))
        Right
        (List.find ((== dedupId) . (^. #specDedupKeyId)) available)

dedupPairs :: [CatalogAsyncDedupSpec] -> [RecordedEvent] -> [(Text, UUID)]
dedupPairs specs events =
  [ (spec ^. #specDedupName, eventIdToUuid ((spec ^. #specIdempotencyKey) recorded))
  | recorded <- events,
    spec <- specs
  ]

eventIdToUuid :: EventId -> UUID
eventIdToUuid (EventId value) = value

condemned :: StreamReprojectionError -> Tx.Transaction (Either StreamReprojectionError value)
condemned err = Tx.condemn $> Left err

-- | Validate the pure catalog and request facts shared by preview and force.
-- The mutation still revalidates all persisted lifecycle and history facts
-- under its transaction locks.
validateStreamReprojectionAdmission ::
  ValidatedProjectionCatalog ->
  StreamReprojectionRequest ->
  Either StreamReprojectionError ()
validateStreamReprojectionAdmission catalog request
  | request ^. #pageSize <= 0 =
      Left (StreamReprojectionInvalidPageSize (request ^. #pageSize))
  | request ^. #maxEvents <= 0 =
      Left (StreamReprojectionInvalidMaxEvents (request ^. #maxEvents))
  | otherwise = do
      projection <-
        maybe
          (Left (StreamReprojectionUnknownProjection (request ^. #projectionId)))
          Right
          ( List.find
              ((== request ^. #projectionId) . (^. #projectionId))
              (catalogInventory catalog ^. #inventoryProjections)
          )
      let declaredGroup = projection ^. #rebuildGroupId
          requestedGroup = request ^. #rebuildGroupId
      if declaredGroup /= requestedGroup
        then
          Left
            ( StreamReprojectionProjectionGroupMismatch
                (request ^. #projectionId)
                declaredGroup
                requestedGroup
            )
        else case policySource catalog (request ^. #projectionId) of
          Nothing -> Left (StreamReprojectionUnknownProjection (request ^. #projectionId))
          Just (sourceId, sourceScope)
            | not (streamMatchesSource sourceScope (request ^. #streamName)) ->
                Left (StreamReprojectionSourceMismatch sourceId (request ^. #streamName))
            | otherwise -> Right ()