packages feed

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

module Main (main) where

import Control.Monad (forM_, unless)
import Data.Aeson qualified as Aeson
import Data.ByteString qualified as BS
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time (UTCTime (..), addUTCTime, getCurrentTime, secondsToDiffTime)
import Data.Time.Calendar (Day (ModifiedJulianDay))
import Data.UUID qualified as UUID
import Data.UUID.V5 qualified as UUID.V5
import Data.Vector qualified as Vector
import Generated.ProcessTimers.Incident.Codec (incidentCodec)
import Generated.ProcessTimers.Incident.Domain (IncidentEscalatedData (..), IncidentEvent (..))
import Generated.ProcessTimers.IncidentTimers.Process
import Generated.ProcessTimers.Nominals (mkIncidentId)
import Keiro.Codec (decodeRecorded)
import Keiro.Command (defaultRunCommandOptions)
import Keiro.DeterministicId (identitySeedBytes)
import Keiro.ProcessManager.Reaction qualified as Reaction
import Keiro.Test.Postgres (Fixture, StoreRunner (..), withFreshResourceStore, withMigratedSuite)
import Keiro.Timer (TimerId (..), TimerRequest (..), TimerRow (..), TimerStatus (..), claimDueTimer, lookupTimer, markTimerFired, runTimerWorkerWith)
import Kiroku.Store qualified as Store
import Kiroku.Store.Types (EventId (..), EventType (..), GlobalPosition (..), RecordedEvent (..), StreamId (..), StreamName (..), StreamVersion (..))

main :: IO ()
main =
  withMigratedSuite $ \fixture -> do
    identityVectorCase
    withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runStore) -> do
      incidentId <- either (fail . show) pure (mkIncidentId "inc_01h455vb4pex5vsknk084sn02q")
      let firstInput = IncidentReported incidentId firstObserved "first"
          secondInput = IncidentReported incidentId secondObserved "second"
          firstEvent = recorded sourceUuid1 1 firstInput
          secondEvent = recorded sourceUuid2 2 secondInput
          escalationAt observed = addUTCTime 300 observed
          reminderAt observed = addUTCTime 3600 observed
          firstEscalation = incidentTimersEscalationTimerRequest "inc_01h455vb4pex5vsknk084sn02q" (escalationAt firstObserved) incidentId "first"
          firstReminder = incidentTimersReminderTimerRequest "inc_01h455vb4pex5vsknk084sn02q" (reminderAt firstObserved) incidentId

      first <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager firstEvent firstInput)
      firstResult <- expectRight first
      assert "first delivery committed both timer statements" (firstResult.timerEffects == Reaction.ReactionTimerEffects 2 1 0)

      escalation1 <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstEscalation.timerId))
      reminder1 <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstReminder.timerId))
      assert "rearm timer used the first deadline" (escalation1.fireAt == escalationAt firstObserved)
      assert "once timer used the first deadline" (reminder1.fireAt == reminderAt firstObserved)
      assert "dynamic payload round-trips" $ case Aeson.fromJSON escalation1.payload of
        Aeson.Success EscalationPayload {detail = "first"} -> True
        _ -> False
      assert "timer identity prefix is pinned" (firstEscalation.timerId == expectedEscalationTimerId)

      second <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager secondEvent secondInput)
      secondResult <- expectRight second
      assert "second accepted source attempted both schedules" (secondResult.timerEffects == Reaction.ReactionTimerEffects 2 0 0)
      escalation2 <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstEscalation.timerId))
      reminder2 <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstReminder.timerId))
      assert "rearm moved the still-scheduled deadline" (escalation2.fireAt == escalationAt secondObserved)
      assert "once preserved the original deadline" (reminder2.fireAt == reminderAt firstObserved)

      replay <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager secondEvent secondInput)
      replayResult <- expectRight replay
      assert "accepted replay skipped timer SQL" (replayResult.timerEffects == Reaction.ReactionTimerEffects 0 0 0)

      cancelled <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid3 3 (ResponderAcked incidentId)) (ResponderAcked incidentId))
      cancelledResult <- expectRight cancelled
      assert "cancel reports its committed and changed counts" (cancelledResult.timerEffects == Reaction.ReactionTimerEffects 1 0 1)
      reminderCancelled <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstReminder.timerId))
      assert "cancelled timer is terminal" (reminderCancelled.status == Cancelled)

      claimedEscalation <- requireTimer =<< (expectRight =<< runStore (claimDueTimer (addUTCTime 1 escalation2.fireAt)))
      assert "due escalation is claimed before firing" (claimedEscalation.status == Firing)
      fired <- expectRight =<< runStore (incidentTimersFireTimer defaultRunCommandOptions claimedEscalation)
      assert "generated firer returns its deterministic event id" (fired /= Nothing)
      redelivered <- expectRight =<< runStore (incidentTimersFireTimer defaultRunCommandOptions claimedEscalation)
      assert "timer fire redelivery confirms the target witness" (redelivered == fired)
      firedId <- maybe (fail "expected escalation fired event id") pure fired
      marked <- expectRight =<< runStore (markTimerFired firstEscalation.timerId firedId)
      assert "claimed escalation reaches terminal fired state" marked

      let thirdInput = IncidentReported incidentId (addUTCTime 600 secondObserved) "third"
      third <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid4 4 thirdInput) thirdInput)
      thirdResult <- expectRight third
      assert "terminal reschedule attempts still commit both statements" (thirdResult.timerEffects == Reaction.ReactionTimerEffects 2 0 0)
      escalationTerminal <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstEscalation.timerId))
      reminderTerminal <- requireTimer =<< (expectRight =<< runStore (lookupTimer firstReminder.timerId))
      assert "rearm does not revive a fired timer" (escalationTerminal.status == Fired && escalationTerminal.fireAt == escalation2.fireAt)
      assert "schedule once does not revive a cancelled timer" (reminderTerminal.status == Cancelled && reminderTerminal.fireAt == reminder1.fireAt)

      targetEvents <- expectRight =<< runStore (Store.readStreamForward (StreamName "incident-inc_01h455vb4pex5vsknk084sn02q") (StreamVersion 0) 10)
      assert "timer firing appends once" (Vector.length targetEvents == 1)
      assert "escalation timer fires the declared command" $
        traverse (decodeRecorded incidentCodec) (Vector.toList targetEvents)
          == Right [IncidentEscalated (IncidentEscalatedData incidentId)]
    absentCancelCase fixture
    claimedCancelRaceCase fixture
    ceilingCase fixture
    putStrLn "process timer conformance: PASS"

