packages feed

keiro-0.18.0.0: test/WorkflowReplayCompatibilitySpec.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}

module WorkflowReplayCompatibilitySpec
  ( spec,
  )
where

import Control.Exception (Exception, throwIO, try)
import Data.Aeson qualified as Aeson
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Set qualified as Set
import Data.Time (getCurrentTime)
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import Keiro.Prelude (liftIO)
import Keiro.Test.Postgres (Fixture, withFreshStore)
import Keiro.Workflow
import Kiroku.Store qualified as Store
import Test.Hspec

spec :: Fixture -> Spec
spec fixture = describe "cross-version workflow replay compatibility" $ around (withFreshStore fixture) do
  it "continues a captured prefix under a source-only refactor without repeating its effect" $ \store -> do
    baselineEffects <- newIORef (0 :: Int)
    candidateEffects <- newIORef (0 :: Int)
    let name = WorkflowName "compatibleRefactor"
        wid = WorkflowId "instance-1"
    captureCrashedPrefix store name wid (stableBaseline baselineEffects)
    candidate <- Store.runStoreIO store $ runWorkflow name wid (stableCandidate candidateEffects)
    candidate `shouldBe` Right (Completed 1)
    readIORef baselineEffects `shouldReturn` 1
    readIORef candidateEffects `shouldReturn` 0

  it "fails closed when candidate code cannot decode a captured result" $ \store -> do
    baselineEffects <- newIORef (0 :: Int)
    candidateEffects <- newIORef (0 :: Int)
    let name = WorkflowName "decoderBreak"
        wid = WorkflowId "instance-1"
    captureCrashedPrefix store name wid (stableBaseline baselineEffects)
    candidate <-
      try (Store.runStoreIO store $ runWorkflow name wid (incompatibleCandidate candidateEffects)) ::
        IO (Either WorkflowError (Either Store.StoreError (WorkflowOutcome Bool)))
    candidate `shouldSatisfy` \case
      Left (WorkflowStepDecodeError "stable-result" _) -> True
      _ -> False
    readIORef candidateEffects `shouldReturn` 0

  it "exposes a decoder that succeeds with changed meaning at the first stable key" $ \store -> do
    baselineEffects <- newIORef (0 :: Int)
    candidateEffects <- newIORef (0 :: Int)
    let name = WorkflowName "semanticDrift"
        wid = WorkflowId "instance-1"
    captureCrashedPrefix store name wid (semanticBaseline baselineEffects)
    candidate <- Store.runStoreIO store $ runWorkflow name wid (semanticCandidate candidateEffects)
    candidate `shouldBe` Right (Completed (CandidateAmount 6))
    CandidateAmount 6 `shouldNotBe` CandidateAmount 5
    readIORef candidateEffects `shouldReturn` 0

  it "treats a renamed step as new work and therefore rejects it as a pure refactor" $ \store -> do
    baselineEffects <- newIORef (0 :: Int)
    candidateEffects <- newIORef (0 :: Int)
    let name = WorkflowName "renamedStep"
        wid = WorkflowId "instance-1"
    captureCrashedPrefix store name wid (stableBaseline baselineEffects)
    candidate <- Store.runStoreIO store $ runWorkflow name wid (renamedCandidate candidateEffects)
    candidate `shouldBe` Right (Completed 1)
    readIORef candidateEffects `shouldReturn` 1

  it "keeps an in-flight instance on its recorded patch branch" $ \store -> do
    baselineEffects <- newIORef (0 :: Int)
    candidateEffects <- newIORef (0 :: Int)
    let name = WorkflowName "patchedContinuation"
        wid = WorkflowId "instance-1"
        options = defaultWorkflowRunOptions {activePatches = Set.singleton compatibilityPatch}
    baseline <- Store.runStoreIO store $ runWorkflow name wid (prePatchWorkflow baselineEffects)
    baseline `shouldBe` Right Suspended
    candidate <- Store.runStoreIO store $ runWorkflowWith options name wid (postPatchWorkflow candidateEffects)
    candidate `shouldBe` Right (Completed "old-branch")
    readIORef baselineEffects `shouldReturn` 1
    readIORef candidateEffects `shouldReturn` 0

  it "reuses an indexed await result without rearming under candidate code" $ \store -> do
    baselineArms <- newIORef (0 :: Int)
    candidateArms <- newIORef (0 :: Int)
    let name = WorkflowName "awaitContinuation"
        wid = WorkflowId "instance-1"
    baseline <- Store.runStoreIO store $ runWorkflow name wid (awaitingWorkflow baselineArms)
    baseline `shouldBe` Right Suspended
    now <- getCurrentTime
    Right () <-
      Store.runStoreIO store $
        appendJournalEntry name wid (StepRecorded "stable-await" (Aeson.toJSON (7 :: Int)) now)
    candidate <- Store.runStoreIO store $ runWorkflow name wid (awaitingWorkflow candidateArms)
    candidate `shouldBe` Right (Completed 7)
    readIORef baselineArms `shouldReturn` 1
    readIORef candidateArms `shouldReturn` 0

  it "continues from a baseline-carried seed after continueAsNew" $ \store -> do
    let name = WorkflowName "rotatedContinuation"
        wid = WorkflowId "instance-1"
    baseline <- Store.runStoreIO store $ runWorkflow name wid baselineRotation
    baseline `shouldBe` Right ContinuedAsNew
    candidate <- Store.runStoreIO store $ runWorkflow name wid candidateRotation
    candidate `shouldBe` Right (Completed 41)

