kiroku-store-0.5.0.0: test/Test/SubscriptionCheckpointWorker.hs
{-# LANGUAGE NumericUnderscores #-}
module Test.SubscriptionCheckpointWorker (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar, tryPutMVar)
import Control.Concurrent.STM (atomically, check, modifyTVar', newTVarIO, readTVar, readTVarIO, writeTVar)
import Control.Exception (SomeException, finally, fromException, try)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (forM, void)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.List (sortOn)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Effectful (runEff)
import Kiroku.Store
import Kiroku.Store.Subscription.Effect qualified as SubEff
import Kiroku.Store.Subscription.Stream (subscriptionAckStream)
import Streamly.Data.Stream qualified as Stream
import Test.Helpers (makeEvent, waitForPublisher, waitWithTimeout, withTestStore, withTestStoreSettings)
import Test.Hspec
spec :: Spec
spec = describe "subscription checkpoint worker policies" $ do
it "starts a non-group worker FromBeginning and reports the durable seed" $ do
eventsRef <- newIORef []
resolutionsRef <- newIORef []
let name = SubscriptionName "worker-from-beginning"
observe evt = case evt of
KirokuEventSubscriptionCheckpointResolved initialization NonGroup ->
modifyIORef' resolutionsRef (initialization :)
_ -> pure ()
tweak settings = settings & #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store -> do
appendBatch store "worker-from-beginning-events" 3
waitForPublisher store (GlobalPosition 3)
let handler event = do
modifyIORef' eventsRef (event ^. #globalPosition :)
seen <- length <$> readIORef eventsRef
pure (if seen >= 3 then Stop else Continue)
config =
(defaultSubscriptionConfig name AllStreams handler)
{ missingCheckpointPolicy = FromBeginning
}
handle <- subscribe store config
expectClean handle
reverse <$> readIORef eventsRef
`shouldReturn` fmap GlobalPosition [1, 2, 3]
inventoryPositions store
`shouldReturn` [(SubscriptionCheckpointKey name 0, GlobalPosition 3)]
readIORef resolutionsRef
`shouldReturn` [InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0)]
it "forms a clean FromCurrentHead cut while appends race startup" $ do
initializationReady <- newEmptyMVar
deliveredRef <- newIORef []
let name = SubscriptionName "worker-current-head-race"
key = SubscriptionCheckpointKey name 0
observe evt = case evt of
KirokuEventSubscriptionCheckpointResolved initialization NonGroup
| initializationKey initialization == key ->
void (tryPutMVar initializationReady initialization)
_ -> pure ()
tweak settings = settings & #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store -> do
appendBatch store "worker-current-head-race-events" 10
waitForPublisher store (GlobalPosition 10)
gate <- newEmptyMVar
let handler event = do
modifyIORef' deliveredRef (event ^. #globalPosition :)
pure $ if event ^. #eventType == EventType "RaceSentinel" then Stop else Continue
config =
(defaultSubscriptionConfig name AllStreams handler)
{ missingCheckpointPolicy = FromCurrentHead
, batchSize = 3
}
subscribeThread <- Async.async (takeMVar gate >> subscribe store config)
appendThread <- Async.async $ do
takeMVar gate
forM [1 .. 20 :: Int] $ \i ->
appendExisting store "worker-current-head-race-events" ("Racing" <> T.pack (show i))
putMVar gate ()
putMVar gate ()
handle <- Async.wait subscribeThread
initialization <- waitMVar "checkpoint resolution" initializationReady
racePositions <- Async.wait appendThread
raceTail <- case reverse racePositions of
[] -> expectationFailure "expected racing appends" >> error "unreachable"
position : _ -> pure position
let seed = checkpointInitializationPosition initialization
waitForPublisher store raceTail
finalPosition <- appendExisting store "worker-current-head-race-events" "RaceSentinel"
waitForPublisher store finalPosition
expectClean handle
initialization `shouldBe` InitializedCheckpoint FromCurrentHead key seed
delivered <- reverse <$> readIORef deliveredRef
delivered `shouldBe` positionsAfter seed finalPosition
it "initializes each consumer-group member independently at the current head" $ do
resolutionsVar <- newTVarIO []
handlerCalled <- newTVarIO False
let name = SubscriptionName "worker-current-head-members"
observe evt = case evt of
KirokuEventSubscriptionCheckpointResolved initialization GroupMember{} ->
atomically (modifyTVar' resolutionsVar (initialization :))
_ -> pure ()
tweak settings = settings & #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store -> do
appendBatch store "worker-current-head-members-events" 6
waitForPublisher store (GlobalPosition 6)
let config member =
( defaultSubscriptionConfig name AllStreams $ \_ -> do
atomically (writeTVar handlerCalled True)
pure Continue
)
{ consumerGroup = Just (ConsumerGroup member 2)
, missingCheckpointPolicy = FromCurrentHead
}
handles <- mapM (subscribe store . config) [0, 1]
atomically $ do
resolutions <- readTVar resolutionsVar
check (length resolutions >= 2)
mapM_ cancel handles
mapM_ wait handles
resolutions <- sortOn initializationKey <$> readTVarIO resolutionsVar
resolutions
`shouldBe` [ InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 0) (GlobalPosition 6)
, InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 6)
]
readTVarIO handlerCalled `shouldReturn` False
inventoryPositions store
`shouldReturn` [ (SubscriptionCheckpointKey name 0, GlobalPosition 6)
, (SubscriptionCheckpointKey name 1, GlobalPosition 6)
]
it "preserves FailIfMissing through the bracketed plain-IO entry point" $
withTestStore $ \store -> do
let name = SubscriptionName "worker-bracketed-missing"
key = SubscriptionCheckpointKey name 0
config =
(defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))
{ missingCheckpointPolicy = FailIfMissing
}
outcome <- withSubscription store config wait
case outcome of
Left err
| Just (SubscriptionCheckpointMissing actual) <- fromException err ->
actual `shouldBe` key
other -> expectationFailure ("expected bracketed missing refusal, got: " <> show other)
it "preserves FromCurrentHead through the higher-order effect entry point" $ do
initializationReady <- newEmptyMVar
deliveredRef <- newIORef []
let name = SubscriptionName "worker-effect-current-head"
key = SubscriptionCheckpointKey name 0
observe evt = case evt of
KirokuEventSubscriptionCheckpointResolved initialization NonGroup
| initializationKey initialization == key ->
void (tryPutMVar initializationReady initialization)
_ -> pure ()
tweak settings = settings & #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store -> do
appendBatch store "worker-effect-current-head-events" 5
waitForPublisher store (GlobalPosition 5)
runEff $ SubEff.runSubscription store $ do
let config =
( defaultSubscriptionConfig name AllStreams $ \event -> do
liftIO (modifyIORef' deliveredRef (event ^. #globalPosition :))
pure Stop
)
{ missingCheckpointPolicy = FromCurrentHead
}
handle <- SubEff.subscribe config
initialization <- liftIO (waitMVar "effect checkpoint resolution" initializationReady)
liftIO $ initialization `shouldBe` InitializedCheckpoint FromCurrentHead key (GlobalPosition 5)
position <- liftIO $ appendExisting store "worker-effect-current-head-events" "EffectFuture"
liftIO (waitForPublisher store position)
liftIO (expectClean handle)
reverse <$> readIORef deliveredRef `shouldReturn` [GlobalPosition 6]
it "preserves FailIfMissing through the Streamly bridge" $
withTestStore $ \store -> do
let name = SubscriptionName "worker-streamly-missing"
key = SubscriptionCheckpointKey name 0
config =
(defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))
{ missingCheckpointPolicy = FailIfMissing
}
(stream, cancelStream) <- subscriptionAckStream store config 1
pulled <- finally (try (Stream.uncons stream)) cancelStream
case pulled of
Left err
| Just (SubscriptionCheckpointMissing actual) <- fromException (err :: SomeException) ->
actual `shouldBe` key
Left err -> expectationFailure ("expected typed Streamly refusal, got: " <> show err)
Right _ -> expectationFailure "expected Streamly bridge startup to fail"
initializationKey :: CheckpointInitialization -> SubscriptionCheckpointKey
initializationKey = \case
ExistingCheckpoint key _ -> key
InitializedCheckpoint _ key _ -> key
positionsAfter :: GlobalPosition -> GlobalPosition -> [GlobalPosition]
positionsAfter (GlobalPosition start) (GlobalPosition end) =
fmap GlobalPosition [start + 1 .. end]
inventoryPositions :: KirokuStore -> IO [(SubscriptionCheckpointKey, GlobalPosition)]
inventoryPositions store = do
Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory
pure
[ (SubscriptionCheckpointKey name member, position)
| SubscriptionCheckpoint name member position _ <- V.toList rows
]
appendBatch :: KirokuStore -> Text -> Int -> IO GlobalPosition
appendBatch store stream count = do
let events = [makeEvent ("History" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]
Right result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events
pure (result ^. #globalPosition)
appendExisting :: KirokuStore -> Text -> Text -> IO GlobalPosition
appendExisting store stream typ = do
Right result <-
runStoreIO store $
appendToStream (StreamName stream) StreamExists [makeEvent typ (Aeson.object [])]
pure (result ^. #globalPosition)
expectClean :: SubscriptionHandle -> IO ()
expectClean handle = do
result <- waitWithTimeout 15_000_000 handle
case result of
Left message -> expectationFailure message
Right (Left err) -> expectationFailure ("subscription failed: " <> show err)
Right (Right ()) -> pure ()
waitMVar :: String -> MVar a -> IO a
waitMVar label var = do
result <- Async.race (threadDelay 10_000_000) (takeMVar var)
case result of
Left () -> expectationFailure ("timed out waiting for " <> label) >> error "unreachable"
Right value -> pure value