packages feed

kiroku-store-0.3.0.0: test/Test/StartupFailureSurfacing.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.StartupFailureSurfacing (spec) where

import Control.Concurrent.Async qualified as Async
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, readTVarIO, writeTVar)
import Control.Exception (SomeException, fromException)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (replicateM_)
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.IntMap.Strict qualified as IntMap
import Data.Map.Strict qualified as Map
import Hasql.Pool qualified as Pool
import Kiroku.Store
import Kiroku.Store.Subscription.EventPublisher qualified as Pub
import Kiroku.Store.Subscription.Worker (withLoadCheckpointHookForTest)
import Test.Helpers (waitWithTimeout, withTestStore, withTestStoreSettings)
import Test.Hspec

spec :: Spec
spec = describe "startup failure surfacing" $ do
    it "fails subscription startup loudly when checkpoint load fails" $ do
        handlerCalled <- newTVarIO False
        observedRef <- newIORef ([] :: [KirokuEvent])
        let subName = SubscriptionName "load-checkpoint-fails-loudly"
            cfg =
                defaultSubscriptionConfig subName AllStreams $ \_ -> do
                    atomically (writeTVar handlerCalled True)
                    pure Continue
            observe evt = modifyIORef' observedRef (evt :)
            injectLoadFailure _ = pure (Just (Left Pool.AcquisitionTimeoutUsageError))
            tweak settings = settings & #eventHandler .~ Just observe

        withTestStoreSettings tweak $ \store ->
            withLoadCheckpointHookForTest injectLoadFailure $ do
                handle <- subscribe store cfg
                result <- waitWithTimeout 5_000_000 handle
                case result of
                    Right (Left e)
                        | Just Pool.AcquisitionTimeoutUsageError <- fromException e -> pure ()
                    Left timeout -> expectationFailure timeout
                    Right other -> expectationFailure ("expected checkpoint load failure, got: " <> show other)

        wasCalled <- readTVarIO handlerCalled
        wasCalled `shouldBe` False
        observed <- reverse <$> readIORef observedRef
        any (isLoadCheckpointError subName) observed `shouldBe` True
        any (isCrashedStop subName) observed `shouldBe` True

    it "leaves no publisher or subscription registry entries after a subscribe/cancel storm" $
        withTestStore $ \store -> do
            let cfg = defaultSubscriptionConfig (SubscriptionName "subscribe-cancel-storm") AllStreams (\_ -> pure Continue)
            -- 200 iterations gives async exceptions many chances to land in the
            -- pre-fork window without making the test expensive on normal runs.
            replicateM_ 200 $ do
                pending <- Async.async (subscribe store cfg)
                Async.cancel pending
                outcome <- Async.waitCatch pending
                case outcome of
                    Right handle -> cancel handle >> (() <$ wait handle)
                    Left (_ :: SomeException) -> pure ()

            subs <- readTVarIO (Pub.subscribers (store ^. #publisher))
            reg <- readTVarIO (store ^. #subscriptionRegistry)
            IntMap.null subs `shouldBe` True
            Map.null reg `shouldBe` True

isLoadCheckpointError :: SubscriptionName -> KirokuEvent -> Bool
isLoadCheckpointError expected = \case
    KirokuEventSubscriptionDbError actual LoadCheckpoint Pool.AcquisitionTimeoutUsageError _
        | actual == expected -> True
    _ -> False

isCrashedStop :: SubscriptionName -> KirokuEvent -> Bool
isCrashedStop expected = \case
    KirokuEventSubscriptionStopped actual _ (StopWorkerCrashed e) _
        | actual == expected
        , Just Pool.AcquisitionTimeoutUsageError <- fromException e ->
            True
    _ -> False