identityVectorCase :: IO ()
identityVectorCase = do
  UUID.toText (reactionIdentity "incident-escalation-timer:" "inc_01h455vb4pex5vsknk084sn02q")
    `assertEqual` "58406c5e-b75e-515c-8a0c-30a4266b4b89"
  UUID.toText (reactionIdentity "incident-escalation-timer:" "\x0101")
    `assertEqual` "be5128ce-0df9-52a8-b2c8-5bd54ced1280"
  UUID.toText (reactionIdentity "incident-escalation-timer:" "\SOH")
    `assertEqual` "77aba1e9-500c-5e71-ae70-5194c47e5a30"
  oldIdentity "\x0101" `assertEqual` oldIdentity "\SOH"
  reactionIdentity "incident-escalation-timer:" "\x0101"
    `assertNotEqual` reactionIdentity "incident-escalation-timer:" "\SOH"
  where
    oldIdentity correlation =
      UUID.V5.generateNamed
        UUID.V5.namespaceURL
        (map (fromIntegral . fromEnum) ("incident-escalation-timer:" <> correlation))

reactionIdentity :: T.Text -> T.Text -> UUID.UUID
reactionIdentity prefix correlation =
  UUID.V5.generateNamed UUID.V5.namespaceURL (identitySeedBytes (T.concat (map field [prefix, correlation])))
  where
    field value = T.pack (show (BS.length (encodeUtf8 value))) <> ":" <> value

assertEqual :: (Eq a, Show a) => a -> a -> IO ()
assertEqual actual expected = unless (actual == expected) (error ("expected " <> show expected <> ", got " <> show actual))

assertNotEqual :: (Eq a, Show a) => a -> a -> IO ()
assertNotEqual left right = unless (left /= right) (error ("expected distinct values, got " <> show left))

absentCancelCase :: Fixture -> IO ()
absentCancelCase fixture =
  withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runStore) -> do
    incidentId <- either (fail . show) pure (mkIncidentId "inc_01h455vb4pex5vsknk084sn04s")
    let request = incidentTimersReminderTimerRequest "inc_01h455vb4pex5vsknk084sn04s" (addUTCTime 3600 firstObserved) incidentId
        acknowledged = ResponderAcked incidentId
    cancelled <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid5 5 acknowledged) acknowledged)
    cancelledResult <- expectRight cancelled
    assert "absent cancel commits a statement without changing a row" (cancelledResult.timerEffects == Reaction.ReactionTimerEffects 1 0 0)
    absent <- expectRight =<< runStore (lookupTimer request.timerId)
    assert "absent cancel creates no tombstone" (absent == Nothing)
    let reported = IncidentReported incidentId firstObserved "after-absent-cancel"
    scheduled <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid6 6 reported) reported)
    scheduledResult <- expectRight scheduled
    assert "once schedule inserts after absent cancel" (scheduledResult.timerEffects == Reaction.ReactionTimerEffects 2 1 0)
    _ <- requireTimer =<< (expectRight =<< runStore (lookupTimer request.timerId))
    pure ()

