packages feed

keiro-0.6.0.0: src/Keiro/ReplayAudit.hs

{-# LANGUAGE GADTs #-}

-- | Read-only, differential replay audits for aggregate event streams.
--
-- Routine deploys should first consume @keiro-dsl diff@'s replay-impact verdict:
-- a replay-neutral deploy touches no data, while an affected verdict supplies the
-- event types for 'AuditTargeted'. 'AuditFull' is intentionally reserved for
-- one-time runtime cutovers and forensics.
--
-- Selection is read-only and server-side. It uses Kiroku's indexed category,
-- event-type, and global-position schema to discover only candidate stream names;
-- each candidate is then hydrated through the public Store effect. The audit
-- never appends events, calls @verifyAndSnapshot@, or writes snapshots.
--
-- Hand-written services have no spec from which to derive an affected set. They
-- must supply a conservative set explicitly or choose 'AuditFull'.
module Keiro.ReplayAudit
  ( AuditMode (..),
    AffectedSet (..),
    AuditBudget (..),
    defaultAuditBudget,
    AuditTarget (..),
    SomeAuditTarget (..),
    streamInCategory,
    AuditOutcome (..),
    StreamAuditResult (..),
    AuditReport (..),
    auditStream,
    auditStreams,
    auditTargets,
    renderAuditReport,
    auditExitCode,
  )
where

import Contravariant.Extras (contrazip3, contrazip5)
import Data.Int (Int32)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Effectful.Concurrent (runConcurrent)
import Effectful.Concurrent.Async qualified as Async
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiki.Core (BoolAlg, RegFile)
import Keiro.Command
  ( CommandError,
    Hydrated (..),
    RunCommandOptions,
    defaultRunCommandOptions,
    hydrateFull,
    hydrateSeeded,
  )
import Keiro.EventStream (EventStream, StateCodec)
import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)
import Keiro.Prelude
import Keiro.ReplayDigest (canonicalJsonBytes, replayDigest)
import Keiro.Snapshot (SnapshotLookup (..), lookupSnapshotSeed)
import Keiro.Stream (Stream (..))
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Read (lookupStreamNames)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types
  ( CategoryName (..),
    EventType (..),
    GlobalPosition (..),
    StreamId (..),
    StreamName (..),
    StreamVersion,
  )
import Kiroku.Store.Types qualified as StoreTypes
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified

-- | Which streams to inspect.
data AuditMode
  = AuditFull
  | AuditTargeted !AffectedSet
  deriving stock (Eq, Show, Generic)

