packages feed

keiro-0.12.0.0: test/PreCanonicalRecoverySpec.hs

{-# LANGUAGE MultilineStrings #-}

module PreCanonicalRecoverySpec
  ( spec,
  )
where

import CatalogSpec qualified as Catalog
import Contravariant.Extras (contrazip2)
import Data.ByteString (ByteString)
import Data.Text qualified as Text
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Prelude
import Keiro.Projection.Catalog
import Keiro.Projection.Catalog qualified as CatalogApi
import Keiro.ReadModel (ReadModelStatus (Live), lookupReadModel)
import Keiro.ReadModel.Rebuild
import Keiro.Test.Postgres (Fixture, withFreshStore)
import Kiroku.Store qualified as Store
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Types (GlobalPosition (..))
import Test.Hspec
import "hasql-transaction" Hasql.Transaction qualified as Tx

spec :: Fixture -> Spec
spec fixture = describe "pre-canonical rebuild recovery" $ around (withFreshStore fixture) $ do
  it "recovers a group stranded mid-rebuild by migration 0024" $ \store -> do
    healthy <- strandPreCanonicalRun store "recovery-run"
    let strandedRun = runId "recovery-run"
        legacySlice = Text.replicate 64 "a"

    expectStore store (registerProjectionCatalog healthy)
      `shouldReturn` Left (RegisteredGroupStaleFingerprint Catalog.mainGroupId legacySlice)
    expectStore store (resumeCatalogRebuild healthy strandedRun (options "ignored"))
      `shouldReturn` Left (CatalogRebuildRunPreCanonical strandedRun Catalog.mainGroupId)
    expectStore store (adoptCatalogGroups healthy (Catalog.mainGroupId :| []))
      `shouldReturn` Left (AdoptGroupNotLive Catalog.mainGroupId GroupRebuilding (Just strandedRun))
    beforeRecovery <-
      expectStore store (startCatalogRebuild healthy Catalog.mainGroupId (options "before-recovery"))
    beforeRecovery `shouldSatisfy` \case
      Left (CatalogRebuildStartFailed RebuildGroupSliceDrift {}) -> True
      _ -> False

    abandoned <-
      expectStore
        store
        ( abandonCatalogRebuild
            healthy
            strandedRun
            (RebuildFailure "operator.pre-canonical" "discard run stranded by the 0024 upgrade")
        )
        >>= shouldBeRight
    abandoned ^. #runStatus `shouldBe` RebuildRunFailed
    groupAfterAbandon <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)
    groupAfterAbandon ^? _Just . #status `shouldBe` Just GroupFailed
    groupAfterAbandon ^? _Just . #failureCode `shouldBe` Just (Just "operator.pre-canonical")

    _ <-
      expectStore
        store
        (abandonCatalogRebuild healthy strandedRun (RebuildFailure "again" "must not replace group evidence"))
        >>= shouldBeRight
    groupAfterSecondAbandon <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)
    groupAfterSecondAbandon ^? _Just . #failureCode `shouldBe` Just (Just "operator.pre-canonical")

    adopted <-
      expectStore store (adoptCatalogGroups healthy (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    map (^. #status) (adopted ^. #adoptedGroups) `shouldBe` [GroupFailed]
    map (^. #sliceFingerprint) (adopted ^. #adoptedGroups)
      `shouldBe` [currentSlice healthy]
    _ <- expectStore store (registerProjectionCatalog healthy) >>= shouldBeRight

    promoted <-
      expectStore store (startCatalogRebuild healthy Catalog.mainGroupId (options "recovery-fresh"))
        >>= shouldBeRight
    promoted ^. #runStatus `shouldBe` RebuildRunPromoted
    recoveredGroup <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)
    recoveredGroup ^? _Just . #status `shouldBe` Just GroupLive
    counter <- expectStore store (lookupReadModel "catalog-counter-query")
    counter ^? _Just . #status `shouldBe` Just Live

  it "abandons a pre-canonical run whose process died while running" $ \store -> do
    healthy <- strandPreCanonicalRun store "running-recovery"
    expectStore store (Store.runTransaction (Tx.statement (rebuildRunIdText (runId "running-recovery")) markRunRunningStmt))
    abandoned <-
      expectStore
        store
        ( abandonCatalogRebuild
            healthy
            (runId "running-recovery")
            (RebuildFailure "operator.pre-canonical" "discard interrupted run")
        )
        >>= shouldBeRight
    abandoned ^. #runStatus `shouldBe` RebuildRunFailed

  it "never abandons a pre-canonical run that is no longer the group's active run" $ \store -> do
    healthy <- strandPreCanonicalRun store "inactive-recovery"
    expectStore
      store
      ( Store.runTransaction
          ( Tx.statement
              (rebuildGroupIdText Catalog.mainGroupId, "replacement-run")
              setActiveRunStmt
          )
      )
    expectStore
      store
      ( abandonCatalogRebuild
          healthy
          (runId "inactive-recovery")
          (RebuildFailure "operator.pre-canonical" "must not abandon a replaced run")
      )
      `shouldReturn` Left
        ( CatalogRebuildAbandonFailed
            (RebuildHandleNoLongerActive Catalog.mainGroupId (runId "inactive-recovery"))
        )

  it "never abandons a terminal pre-canonical run" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))
    healthy <- expectValid (operationsCatalog passingVerification)
    _ <- expectStore store (registerProjectionCatalog healthy) >>= shouldBeRight
    promoted <-
      expectStore store (startCatalogRebuild healthy Catalog.mainGroupId (options "terminal-recovery"))
        >>= shouldBeRight
    promoted ^. #runStatus `shouldBe` RebuildRunPromoted
    expectStore
      store
      (Store.runTransaction (Tx.statement (rebuildRunIdText (runId "terminal-recovery")) markRunPreCanonicalStmt))
    expectStore
      store
      ( abandonCatalogRebuild
          healthy
          (runId "terminal-recovery")
          (RebuildFailure "operator.pre-canonical" "must not abandon a promoted run")
      )
      `shouldReturn` Left (CatalogRebuildRunNotActive (runId "terminal-recovery"))

strandPreCanonicalRun :: Store.KirokuStore -> Text -> IO ValidatedProjectionCatalog
strandPreCanonicalRun store runIdentity = do
  expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))
  faulted <- expectValid (operationsCatalog failingVerification)
  healthy <- expectValid (operationsCatalog passingVerification)
  _ <- expectStore store (registerProjectionCatalog faulted) >>= shouldBeRight
  first <- expectStore store (startCatalogRebuild faulted Catalog.mainGroupId (options runIdentity))
  first `shouldSatisfy` \case
    Left CatalogRebuildVerificationFailed {} -> True
    _ -> False
  expectStore
    store
    ( Store.runTransaction $ do
        Tx.statement (rebuildRunIdText (runId runIdentity)) markRunPreCanonicalStmt
        Tx.statement
          (rebuildGroupIdText Catalog.mainGroupId, Text.replicate 64 "a")
          setStoredSliceStmt
    )
  pure healthy

operationsCatalog :: RebuildVerification -> ProjectionCatalog
operationsCatalog verificationHook =
  Catalog.validCatalog
    { targets =
        [ if target ^. #targetId == Catalog.counterTargetId
            then target & #resetPolicy .~ PreserveAndReconcile
            else target & #resetPolicy .~ ClearBeforeReplay
        | target <- Catalog.validCatalog ^. #targets
        ],
      rebuildGroups =
        [ group & #verificationHooks .~ [verificationHook]
        | group <- Catalog.validCatalog ^. #rebuildGroups
        ]
    }

passingVerification :: RebuildVerification
passingVerification = verification (pure (Right ()))

failingVerification :: RebuildVerification
failingVerification = verification (pure (Left "fault injected by pre-canonical recovery spec"))

verification :: Tx.Transaction (Either Text ()) -> RebuildVerification
verification action =
  RebuildVerification
    { verificationId = "pre-canonical-row-check",
      verificationVersion = "v1",
      verifyRebuild = action
    }

options :: Text -> RebuildOptions
options identity =
  defaultRebuildOptions
    RebuildRequest
      { rebuildRunId = runId identity,
        requestedBy = "pre-canonical-recovery-spec",
        requestReason = "exercise migration 0024 recovery",
        replayFrom = GlobalPosition 0
      }

runId :: Text -> RebuildRunId
runId identity =
  case mkRebuildRunId identity of
    Left err -> error (Text.unpack err)
    Right value -> value

currentSlice :: ValidatedProjectionCatalog -> Text
currentSlice catalog =
  maybe
    (error "pre-canonical recovery catalog has no main-group slice")
    groupSliceFingerprintText
    (CatalogApi.groupSliceFingerprint catalog Catalog.mainGroupId)

expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog
expectValid catalog =
  case validateProjectionCatalog catalog of
    Success validated -> pure validated
    Failure diagnostics ->
      expectationFailure ("expected valid catalog, got " <> show diagnostics)
        >> error "unreachable"

expectStore ::
  Store.KirokuStore ->
  Eff '[Store, Error StoreError, IOE] value ->
  IO value
expectStore store action =
  Store.runStoreIO store action >>= \case
    Left err -> expectationFailure ("store action failed: " <> show err) >> error "unreachable"
    Right value -> pure value

shouldBeRight :: (Show err) => Either err value -> IO value
shouldBeRight = \case
  Left err -> expectationFailure ("expected Right, got Left " <> show err) >> error "unreachable"
  Right value -> pure value

operationsFixtureSql :: ByteString
operationsFixtureSql =
  """
  CREATE SCHEMA app;
  CREATE TABLE app.counter (id bigint PRIMARY KEY);
  CREATE TABLE app.counter_audit (
    id bigint PRIMARY KEY,
    counter_id bigint REFERENCES app.counter(id)
  );
  INSERT INTO subscriptions (subscription_name, last_seen)
  VALUES ('catalog-async-subscription', 0);
  """

markRunPreCanonicalStmt :: Statement Text ()
markRunPreCanonicalStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_runs
    SET group_slice_fingerprint = '$pre-canonical',
        contract_fingerprint = 'contract-v2:' || repeat('c', 64),
        runner_format = 'keiro/projection-replay/v2'
    WHERE run_id = $1
    """
    (E.param (E.nonNullable E.text))
    D.noResult

setStoredSliceStmt :: Statement (Text, Text) ()
setStoredSliceStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET slice_fingerprint = $2
    WHERE group_id = $1
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

markRunRunningStmt :: Statement Text ()
markRunRunningStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_runs
    SET status = 'running', failed_at = NULL, failure_code = NULL,
        failure_detail = NULL, failure_source_id = NULL,
        failure_projection_id = NULL, failure_position = NULL
    WHERE run_id = $1
    """
    (E.param (E.nonNullable E.text))
    D.noResult

setActiveRunStmt :: Statement (Text, Text) ()
setActiveRunStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET active_run_id = $2
    WHERE group_id = $1
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult