packages feed

keiro-dsl-0.17.0.0: test/conformance-process-reactions/Main.hs

module Main (main) where

import Control.Monad (unless)
import Data.Aeson (ToJSON)
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Aeson.Key
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime (..), secondsToDiffTime)
import Data.Time.Calendar (Day (ModifiedJulianDay))
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Data.UUID.V5 qualified as UUID.V5
import Data.Vector qualified as Vector
import Effectful (IOE, liftIO, (:>))
import Generated.ProcessReactions.AuditOnly.Process qualified as Audit
import Generated.ProcessReactions.Incident.Domain qualified as Target
import Generated.ProcessReactions.IncidentReaction.Process
import Generated.ProcessReactions.IncidentSaga.Domain qualified as Saga
import Generated.ProcessReactions.Nominals (IncidentId, Severity (..), incidentIdText, mkIncidentId)
import Generated.ProcessReactions.ScalingReaction.Process qualified as Scaling
import Keiro.Command (defaultRunCommandOptions)
import Keiro.ProcessManager (PMCommand (..))
import Keiro.ProcessManager.Reaction qualified as Reaction
import Keiro.Test.Postgres (StoreRunner (..), withFreshResourceStore, withMigratedSuite)
import Kiroku.Store qualified as Store
import Kiroku.Store.Types (EventId (..), EventType (..), GlobalPosition (..), RecordedEvent (..), StreamId (..), StreamName (..), StreamVersion (..))
import Shibuya.Adapter (Adapter (..))
import Shibuya.Core.Ack (AckDecision (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..))
import Shibuya.Core.Types (Envelope (..))
import Streamly.Data.Stream qualified as Streamly
import System.Environment (getArgs)
import System.IO (hPutStrLn, stderr)

main :: IO ()
main = getArgs >>= \case
  [] -> regularMain
  ["--hydration-probe"] -> hydrationProbeMain
  arguments -> fail ("unexpected arguments: " <> show arguments)

regularMain :: IO ()
regularMain =
  withMigratedSuite $ \fixture ->
    withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runStore) -> do
      incidentId <- either (fail . show) pure (mkIncidentId "inc_01h455vb4pex5vsknk084sn02q")
      let critical = IncidentReported incidentId Sev1
          routine = IncidentReported incidentId Sev2
          noted = IncidentNoted incidentId
          criticalSource = recorded sourceUuid1 1 "IncidentReported" critical
          routineSource = recorded sourceUuid2 2 "IncidentReported" routine
          notedSource = recorded sourceUuid3 3 "IncidentNoted" noted

      assert "guarded Sev1 arm" $ case incidentReactionReact critical of
        Reaction.AdvanceReaction
          { command = Saga.RecordCritical (Saga.RecordCriticalData recordedId),
            followUps = [Reaction.FollowDispatch (PMCommand _ (Target.EscalateIncident (Target.EscalateIncidentData targetId)))],
            onAccepted = []
          } -> recordedId == incidentId && targetId == incidentId
        _ -> False
      assert "routine otherwise arm" $ case incidentReactionReact routine of
        Reaction.AdvanceReaction
          { command = Saga.RecordRoutine (Saga.RecordRoutineData recordedId),
            followUps = [],
            onAccepted = []
          } -> recordedId == incidentId
        _ -> False
      assert "no-action input" $ case incidentReactionReact noted of
        Reaction.NoAdvance [] -> True
        _ -> False
      assert "timer-free audit process" $ case Audit.auditOnlyReact (Audit.IncidentNoted incidentId) of
        Reaction.NoAdvance [] -> True
        _ -> False

      acknowledgements <- newIORef []
      expectRight
        =<< runStore
          ( incidentReactionRunProcessWorker
              defaultRunCommandOptions
              (inMemoryAdapter "incident-reaction-conformance" acknowledgements [criticalSource, routineSource, notedSource, criticalSource])
          )
      readIORef acknowledgements >>= assert "guard variants, no-action, and duplicate acknowledge" . (== replicate 4 AckOk)

      auditAcks <- newIORef []
      expectRight
        =<< runStore
          ( Audit.auditOnlyRunProcessWorker
              defaultRunCommandOptions
              (inMemoryAdapter "audit-only-conformance" auditAcks [recorded sourceUuid4 4 "IncidentNoted" (Audit.IncidentNoted incidentId)])
          )
      readIORef auditAcks >>= assert "timer-free no-action worker acknowledges" . (== [AckOk])

      let key = incidentIdText incidentId
      sagaEvents <- expectRight =<< runStore (Store.readStreamForward (StreamName ("incidentSaga-" <> key)) (StreamVersion 0) 10)
      targetEvents <- expectRight =<< runStore (Store.readStreamForward (StreamName ("incident-" <> key)) (StreamVersion 0) 10)
      auditEvents <- expectRight =<< runStore (Store.readStreamForward (StreamName ("incidentAudit-" <> key)) (StreamVersion 0) 10)
      assert "both guarded variants append saga events exactly once" (Vector.length sagaEvents == 2)
      assert "only Sev1 dispatches" (Vector.length targetEvents == 1)
      assert "no-action process appends nothing" (Vector.null auditEvents)

      duplicate <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentReactionProcessManager criticalSource critical)
      assert "public once runner observes duplicate witness" $ case duplicate of
        Right result -> case Reaction.managerResult result of
          Reaction.ReactionDuplicate {} -> True
          _ -> False
        Left _ -> False
      putStrLn "process reaction conformance: PASS"

