kiroku-store-0.2.0.0: test/Test/PublisherRestartNoRebroadcast.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.PublisherRestartNoRebroadcast (spec) where
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (when)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Text qualified as T
import Kiroku.Store
import Kiroku.Store.Subscription.Types (OverflowPolicy (..), SubscriptionConfigM (..))
import Kiroku.Test.Postgres (withMigratedTestDatabase)
import Test.Helpers (caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, waitWithTimeout)
import Test.Hspec
spec :: Spec
spec =
describe "publisher restart tail initialization" $ do
it "does not rebroadcast restart history into a blocked all-stream subscriber" $
withMigratedTestDatabase $ \connStr -> do
let settings = defaultConnectionSettings connStr
withStore settings $ \store ->
appendBatch store "tail-init-overflow-seed" "TailInitSeed" 1001
firstSeen <- newEmptyMVar
releaseFirst <- newEmptyMVar
caughtUp <- newEmptyMVar
blockedFirst <- newTVarIO True
ref <- newIORef ([] :: [GlobalPosition])
let subName = SubscriptionName "tail-init-no-rebroadcast"
stopType = EventType "TailInitStop"
handler' evt = do
modifyIORef' ref ((evt ^. #globalPosition) :)
shouldBlock <- atomically $ do
block <- readTVar blockedFirst
when block (writeTVar blockedFirst False)
pure block
when shouldBlock $ do
putMVar firstSeen ()
takeMVar releaseFirst
if evt ^. #eventType == stopType
then pure Stop
else pure Continue
cfg =
SubscriptionConfig
{ name = subName
, target = AllStreams
, handler = handler'
, batchSize = 10
, queueCapacity = 1
, overflowPolicy = DropSubscription
, consumerGroup = Nothing
, consumerGroupGuard = False
, retryPolicy = defaultRetryPolicy
, eventTypeFilter = AllEventTypes
, selector = Nothing
}
liveSettings =
settings
& #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)
withStore liveSettings $ \store -> do
handle <- subscribe store cfg
takeMVar firstSeen
Right _ <-
runStoreIO store $
appendToStream
(StreamName "tail-init-overflow-live")
NoStream
[makeEvent "TailInitDuringCatchUp" (Aeson.object [])]
waitForPublisher store (GlobalPosition 1002)
putMVar releaseFirst ()
waitForSubscriptionLive caughtUp
Right _ <-
runStoreIO store $
appendToStream
(StreamName "tail-init-overflow-stop")
NoStream
[makeEvent "TailInitStop" (Aeson.object [])]
result <- waitWithTimeout 10_000_000 handle
case result of
Left timeout -> expectationFailure timeout
Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)
Right (Right ()) -> pure ()
collected <- reverse <$> readIORef ref
collected `shouldBe` map GlobalPosition [1 .. 1003]
it "delivers a live event appended after a restarted subscriber catches up" $
withMigratedTestDatabase $ \connStr -> do
let settings = defaultConnectionSettings connStr
subName = SubscriptionName "tail-init-live-after-catchup"
withStore settings $ \store ->
appendBatch store "tail-init-live-seed" "TailInitLiveSeed" 3
caughtUp <- newEmptyMVar
ref <- newIORef ([] :: [GlobalPosition])
let cfg =
SubscriptionConfig
{ name = subName
, target = AllStreams
, handler = \evt -> do
modifyIORef' ref ((evt ^. #globalPosition) :)
if evt ^. #globalPosition == GlobalPosition 4
then pure Stop
else pure Continue
, batchSize = 10
, queueCapacity = 1
, overflowPolicy = DropSubscription
, consumerGroup = Nothing
, consumerGroupGuard = False
, retryPolicy = defaultRetryPolicy
, eventTypeFilter = AllEventTypes
, selector = Nothing
}
liveSettings =
settings
& #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)
withStore liveSettings $ \store -> do
handle <- subscribe store cfg
waitForSubscriptionLive caughtUp
Right _ <-
runStoreIO store $
appendToStream
(StreamName "tail-init-live-after-catchup")
NoStream
[makeEvent "TailInitLiveAfterCatchUp" (Aeson.object [])]
result <- waitWithTimeout 10_000_000 handle
case result of
Left timeout -> expectationFailure timeout
Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)
Right (Right ()) -> pure ()
collected <- reverse <$> readIORef ref
collected `shouldBe` map GlobalPosition [1 .. 4]
it "keeps per-subscription checkpoints independent from the publisher tail" $
withMigratedTestDatabase $ \connStr -> do
let settings = defaultConnectionSettings connStr
withStore settings $ \store ->
appendBatch store "tail-init-checkpoint-seed" "TailInitCheckpointSeed" 10
withStore settings $ \store -> do
runUntil store (SubscriptionName "tail-init-sub-a") 3
`shouldReturn` map GlobalPosition [1 .. 3]
runUntil store (SubscriptionName "tail-init-sub-b") 7
`shouldReturn` map GlobalPosition [1 .. 7]
withStore settings $ \store -> do
runUntil store (SubscriptionName "tail-init-sub-a") 7
`shouldReturn` map GlobalPosition [4 .. 10]
runUntil store (SubscriptionName "tail-init-sub-b") 3
`shouldReturn` map GlobalPosition [8 .. 10]
appendBatch :: KirokuStore -> T.Text -> T.Text -> Int -> IO ()
appendBatch store streamName typePrefix count = do
let events =
[ makeEvent (typePrefix <> T.pack (show i)) (Aeson.object [])
| i <- [1 .. count]
]
result <- runStoreIO store $ appendToStream (StreamName streamName) NoStream events
case result of
Left err -> expectationFailure ("append failed: " <> show err)
Right _ -> pure ()
runUntil :: KirokuStore -> SubscriptionName -> Int -> IO [GlobalPosition]
runUntil store subName targetCount = do
ref <- newIORef ([] :: [GlobalPosition])
countRef <- newTVarIO (0 :: Int)
let handler' evt = do
modifyIORef' ref ((evt ^. #globalPosition) :)
n <- atomically $ do
count <- readTVar countRef
let count' = count + 1
writeTVar countRef count'
pure count'
if n >= targetCount
then pure Stop
else pure Continue
cfg =
SubscriptionConfig
{ name = subName
, target = AllStreams
, handler = handler'
, batchSize = 10
, queueCapacity = 1
, overflowPolicy = DropSubscription
, consumerGroup = Nothing
, consumerGroupGuard = False
, retryPolicy = defaultRetryPolicy
, eventTypeFilter = AllEventTypes
, selector = Nothing
}
handle <- subscribe store cfg
result <- waitWithTimeout 10_000_000 handle
case result of
Left timeout -> expectationFailure timeout
Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)
Right (Right ()) -> pure ()
reverse <$> readIORef ref