data CapturedPrefix = CapturedPrefix
  deriving stock (Show)

instance Exception CapturedPrefix

captureCrashedPrefix ::
  Store.KirokuStore ->
  WorkflowName ->
  WorkflowId ->
  Eff '[Workflow, Store.Store, Error Store.StoreError, IOE] value ->
  IO ()
captureCrashedPrefix store name wid body = do
  captured <- try @CapturedPrefix (Store.runStoreIO store $ runWorkflow name wid body)
  case captured of
    Left CapturedPrefix -> pure ()
    Right _ -> expectationFailure "baseline workflow completed instead of leaving a captured prefix"

incrementAndRead :: IORef Int -> IO Int
incrementAndRead ref = atomicModifyIORef' ref (\value -> (value + 1, value + 1))

stableBaseline :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int
stableBaseline effects = do
  result <- step (StepName "stable-result") (liftIO (incrementAndRead effects))
  liftIO (failWithCapturedPrefix result)

stableCandidate :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int
stableCandidate effects = step (StepName "stable-result") (liftIO (incrementAndRead effects))

incompatibleCandidate :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Bool
incompatibleCandidate effects = step (StepName "stable-result") (liftIO (incrementAndRead effects) >> pure True)

renamedCandidate :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int
renamedCandidate effects = step (StepName "renamed-result") (liftIO (incrementAndRead effects))

newtype BaselineAmount = BaselineAmount Int
  deriving stock (Eq, Show)

instance Aeson.ToJSON BaselineAmount where
  toJSON (BaselineAmount amount) = Aeson.toJSON amount

instance Aeson.FromJSON BaselineAmount where
  parseJSON value = BaselineAmount <$> Aeson.parseJSON value

newtype CandidateAmount = CandidateAmount Int
  deriving stock (Eq, Show)

instance Aeson.ToJSON CandidateAmount where
  toJSON (CandidateAmount amount) = Aeson.toJSON amount

instance Aeson.FromJSON CandidateAmount where
  parseJSON value = CandidateAmount . (+ 1) <$> Aeson.parseJSON value

semanticBaseline :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es BaselineAmount
semanticBaseline effects = do
  result <- step (StepName "stable-result") (BaselineAmount <$> liftIO (incrementAndRead effects >> pure 5))
  liftIO (failWithCapturedPrefix result)

semanticCandidate :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es CandidateAmount
semanticCandidate effects =
  step (StepName "stable-result") (CandidateAmount <$> liftIO (incrementAndRead effects >> pure 5))

failWithCapturedPrefix :: value -> IO value
failWithCapturedPrefix _ = throwIO CapturedPrefix

compatibilityPatch :: PatchId
compatibilityPatch = PatchId "compatibility-v2"

prePatchWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es String
prePatchWorkflow effects = do
  _ <- step (StepName "stable-result") (liftIO (incrementAndRead effects))
  (_ :: ()) <- awaitStep (StepName "never-resolved") (pure ())
  pure "baseline"

postPatchWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es String
postPatchWorkflow effects = do
  _ <- step (StepName "stable-result") (liftIO (incrementAndRead effects))
  useNew <- patch compatibilityPatch
  if useNew then pure "new-branch" else pure "old-branch"

awaitingWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int
awaitingWorkflow arms = awaitStep (StepName "stable-await") (liftIO (incrementAndRead arms) >> pure ())

baselineRotation :: (Workflow :> es) => Eff es Int
baselineRotation = continueAsNew (41 :: Int)

candidateRotation :: (Workflow :> es) => Eff es Int
candidateRotation = restoreSeed (0 :: Int)