kiroku-store-0.3.0.0: test/Test/PublisherCallbackResilience.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE OverloadedStrings #-}
module Test.PublisherCallbackResilience (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (newEmptyMVar, tryPutMVar)
import Control.Concurrent.STM (atomically, check, newTVarIO, readTVar, writeTVar)
import Control.Exception (Exception, throwIO)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson qualified as Aeson
import Data.Data (Typeable)
import Data.Generics.Labels ()
import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef)
import Kiroku.Store
import Test.Helpers (caughtUpEventHandler, makeEvent, waitForSubscriptionLive, waitWithTimeout, withTestStoreSettings)
import Test.Hspec
data CallbackBoom = CallbackBoom
deriving stock (Show, Typeable)
deriving anyclass (Exception)
timeoutMicros :: Int
timeoutMicros = 10_000_000
within :: String -> IO a -> IO a
within label action = do
result <- Async.race (threadDelay timeoutMicros) action
case result of
Left () -> fail ("timed out waiting for " <> label)
Right a -> pure a
spec :: Spec
spec = describe "publisher callback resilience" $ do
it "keeps the publisher alive when decodeHook throws once" $ do
failedOnce <- newIORef False
loopErrorSeen <- newEmptyMVar
caughtUp <- newEmptyMVar
delivered <- newIORef ([] :: [EventType])
deliveredCount <- newTVarIO (0 :: Int)
let subName = SubscriptionName "publisher-decode-hook-resilience"
decodeOnce event
| event ^. #eventType == EventType "Boom" = do
alreadyFailed <- atomicModifyIORef' failedOnce (\old -> (True, old))
if alreadyFailed
then pure event
else throwIO CallbackBoom
| otherwise = pure event
observe evt = do
case evt of
KirokuEventPublisherLoopError{} -> () <$ tryPutMVar loopErrorSeen ()
_ -> pure ()
caughtUpEventHandler subName caughtUp Nothing evt
tweak settings =
settings
& #storeSettings .~ defaultStoreSettings{decodeHook = Just decodeOnce}
& #eventHandler .~ Just observe
handler event = do
modifyIORef' delivered ((event ^. #eventType) :)
atomically $ do
n <- readTVar deliveredCount
let n' = n + 1
writeTVar deliveredCount n'
pure $ if n' >= 2 then Stop else Continue
withTestStoreSettings tweak $ \store -> do
handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)
waitForSubscriptionLive caughtUp
Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-boom") NoStream [makeEvent "Boom" (Aeson.object [])]
within "publisher loop error event" (waitForSubscriptionLive loopErrorSeen)
Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-ok") NoStream [makeEvent "Ok" (Aeson.object [])]
result <- waitWithTimeout timeoutMicros handle
case result of
Right (Right ()) -> pure ()
Left timeout -> expectationFailure timeout
Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)
seen <- reverse <$> readIORef delivered
seen `shouldBe` [EventType "Boom", EventType "Ok"]
it "drops throwing eventHandler exceptions without killing publisher or worker" $ do
deliveredCount <- newTVarIO (0 :: Int)
let tweak settings = settings & #eventHandler .~ Just (\_ -> throwIO CallbackBoom)
handler _ = do
atomically $ do
n <- readTVar deliveredCount
let n' = n + 1
writeTVar deliveredCount n'
pure $ if n' >= 3 then Stop else Continue
withTestStoreSettings tweak $ \store -> do
handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "throwing-event-handler-resilience") AllStreams handler)
Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-1") NoStream [makeEvent "One" (Aeson.object [])]
Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-2") NoStream [makeEvent "Two" (Aeson.object [])]
Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-3") NoStream [makeEvent "Three" (Aeson.object [])]
result <- waitWithTimeout timeoutMicros handle
case result of
Right (Right ()) -> pure ()
Left timeout -> expectationFailure timeout
Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)
finalCount <- atomically (readTVar deliveredCount)
finalCount `shouldBe` 3