claimedCancelRaceCase :: Fixture -> IO ()
claimedCancelRaceCase fixture =
  withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runStore) -> do
    incidentId <- either (fail . show) pure (mkIncidentId "inc_01h455vb4pex5vsknk084sn05t")
    let key = "inc_01h455vb4pex5vsknk084sn05t"
        reported = IncidentReported incidentId firstObserved "claim-race"
        escalation = incidentTimersEscalationTimerRequest key (addUTCTime 300 firstObserved) incidentId "claim-race"
        reminder = incidentTimersReminderTimerRequest key (addUTCTime 3600 firstObserved) incidentId
    scheduled <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid7 7 reported) reported)
    _ <- expectRight scheduled
    claimedEscalation <- requireTimer =<< (expectRight =<< runStore (claimDueTimer (addUTCTime 400 firstObserved)))
    escalationEvent <- requireEventId =<< (expectRight =<< runStore (incidentTimersFireTimer defaultRunCommandOptions claimedEscalation))
    escalationMarked <- expectRight =<< runStore (markTimerFired escalation.timerId escalationEvent)
    assert "race setup marks escalation fired" escalationMarked
    claimedReminder <- requireTimer =<< (expectRight =<< runStore (claimDueTimer (addUTCTime 4000 firstObserved)))
    assert "reminder is already claimed before cancellation" (claimedReminder.timerId == reminder.timerId && claimedReminder.status == Firing)
    let acknowledged = ResponderAcked incidentId
    cancelled <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid8 8 acknowledged) acknowledged)
    cancelledResult <- expectRight cancelled
    assert "cancel can race an already claimed timer" (cancelledResult.timerEffects == Reaction.ReactionTimerEffects 1 0 1)
    reminderEvent <- requireEventId =<< (expectRight =<< runStore (incidentTimersFireTimer defaultRunCommandOptions claimedReminder))
    reminderMarked <- expectRight =<< runStore (markTimerFired reminder.timerId reminderEvent)
    assert "cancelled row remains terminal after the claimed fire attempt" (not reminderMarked)
    reminderTerminal <- requireTimer =<< (expectRight =<< runStore (lookupTimer reminder.timerId))
    assert "claimed cancel race leaves the timer cancelled" (reminderTerminal.status == Cancelled)
    targetEvents <- expectRight =<< runStore (Store.readStreamForward (StreamName ("incident-" <> key)) (StreamVersion 0) 10)
    assert "claimed cancel race is harmless under the target state guard" (Vector.length targetEvents == 1)

ceilingCase :: Fixture -> IO ()
ceilingCase fixture =
  withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runStore) -> do
    incidentId <- either (fail . show) pure (mkIncidentId "inc_01h455vb4pex5vsknk084sn06v")
    let key = "inc_01h455vb4pex5vsknk084sn06v"
        reported = IncidentReported incidentId firstObserved "ceiling"
        escalation = incidentTimersEscalationTimerRequest key (addUTCTime 300 firstObserved) incidentId "ceiling"
    scheduled <- expectRight =<< runStore (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions incidentTimersProcessManager (recorded sourceUuid9 9 reported) reported)
    _ <- expectRight scheduled
    now <- getCurrentTime
    forM_ [0 .. 5 :: Int] $ \attempt -> do
      _ <- expectRight =<< runStore (runTimerWorkerWith Nothing incidentTimersTimerWorkerOptions (addUTCTime (fromIntegral (attempt * 400)) now) (\_ -> pure Nothing))
      pure ()
    terminal <- requireTimer =<< (expectRight =<< runStore (lookupTimer escalation.timerId))
    assert "generated attempt ceiling dead-letters before a sixth fire" (terminal.status == Dead && terminal.attempts == 6)

recorded :: UUID.UUID -> Int -> IncidentTimersInput -> RecordedEvent
recorded eventUuid position input =
  RecordedEvent
    { eventId = EventId eventUuid,
      eventType = EventType "ProcessTimerInput",
      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 = firstObserved
    }

firstObserved :: UTCTime
firstObserved = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)

secondObserved :: UTCTime
secondObserved = addUTCTime 600 firstObserved

expectedEscalationTimerId :: TimerId
expectedEscalationTimerId =
  TimerId
    ( UUID.V5.generateNamed
        UUID.V5.namespaceURL
        (identitySeedBytes (T.concat (map field ["incident-escalation-timer:", "inc_01h455vb4pex5vsknk084sn02q"])))
    )
  where
    field value = T.pack (show (BS.length (encodeUtf8 value))) <> ":" <> value

sourceUuid1, sourceUuid2, sourceUuid3, sourceUuid4, sourceUuid5, sourceUuid6, sourceUuid7, sourceUuid8, sourceUuid9 :: UUID.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"
sourceUuid5 = staticUuid "123e4567-e89b-72d3-a456-426614174005"
sourceUuid6 = staticUuid "123e4567-e89b-72d3-a456-426614174006"
sourceUuid7 = staticUuid "123e4567-e89b-72d3-a456-426614174007"
sourceUuid8 = staticUuid "123e4567-e89b-72d3-a456-426614174008"
sourceUuid9 = staticUuid "123e4567-e89b-72d3-a456-426614174009"

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

requireTimer :: Maybe TimerRow -> IO TimerRow
requireTimer = maybe (fail "expected timer row") pure

requireEventId :: Maybe EventId -> IO EventId
requireEventId = maybe (fail "expected fired event id") pure

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

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