hydrationProbeMain :: IO ()
hydrationProbeMain =
  withMigratedSuite $ \fixture ->
    withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runStore) -> do
      emptyId <- either (fail . show) pure (mkIncidentId "inc_01h455vb4pex5vsknk084sn09z")
      let noAdvance = IncidentNoted emptyId
          noAdvanceSource = recorded sourceUuid2 2 "IncidentNoted" noAdvance
          run input event = expectRight =<< (expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentReactionProcessManager event input))
          runScaling input event = do
            _ <- expectRight =<< (expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions Scaling.scalingReactionProcessManager event input))
            pure ()
          bracketCase label action = do
            hPutStrLn stderr ("reaction-case-start " <> Text.unpack label)
            result <- action
            hPutStrLn stderr ("reaction-case-end " <> Text.unpack label)
            pure result
      mapM_ (probeFanOutCase bracketCase runScaling) [(8, False), (32, False), (128, False), (8, True), (32, True), (128, True)]
      _ <- bracketCase "no-advance" (run noAdvance noAdvanceSource)
      putStrLn "hydration probe conformance: PASS fan-out=8,32,128 shapes=same,distinct"

probeFanOutCase :: (Text -> IO () -> IO ()) -> (Scaling.ScalingReactionInput -> RecordedEvent -> IO ()) -> (Int, Bool) -> IO ()
probeFanOutCase bracketCase runScaling (count, distinct) = do
  correlation <- scalingId (1000 + shapeOffset)
  targets <- traverse scalingId [targetOffset + 1 .. targetOffset + count]
  input <- scalingInput count distinct correlation targets
  let shape = if distinct then "distinct" else "same"
      coordinate = "fanout-" <> Text.pack (show count) <> "-" <> shape
      source = recorded (probeSourceUuid coordinate) (3000 + shapeOffset) ("FanOut" <> Text.pack (show count) <> if distinct then "Distinct" else "Same") input
  bracketCase (coordinate <> "-first-delivery") (runScaling input source)
  bracketCase (coordinate <> "-accepted-redelivery") (runScaling input source)
  where
    shapeOffset = count + if distinct then 500 else 0
    targetOffset = 10000 + shapeOffset * 200

scalingInput :: Int -> Bool -> IncidentId -> [IncidentId] -> IO Scaling.ScalingReactionInput
scalingInput count distinct correlation targets =
  case Aeson.fromJSON encoded of
    Aeson.Error problem -> fail problem
    Aeson.Success input -> pure input
  where
    shape = if distinct then "Distinct" else "Same"
    tag = "FanOut" <> Text.pack (show count) <> shape
    targetFields =
      if distinct
        then zipWith (\index target -> Aeson.Key.fromText ("target" <> Text.justifyRight 3 '0' (Text.pack (show index))) Aeson..= target) [1 :: Int ..] targets
        else []
    encoded = Aeson.object (("tag" Aeson..= tag) : ("incidentId" Aeson..= correlation) : targetFields)

scalingId :: Int -> IO IncidentId
scalingId value = either (fail . show) pure (mkIncidentId ("inc_01h455vb4pex5vsknk084s" <> Text.justifyRight 4 '0' (base32 value)))

base32 :: Int -> Text
base32 value
  | value < 32 = Text.singleton (Text.index alphabet value)
  | otherwise = base32 (value `div` 32) <> Text.singleton (Text.index alphabet (value `mod` 32))
  where
    alphabet = "0123456789abcdefghjkmnpqrstvwxyz"

probeSourceUuid :: Text -> UUID
probeSourceUuid label = UUID.V5.generateNamed UUID.V5.namespaceURL (map (fromIntegral . fromEnum) (Text.unpack label))

recorded :: (ToJSON input) => UUID -> Int -> Text -> input -> RecordedEvent
recorded eventUuid position eventName input =
  RecordedEvent
    { eventId = EventId eventUuid,
      eventType = EventType eventName,
      streamVersion = StreamVersion (fromIntegral position),
      globalPosition = GlobalPosition (fromIntegral position),
      originalStreamId = StreamId 1,
      originalVersion = StreamVersion (fromIntegral position),
      payload = Aeson.toJSON input,
      metadata = Nothing,
      causationId = Nothing,
      correlationId = Nothing,
      createdAt = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime (fromIntegral position))
    }

sourceUuid1, sourceUuid2, sourceUuid3, sourceUuid4 :: UUID
sourceUuid1 = staticUuid "123e4567-e89b-72d3-a456-426614174001"
sourceUuid2 = staticUuid "123e4567-e89b-72d3-a456-426614174002"
sourceUuid3 = staticUuid "123e4567-e89b-72d3-a456-426614174003"
sourceUuid4 = staticUuid "123e4567-e89b-72d3-a456-426614174004"

staticUuid :: String -> UUID
staticUuid value = case UUID.fromString value of
  Just parsed -> parsed
  Nothing -> error "invalid static source UUID"

inMemoryAdapter :: (IOE :> es) => Text -> IORef [AckDecision] -> [msg] -> Adapter es msg
inMemoryAdapter name decisions messages =
  Adapter
    { adapterName = name,
      source = Streamly.fromList (map ingest messages),
      shutdown = pure ()
    }
  where
    ingest message =
      Ingested
        { envelope = Envelope "message" Nothing Nothing Nothing Nothing Nothing Nothing mempty message,
          ack = AckHandle (\decision -> liftIO (modifyIORef' decisions (<> [decision]))),
          lease = Nothing
        }

expectRight :: (Show problem) => Either problem value -> IO value
expectRight = either (error . show) pure

assert :: String -> Bool -> IO ()
assert label condition = unless condition (error ("process reaction conformance failed: " <> label))