-- | Conservative stored-data surface emitted by the DSL replay-impact diff.
data AffectedSet = AffectedSet
  { affectedEventTypes :: !(Set EventType),
    includeSnapshotStreams :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | Per-run cost and resume controls.
--
-- The checkpoint is the maximum global position of the last audited stream at
-- the selection snapshot. Re-running with that checkpoint selects only streams
-- whose latest event lies after it. A category that receives new events during
-- or after a run may intentionally cause a previously checked stream to be
-- selected again: its durable history changed and warrants another verdict.
data AuditBudget = AuditBudget
  { maxStreams :: !(Maybe Int),
    parallelism :: !Int,
    resumeFrom :: !(Maybe GlobalPosition)
  }
  deriving stock (Eq, Show, Generic)

defaultAuditBudget :: AuditBudget
defaultAuditBudget =
  AuditBudget
    { maxStreams = Nothing,
      parallelism = 4,
      resumeFrom = Nothing
    }

-- | One typed aggregate/category assembly.
data AuditTarget phi rs s ci co = AuditTarget
  { eventStream :: !(ValidatedEventStream phi rs s ci co),
    category :: !Text,
    mkStream :: !(StreamName -> Maybe (Stream (EventStream phi rs s ci co)))
  }
  deriving stock (Generic)

-- | Existential packaging for services with multiple aggregate types.
data SomeAuditTarget where
  SomeAuditTarget ::
    (BoolAlg phi (RegFile rs, ci), Eq co) =>
    AuditTarget phi rs s ci co ->
    SomeAuditTarget

-- | Accept a raw store name only when it belongs to the expected category.
streamInCategory :: Text -> StreamName -> Maybe (Stream eventStream)
streamInCategory expected streamName =
  case StoreTypes.categoryName streamName of
    CategoryName actual
      | actual == expected -> Just (Stream streamName)
    _ -> Nothing

-- | Replay result for one accepted stream name.
data AuditOutcome
  = ReplayOk
      { streamVersion :: !StreamVersion,
        digest :: !(Maybe Text)
      }
  | ReplayFailed
      { commandError :: !CommandError
      }
  | SeedDivergence
      { seedVersion :: !StreamVersion,
        seededDigest :: !Text,
        fullDigest :: !Text
      }
  deriving stock (Eq, Show, Generic)

data StreamAuditResult = StreamAuditResult
  { streamName :: !StreamName,
    outcome :: !AuditOutcome
  }
  deriving stock (Eq, Show, Generic)

data AuditReport = AuditReport
  { targetCategory :: !Text,
    mode :: !Text,
    results :: ![StreamAuditResult],
    rejectedStreams :: ![StreamName],
    streamsSelected :: !Int,
    streamsSkipped :: !Int,
    failures :: !Int,
    divergences :: !Int,
    checkpoint :: !(Maybe GlobalPosition)
  }
  deriving stock (Eq, Show, Generic)

auditStream ::
  forall phi rs s ci co es.
  (Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
  AuditTarget phi rs s ci co ->
  Stream (EventStream phi rs s ci co) ->
  Eff es AuditOutcome
auditStream target stream = do
  let raw = unvalidated (target ^. #eventStream)
      options = defaultRunCommandOptions
  hydrateFull options raw stream >>= \case
    Left err -> pure (ReplayFailed err)
    Right full ->
      case raw ^. #stateCodec of
        Nothing ->
          pure
            ReplayOk
              { streamVersion = full ^. #streamVersion,
                digest = Nothing
              }
        Just codec -> auditSeed options raw codec full
  where
    auditSeed ::
      RunCommandOptions ->
      EventStream phi rs s ci co ->
      StateCodec (s, RegFile rs) ->
      Hydrated rs s ->
      Eff es AuditOutcome
    auditSeed options raw codec full = do
      let name = (raw ^. #resolveStreamName) stream
          fullValue = (codec ^. #encode) (full ^. #state, full ^. #registers)
          fullBytes = canonicalJsonBytes fullValue
          fullHash = replayDigest fullValue
      lookupSnapshotSeed name codec >>= \case
        SnapshotUnavailable _ ->
          pure
            ReplayOk
              { streamVersion = full ^. #streamVersion,
                digest = Just fullHash
              }
        SnapshotHit seed ->
          hydrateSeeded
            options
            raw
            stream
            (seed ^. #state)
            (seed ^. #registers)
            (seed ^. #streamVersion)
            >>= \case
              Left err ->
                pure
                  SeedDivergence
                    { seedVersion = seed ^. #streamVersion,
                      seededDigest = "replay-failed:" <> Text.pack (show err),
                      fullDigest = fullHash
                    }
              Right seeded ->
                let seededValue =
                      (codec ^. #encode)
                        (seeded ^. #state, seeded ^. #registers)
                    seededHash = replayDigest seededValue
                 in if canonicalJsonBytes seededValue == fullBytes
                      then
                        pure
                          ReplayOk
                            { streamVersion = full ^. #streamVersion,
                              digest = Just fullHash
                            }
                      else
                        pure
                          SeedDivergence
                            { seedVersion = seed ^. #streamVersion,
                              seededDigest = seededHash,
                              fullDigest = fullHash
                            }

auditStreams ::
  forall phi rs s ci co es.
  (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
  AuditMode ->
  AuditBudget ->
  AuditTarget phi rs s ci co ->
  Eff es AuditReport
auditStreams auditMode budget target = do
  totalStreams <- categoryStreamCount (target ^. #category)
  selectedTotal <- selectedStreamCount auditMode (target ^. #category)
  let skipped =
        case auditMode of
          AuditFull -> 0
          AuditTargeted _ -> Prelude.max 0 (totalStreams Prelude.- selectedTotal)
      workerCount = Prelude.max 1 (budget ^. #parallelism)
      remaining0 = fmap (Prelude.max 0) (budget ^. #maxStreams)
      cursor0 = fromMaybe (GlobalPosition 0) (budget ^. #resumeFrom)
  (allResults, rejected, selected, finalCursor) <-
    go workerCount remaining0 cursor0 [] [] 0
  let failureCount =
        Prelude.length rejected
          Prelude.+ Prelude.length
            [ ()
            | StreamAuditResult {outcome = ReplayFailed {}} <- allResults
            ]
      divergenceCount =
        Prelude.length
          [ ()
          | StreamAuditResult {outcome = SeedDivergence {}} <- allResults
          ]
  pure
    AuditReport
      { targetCategory = target ^. #category,
        mode = case auditMode of AuditFull -> "full"; AuditTargeted _ -> "targeted",
        results = allResults,
        rejectedStreams = rejected,
        streamsSelected = selected,
        streamsSkipped = skipped,
        failures = failureCount,
        divergences = divergenceCount,
        checkpoint =
          if selected == 0
            then budget ^. #resumeFrom
            else Just finalCursor
      }
  where
    pageSize = 128

    go ::
      Int ->
      Maybe Int ->
      GlobalPosition ->
      [StreamAuditResult] ->
      [StreamName] ->
      Int ->
      Eff es ([StreamAuditResult], [StreamName], Int, GlobalPosition)
    go workerCount remaining cursor resultAcc rejectedAcc selectedAcc
      | Just 0 <- remaining =
          pure
            ( Prelude.reverse resultAcc,
              Prelude.reverse rejectedAcc,
              selectedAcc,
              cursor
            )
      | otherwise = do
          let requestSize =
                maybe pageSize (Prelude.min pageSize) remaining
          page <- selectStreamPage auditMode (target ^. #category) cursor requestSize
          if Vector.null page
            then
              pure
                ( Prelude.reverse resultAcc,
                  Prelude.reverse rejectedAcc,
                  selectedAcc,
                  cursor
                )
            else do
              names <- lookupStreamNames (Prelude.fst <$> Vector.toList page)
              let resolved =
                    [ (streamName, streamValue)
                    | (streamId, _watermark) <- Vector.toList page,
                      Just streamName <- [Map.lookup streamId names],
                      Just streamValue <- [(target ^. #mkStream) streamName]
                    ]
                  acceptedNames = Set.fromList (Prelude.fst <$> resolved)
                  pageNames =
                    [ streamName
                    | (streamId, _watermark) <- Vector.toList page,
                      Just streamName <- [Map.lookup streamId names]
                    ]
                  rejected =
                    Prelude.filter (`Set.notMember` acceptedNames) pageNames
              audited <-
                runConcurrent
                  $ Async.pooledMapConcurrentlyN
                    workerCount
                    ( \(name, streamValue) ->
                        StreamAuditResult name <$> auditStream target streamValue
                    )
                    resolved
              let nextCursor = Prelude.snd (Vector.last page)
                  pageCount = Vector.length page
                  nextRemaining = (Prelude.- pageCount) <$> remaining
              go
                workerCount
                nextRemaining
                nextCursor
                (Prelude.reverse audited <> resultAcc)
                (Prelude.reverse rejected <> rejectedAcc)
                (selectedAcc Prelude.+ pageCount)

auditTargets ::
  (IOE :> es, Store :> es) =>
  AuditMode ->
  AuditBudget ->
  [SomeAuditTarget] ->
  Eff es [AuditReport]
auditTargets auditMode budget =
  traverse $ \(SomeAuditTarget target) -> auditStreams auditMode budget target

renderAuditReport :: AuditReport -> Text
renderAuditReport report =
  Text.intercalate
    " "
    [ "replay-audit",
      "category=" <> report ^. #targetCategory,
      "mode=" <> report ^. #mode,
      "selected=" <> textShow (report ^. #streamsSelected),
      "skipped=" <> textShow (report ^. #streamsSkipped),
      "failures=" <> textShow (report ^. #failures),
      "divergences=" <> textShow (report ^. #divergences),
      "checkpoint=" <> maybe "none" textShow (report ^. #checkpoint)
    ]
  where
    textShow :: (Show a) => a -> Text
    textShow = Text.pack . show

auditExitCode :: [AuditReport] -> Int
auditExitCode reports
  | Prelude.any
      (\report -> report ^. #failures > 0 Prelude.|| report ^. #divergences > 0)
      reports =
      1
  | otherwise = 0

-- Selection -----------------------------------------------------------------

categoryStreamCount :: (Store :> es) => Text -> Eff es Int
categoryStreamCount category =
  Prelude.fromIntegral <$> runTransaction (Tx.statement category categoryStreamCountStmt)

selectedStreamCount :: (Store :> es) => AuditMode -> Text -> Eff es Int
selectedStreamCount auditMode category =
  case auditMode of
    AuditFull -> categoryStreamCount category
    AuditTargeted affected ->
      Prelude.fromIntegral
        <$> runTransaction
          ( Tx.statement
              (category, eventTypeTexts affected, affected ^. #includeSnapshotStreams)
              targetedStreamCountStmt
          )

selectStreamPage ::
  (Store :> es) =>
  AuditMode ->
  Text ->
  GlobalPosition ->
  Int ->
  Eff es (Vector (StreamId, GlobalPosition))
selectStreamPage auditMode category (GlobalPosition cursor) limit =
  case auditMode of
    AuditFull ->
      runTransaction
        $ Tx.statement
          (category, cursor, Prelude.fromIntegral limit)
          fullStreamPageStmt
    AuditTargeted affected ->
      runTransaction
        $ Tx.statement
          ( category,
            eventTypeTexts affected,
            affected ^. #includeSnapshotStreams,
            cursor,
            Prelude.fromIntegral limit
          )
          targetedStreamPageStmt

eventTypeTexts :: AffectedSet -> Vector Text
eventTypeTexts affected =
  Vector.fromList
    [ text
    | EventType text <- Set.toAscList (affected ^. #affectedEventTypes)
    ]

categoryStreamCountStmt :: Statement Text Int64
categoryStreamCountStmt =
  preparable
    """
    SELECT count(*)
    FROM kiroku.streams
    WHERE category = $1
      AND stream_version > 0
    """
    (E.param (E.nonNullable E.text))
    (D.singleRow (D.column (D.nonNullable D.int8)))

fullStreamPageStmt :: Statement (Text, Int64, Int32) (Vector (StreamId, GlobalPosition))
fullStreamPageStmt =
  preparable
    """
    SELECT s.stream_id, max(all_events.stream_version) AS watermark
    FROM kiroku.streams s
    JOIN kiroku.stream_events all_events
      ON all_events.stream_id = 0
     AND all_events.original_stream_id = s.stream_id
    WHERE s.category = $1
    GROUP BY s.stream_id
    HAVING max(all_events.stream_version) > $2
    ORDER BY watermark ASC
    LIMIT $3
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.int4))
    )
    streamPageDecoder

targetedStreamCountStmt :: Statement (Text, Vector Text, Bool) Int64
targetedStreamCountStmt =
  preparable
    (targetedSelectionCte <> "SELECT count(*) FROM selected")
    targetedSelectionEncoder
    (D.singleRow (D.column (D.nonNullable D.int8)))

targetedStreamPageStmt ::
  Statement
    (Text, Vector Text, Bool, Int64, Int32)
    (Vector (StreamId, GlobalPosition))
targetedStreamPageStmt =
  preparable
    ( targetedSelectionCte
        <> """
           SELECT stream_id, watermark
           FROM selected
           WHERE watermark > $4
           ORDER BY watermark ASC
           LIMIT $5
           """
    )
    ( contrazip5
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
        (E.param (E.nonNullable E.bool))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.int4))
    )
    streamPageDecoder

targetedSelectionEncoder :: E.Params (Text, Vector Text, Bool)
targetedSelectionEncoder =
  contrazip3
    (E.param (E.nonNullable E.text))
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    (E.param (E.nonNullable E.bool))

targetedSelectionCte :: Text
targetedSelectionCte =
  """
  WITH candidate_streams AS (
    SELECT DISTINCT s.stream_id
    FROM kiroku.streams s
    JOIN kiroku.stream_events source_events
      ON source_events.stream_id = 0
     AND source_events.original_stream_id = s.stream_id
    JOIN kiroku.events e ON e.event_id = source_events.event_id
    WHERE s.category = $1
      AND e.event_type = ANY($2::text[])
    UNION
    SELECT s.stream_id
    FROM kiroku.streams s
    JOIN keiro.keiro_snapshots snapshots ON snapshots.stream_id = s.stream_id
    WHERE $3
      AND s.category = $1
  ),
  selected AS (
    SELECT candidates.stream_id, max(all_events.stream_version) AS watermark
    FROM candidate_streams candidates
    JOIN kiroku.stream_events all_events
      ON all_events.stream_id = 0
     AND all_events.original_stream_id = candidates.stream_id
    GROUP BY candidates.stream_id
  )
  """

streamPageDecoder :: D.Result (Vector (StreamId, GlobalPosition))
streamPageDecoder =
  D.rowVector
    $ (,)
    <$> (StreamId <$> D.column (D.nonNullable D.int8))
    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))