kiroku-store-0.5.0.0: test/Test/SubscriptionCheckpointInitialization.hs
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE TypeApplications #-}
module Test.SubscriptionCheckpointInitialization (spec) where
import Control.Concurrent.Async qualified as Async
import Control.Lens ((&), (.~))
import Data.Aeson qualified as Aeson
import Data.Int (Int32)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Effectful (runEff)
import Effectful.Error.Static (runErrorNoCallStack)
import Kiroku.Store
import Test.Helpers (makeEvent, withTestStore)
import Test.Hspec
spec :: Spec
spec = describe "subscription checkpoint initialization" $ do
it "materializes position zero for FromBeginning" $
withTestStore $ \store -> do
let name = SubscriptionName "initialize-from-beginning"
key = SubscriptionCheckpointKey name 0
result <- initialize store name 0 FromBeginning
result `shouldBe` Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0))
inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 0)]
it "atomically materializes the current store head for FromCurrentHead" $
withTestStore $ \store -> do
appendEvents store "initialize-current-head-events" 3
let name = SubscriptionName "initialize-current-head"
key = SubscriptionCheckpointKey name 0
result <- initialize store name 0 FromCurrentHead
result `shouldBe` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 3))
inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 3)]
it "returns a typed missing result without creating a row for FailIfMissing" $
withTestStore $ \store -> do
let name = SubscriptionName "initialize-fail-if-missing"
key = SubscriptionCheckpointKey name 0
result <- initialize store name 0 FailIfMissing
result `shouldBe` Left (SubscriptionCheckpointMissing key)
inventoryKeys <$> inventory store `shouldReturn` []
it "preserves an existing row for every configured policy" $
withTestStore $ \store -> do
appendEvents store "initialize-existing-events" 5
let name = SubscriptionName "initialize-existing"
key = SubscriptionCheckpointKey name 0
initialize store name 0 FromCurrentHead
`shouldReturn` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 5))
mapM_ (assertExisting store key) [FromBeginning, FromCurrentHead, FailIfMissing]
inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 5)]
it "isolates checkpoint initialization by consumer-group member" $
withTestStore $ \store -> do
appendEvents store "initialize-member-events" 4
let name = SubscriptionName "initialize-members"
initialize store name 0 FromBeginning
`shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))
initialize store name 1 FromCurrentHead
`shouldReturn` Right (InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 4))
inventoryKeys <$> inventory store
`shouldReturn` [ (name, 0, GlobalPosition 0)
, (name, 1, GlobalPosition 4)
]
it "converges concurrent initializers on one durable winner" $
withTestStore $ \store -> do
appendEvents store "initialize-race-events" 7
let name = SubscriptionName "initialize-race"
policies = take 20 (cycle [FromBeginning, FromCurrentHead])
results <- Async.mapConcurrently (initialize store name 3) policies
let successes = [initialization | Right initialization <- results]
positions = fmap checkpointInitializationPosition successes
initializedCount = length [() | InitializedCheckpoint{} <- successes]
length successes `shouldBe` length policies
initializedCount `shouldBe` 1
positions `shouldSatisfy` \case
[] -> False
first : rest -> all (== first) rest
case positions of
[] -> expectationFailure "expected concurrent initialization results"
winner : _ ->
inventoryKeys <$> inventory store
`shouldReturn` [(name, 3, winner)]
it "runs through the resource-backed Store interpreter" $
withTestStore $ \store -> do
let name = SubscriptionName "initialize-resource"
key = SubscriptionCheckpointKey name 2
result <-
runEff
. runErrorNoCallStack @StoreError
. runKirokuStoreWith store
. runStoreResource
$ initializeSubscriptionCheckpoint name 2 FromBeginning
result `shouldBe` Right (Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0)))
assertExisting :: KirokuStore -> SubscriptionCheckpointKey -> MissingCheckpointPolicy -> IO ()
assertExisting store key@(SubscriptionCheckpointKey name member) policy =
initialize store name member policy
`shouldReturn` Right (ExistingCheckpoint key (GlobalPosition 5))
initialize ::
KirokuStore ->
SubscriptionName ->
Int32 ->
MissingCheckpointPolicy ->
IO (Either SubscriptionCheckpointMissing CheckpointInitialization)
initialize store name member policy = do
result <- runStoreIO store (initializeSubscriptionCheckpoint name member policy)
case result of
Left err -> expectationFailure ("checkpoint initialization failed: " <> show err) >> error "unreachable"
Right initialized -> pure initialized
inventory :: KirokuStore -> IO SubscriptionCheckpointInventory
inventory store = do
result <- runStoreIO store subscriptionCheckpointInventory
case result of
Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"
Right rows -> pure rows
inventoryKeys :: SubscriptionCheckpointInventory -> [(SubscriptionName, Int32, GlobalPosition)]
inventoryKeys (SubscriptionCheckpointInventory _ rows) =
[ (name, member, position)
| SubscriptionCheckpoint name member position _ <- V.toList rows
]
appendEvents :: KirokuStore -> Text -> Int -> IO ()
appendEvents store stream count = do
let events = [makeEvent ("Initialize" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]
result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events
case result of
Left err -> expectationFailure ("append failed: " <> show err)
Right _ -> pure ()