packages feed

keiro-0.12.0.0: test/CatalogEvolutionSpec.hs

{-# LANGUAGE MultilineStrings #-}

module CatalogEvolutionSpec
  ( spec,
  )
where

import CatalogSpec qualified as Catalog
import Contravariant.Extras (contrazip2)
import Data.ByteString (ByteString)
import Data.Either (isRight)
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.Projection.Catalog.Operations qualified as Operations
import Keiro.ReadModel (ReadModel (..), ReadModelStatus (Abandoned), 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 "catalog evolution adoption" $ around (withFreshStore fixture) $ do
  it "previews and transactionally adopts a changed slice and query schema" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.validCatalog)
    changed <- expectValid changedCatalog
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    let oldSlice = sliceText current Catalog.mainGroupId
        newSlice = sliceText changed Catalog.mainGroupId

    expectStore store (registerProjectionCatalog changed)
      `shouldReturn` Left (RegisteredGroupSliceDrift Catalog.mainGroupId oldSlice newSlice)
    expectStore store (beginGroupRebuild changed Catalog.mainGroupId (request "before-adoption"))
      `shouldReturn` Left (RebuildGroupSliceDrift Catalog.mainGroupId oldSlice newSlice)

    plan <- expectStore store (previewCatalogAdoption changed)
    plan ^. #groupStates
      `shouldBe` [(Catalog.mainGroupId, AdoptionSliceChanged oldSlice newSlice)]
    plan ^. #removedGroups `shouldBe` []
    report <-
      expectStore
        store
        ( Operations.previewCatalogAdoption
            (Operations.projectionCatalogOperations changed)
            (Catalog.mainGroupId :| [])
        )
        >>= shouldBeRight
    report ^. #reportSchema `shouldBe` "keiro/catalog-adoption-preview/v2"
    report ^. #requestedGroups `shouldBe` [Catalog.mainGroupId]
    map (^. #classification) (report ^. #groups)
      `shouldBe` [AdoptionSliceChanged oldSlice newSlice]
    map (^. #inScope) (report ^. #groups) `shouldBe` [True]
    map (^. #currentSlice) (report ^. #groups) `shouldBe` [newSlice]
    report ^. #removedGroups `shouldBe` []

    expectStore
      store
      ( Store.runTransaction
          ( Tx.sql
              "UPDATE keiro.keiro_projection_group_cursors SET position_basis = 'unmanaged', subscription_names = ARRAY[]::TEXT[] WHERE group_id = 'counter-group'"
          )
      )
    beforeAdoption <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)
    beforeAdoption ^? _Just . #servingPositionBasis
      `shouldBe` Just ServingPositionUnmanaged

    outcome <-
      expectStore
        store
        ( Operations.adoptCatalogGroups
            (Operations.projectionCatalogOperations changed)
            (Catalog.mainGroupId :| [])
        )
        >>= shouldBeRight
    outcome ^. #reportSchema `shouldBe` "keiro/catalog-adoption-outcome/v2"
    let adopted = outcome ^. #adoptedGroups
    map (^. #sliceFingerprint) adopted `shouldBe` [newSlice]
    afterAdoption <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)
    afterAdoption ^? _Just . #servingPositionBasis
      `shouldBe` Just ServingPositionCheckpoint
    metadata <- expectStore store (lookupReadModel "catalog-counter-query")
    metadata ^? _Just . #version `shouldBe` Just 2
    metadata ^? _Just . #shapeHash
      `shouldBe` Just "catalog-counter-query-v1-adopted"
    registered <- expectStore store (registerProjectionCatalog changed)
    registered `shouldSatisfy` isRight

    promoted <-
      expectStore
        store
        (startCatalogRebuild changed Catalog.mainGroupId (options "after-adoption"))
        >>= shouldBeRight
    promoted ^. #runStatus `shouldBe` RebuildRunPromoted

  it "refuses unregistered and non-live groups without partially adopting" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.additiveCatalog)
    changed <- expectValid (changeCatalog (rebuildableCatalog Catalog.additiveCatalog))
    base <- expectValid (rebuildableCatalog Catalog.validCatalog)
    _ <- expectStore store (registerProjectionCatalog base) >>= shouldBeRight
    expectStore store (adoptCatalogGroups current (Catalog.additiveGroupId :| []))
      `shouldReturn` Left (AdoptGroupUnregistered Catalog.additiveGroupId)

    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    _ <-
      expectStore store (beginGroupRebuild current Catalog.mainGroupId (request "non-live"))
        >>= shouldBeRight
    let mainBefore = sliceText current Catalog.mainGroupId
        additiveBefore = sliceText current Catalog.additiveGroupId
    expectStore
      store
      (adoptCatalogGroups changed (Catalog.additiveGroupId :| [Catalog.mainGroupId]))
      `shouldReturn` Left (AdoptGroupNotLive Catalog.mainGroupId GroupRebuilding (Just (runId "non-live")))
    main <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)
    main ^? _Just . #sliceFingerprint `shouldBe` Just mainBefore
    main ^? _Just . #status `shouldBe` Just GroupRebuilding
    additive <- expectStore store (lookupProjectionRebuildGroup Catalog.additiveGroupId)
    additive ^? _Just . #sliceFingerprint `shouldBe` Just additiveBefore

  it "classifies and adopts a live slice-v1 stored fingerprint" $ \store -> do
    current <- expectValid Catalog.validCatalog
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    let stale = "slice-v1:" <> Text.replicate 64 "a"
    expectStore
      store
      (Store.runTransaction (Tx.statement (rebuildGroupIdText Catalog.mainGroupId, stale) setStoredSliceStmt))
    expectStore store (registerProjectionCatalog current)
      `shouldReturn` Left (RegisteredGroupStaleFingerprint Catalog.mainGroupId stale)
    plan <- expectStore store (previewCatalogAdoption current)
    plan ^. #groupStates
      `shouldBe` [(Catalog.mainGroupId, AdoptionStaleFormat stale)]
    _ <-
      expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    registered <- expectStore store (registerProjectionCatalog current)
    registered `shouldSatisfy` isRight

  it "adopts only stale-format failed groups while preserving their fence" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.validCatalog)
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    handle <-
      expectStore store (beginGroupRebuild current Catalog.mainGroupId (request "failed-adoption"))
        >>= shouldBeRight
    _ <-
      expectStore
        store
        (abandonGroupRebuild handle (RebuildFailure "operator.abandoned" "prepare adoption boundary"))
        >>= shouldBeRight

    expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))
      `shouldReturn` Left
        (AdoptGroupNotLive Catalog.mainGroupId GroupFailed (Just (runId "failed-adoption")))

    let stale = "slice-v1:" <> Text.replicate 64 "a"
    expectStore
      store
      (Store.runTransaction (Tx.statement (rebuildGroupIdText Catalog.mainGroupId, stale) setStoredSliceStmt))
    adopted <-
      expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    map (^. #status) (adopted ^. #adoptedGroups) `shouldBe` [GroupFailed]
    map (^. #sliceFingerprint) (adopted ^. #adoptedGroups)
      `shouldBe` [sliceText current Catalog.mainGroupId]

  it "adopts a renamed query registration completely" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.validCatalog)
    renamed <- expectValid renamedCatalog
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    let oldSlice = sliceText current Catalog.mainGroupId
        newSlice = sliceText renamed Catalog.mainGroupId
    expectStore store (registerProjectionCatalog renamed)
      `shouldReturn` Left (RegisteredGroupSliceDrift Catalog.mainGroupId oldSlice newSlice)
    plan <- expectStore store (previewCatalogAdoption renamed)
    plan ^. #registrations
      `shouldContain` [RegistrationAdoption "catalog-counter-query-renamed" Catalog.mainGroupId RegistrationInsert]
    plan ^. #orphanedRegistrations
      `shouldContain` [OrphanedRegistration "catalog-counter-query" Catalog.mainGroupId]
    result <-
      expectStore store (adoptCatalogGroups renamed (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    result ^. #registrationOutcomes
      `shouldContain` [RegistrationAdoption "catalog-counter-query-renamed" Catalog.mainGroupId RegistrationInsert]
    result ^. #removedOrphans
      `shouldBe` [OrphanedRegistration "catalog-counter-query" Catalog.mainGroupId]
    renamedRow <- expectStore store (lookupReadModel "catalog-counter-query-renamed")
    renamedRow ^? _Just . #shapeHash
      `shouldBe` Just "catalog-counter-query-renamed-v1"
    oldRow <- expectStore store (lookupReadModel "catalog-counter-query")
    oldRow `shouldBe` Nothing
    registered <- expectStore store (registerProjectionCatalog renamed)
    registered `shouldSatisfy` isRight

  it "inserts an added query registration during adoption" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.validCatalog)
    added <- expectValid addedQueryCatalog
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    plan <- expectStore store (previewCatalogAdoption added)
    plan ^. #registrations
      `shouldContain` [RegistrationAdoption "catalog-added-query" Catalog.mainGroupId RegistrationInsert]
    plan ^. #orphanedRegistrations `shouldBe` []
    result <-
      expectStore store (adoptCatalogGroups added (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    result ^. #registrationOutcomes
      `shouldContain` [RegistrationAdoption "catalog-added-query" Catalog.mainGroupId RegistrationInsert]
    result ^. #removedOrphans `shouldBe` []
    addedRow <- expectStore store (lookupReadModel "catalog-added-query")
    addedRow ^? _Just . #rebuildGroupId
      `shouldBe` Just (rebuildGroupIdText Catalog.mainGroupId)

  it "does not orphan a query registration moved to an out-of-scope group" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.additiveCatalog)
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    expectStore
      store
      ( Store.runTransaction
          ( Tx.statement
              ("catalog-additive-query", rebuildGroupIdText Catalog.mainGroupId)
              setQueryGroupStmt
          )
      )
    plan <- expectStore store (previewCatalogAdoption current)
    plan ^. #orphanedRegistrations `shouldBe` []
    result <-
      expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    result ^. #removedOrphans `shouldBe` []
    movedRow <- expectStore store (lookupReadModel "catalog-additive-query")
    movedRow ^? _Just . #rebuildGroupId
      `shouldBe` Just (rebuildGroupIdText Catalog.mainGroupId)

  it "keeps an inserted registration fenced when adopting a failed stale-format group" $ \store -> do
    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))
    current <- expectValid (rebuildableCatalog Catalog.validCatalog)
    renamed <- expectValid renamedCatalog
    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight
    handle <-
      expectStore store (beginGroupRebuild current Catalog.mainGroupId (request "failed-insert"))
        >>= shouldBeRight
    _ <-
      expectStore store (abandonGroupRebuild handle (RebuildFailure "operator.abandoned" "fence insert"))
        >>= shouldBeRight
    let stale = "slice-v1:" <> Text.replicate 64 "b"
    expectStore
      store
      (Store.runTransaction (Tx.statement (rebuildGroupIdText Catalog.mainGroupId, stale) setStoredSliceStmt))
    _ <-
      expectStore store (adoptCatalogGroups renamed (Catalog.mainGroupId :| []))
        >>= shouldBeRight
    renamedRow <- expectStore store (lookupReadModel "catalog-counter-query-renamed")
    renamedRow ^? _Just . #status `shouldBe` Just Abandoned

changedCatalog :: ProjectionCatalog
changedCatalog = changeCatalog (rebuildableCatalog Catalog.validCatalog)

renamedCatalog :: ProjectionCatalog
renamedCatalog =
  let base = rebuildableCatalog Catalog.validCatalog
   in base {queryModels = renameCounterQuery <$> base ^. #queryModels}

renameCounterQuery :: SomeQueryModelBinding -> SomeQueryModelBinding
renameCounterQuery (SomeQueryModelBinding binding)
  | binding ^. #readModel . #name == "catalog-counter-query" =
      SomeQueryModelBinding
        binding
          { readModel =
              (binding ^. #readModel)
                { name = "catalog-counter-query-renamed",
                  shapeHash = "catalog-counter-query-renamed-v1"
                }
          }
  | otherwise = SomeQueryModelBinding binding

addedQueryCatalog :: ProjectionCatalog
addedQueryCatalog =
  let base = rebuildableCatalog Catalog.validCatalog
   in base
        { queryModels =
            base ^. #queryModels
              <> [SomeQueryModelBinding addedQueryBinding]
        }

addedQueryBinding :: QueryModelBinding Text ()
addedQueryBinding =
  Catalog.counterBinding
    { queryModelId = queryModelIdentity "added-query",
      readModel =
        (Catalog.counterBinding ^. #readModel)
          { name = "catalog-added-query",
            shapeHash = "catalog-added-query-v1"
          },
      rebuildGroup = Catalog.mainGroupId,
      observedTargets = [Catalog.counterTargetId],
      claimSite = claimSiteIdentity "catalog:added-query"
    }

rebuildableCatalog :: ProjectionCatalog -> ProjectionCatalog
rebuildableCatalog catalog =
  catalog
    { targets =
        [ target & #resetPolicy .~ ClearBeforeReplay
        | target <- catalog ^. #targets
        ]
    }

changeCatalog :: ProjectionCatalog -> ProjectionCatalog
changeCatalog catalog =
  catalog
    { sources =
        [ source & #codecFingerprint .~ (source ^. #codecFingerprint <> "-adopted")
        | source <- catalog ^. #sources
        ],
      queryModels = bumpQueryModel <$> catalog ^. #queryModels
    }

bumpQueryModel :: SomeQueryModelBinding -> SomeQueryModelBinding
bumpQueryModel (SomeQueryModelBinding binding) =
  SomeQueryModelBinding
    ( binding
        & #readModel
        . #version
        %~ (+ 1)
        & #readModel
        . #shapeHash
        %~ (<> "-adopted")
    )

sliceText :: ValidatedProjectionCatalog -> RebuildGroupId -> Text
sliceText catalog groupId =
  maybe
    (error "test catalog group has no slice")
    groupSliceFingerprintText
    (CatalogApi.groupSliceFingerprint catalog groupId)

request :: Text -> RebuildRequest
request identity =
  RebuildRequest
    { rebuildRunId = runId identity,
      requestedBy = "catalog-evolution-spec",
      requestReason = "exercise explicit catalog adoption",
      replayFrom = GlobalPosition 0
    }

options :: Text -> RebuildOptions
options = defaultRebuildOptions . request

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

queryModelIdentity :: Text -> QueryModelId
queryModelIdentity identity =
  case mkQueryModelId identity of
    Left err -> error (show err)
    Right value -> value

claimSiteIdentity :: Text -> ClaimSite
claimSiteIdentity identity =
  case mkClaimSite identity of
    Left err -> error (show err)
    Right value -> value

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

catalogFixtureSql :: ByteString
catalogFixtureSql =
  """
  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)
  );
  CREATE TABLE app.catalog_additive (id bigint PRIMARY KEY);
  INSERT INTO subscriptions (subscription_name, last_seen)
  VALUES ('catalog-async-subscription', 0);
  """

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

setQueryGroupStmt :: Statement (Text, Text) ()
setQueryGroupStmt =
  preparable
    """
    UPDATE keiro.keiro_read_models
    SET rebuild_group_id = $2
    WHERE name = $1
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult