packages feed

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

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}

{- | Tests for the central subscription-state registry (ExecPlan 45 / EP-1 of
MasterPlan 7).

These prove the registry's observable behaviour against a real migrated
PostgreSQL database:

  * Several live subscriptions (including consumer-group members) appear in the
    'Kiroku.Store.Subscription.subscriptionStates' snapshot with sensible state
    labels, matching keys, and a valid FSM cursor, and a live handle's
    'currentState' agrees with its snapshot entry (the single-source-of-truth
    reshape).
  * A subscription's key disappears from the snapshot when it stops cleanly, is
    cancelled, or crashes — and its held handle's 'currentState' then returns
    'Nothing'.
  * A stale duplicate-key worker's cleanup cannot delete a newer worker's
    replacement entry (the per-worker token defends the active entry).
-}
module Test.SubscriptionRegistry (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (readTVarIO)
import Control.Exception (throwIO)
import Control.Lens ((^.))
import Data.Aeson qualified as Aeson
import Data.Foldable (for_)
import Data.Generics.Labels ()
import Data.Int (Int32)
import Data.IntMap.Strict qualified as IntMap
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust, isNothing)
import Data.Text (Text)
import Kiroku.Store
import Kiroku.Store.Subscription.EventPublisher qualified as Pub
import Kiroku.Store.Subscription.Types (SubscriptionConfigM (..))
import Test.Helpers (makeEvent, waitForPublisher, withTestStore)
import Test.Hspec

-- | A plain @$all@ subscription config whose handler never stops.
plainCont :: Text -> SubscriptionConfig
plainCont nm = defaultSubscriptionConfig (SubscriptionName nm) AllStreams (\_ -> pure Continue)

-- | A size-@n@ category-group config for member @m@ whose handler never stops.
groupCont :: Text -> Text -> Int32 -> Int32 -> SubscriptionConfig
groupCont nm cat m n =
    (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) (\_ -> pure Continue))
        { consumerGroup = Just (ConsumerGroup{member = m, size = n})
        }

-- | A size-@n@ @$all@ group config for member @m@ whose handler never stops.
groupAllCont :: Text -> Int32 -> Int32 -> SubscriptionConfig
groupAllCont nm m n =
    (defaultSubscriptionConfig (SubscriptionName nm) AllStreams (\_ -> pure Continue))
        { consumerGroup = Just (ConsumerGroup{member = m, size = n})
        }

publisherSubscriberCount :: KirokuStore -> IO Int
publisherSubscriberCount store =
    IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))

-- | Poll the snapshot until the keyed entry reaches the given 'statePhase' label.
waitUntilPhase :: Int -> KirokuStore -> (SubscriptionName, Int32) -> Text -> IO Bool
waitUntilPhase budget store key phase
    | budget <= 0 = pure False
    | otherwise = do
        m <- subscriptionStates store
        case Map.lookup key m of
            Just v | (v ^. #statePhase) == phase -> pure True
            _ -> threadDelay 20_000 >> waitUntilPhase (budget - 20_000) store key phase

-- | Poll the snapshot until the keyed entry is present.
waitUntilPresent :: Int -> KirokuStore -> (SubscriptionName, Int32) -> IO Bool
waitUntilPresent budget store key
    | budget <= 0 = pure False
    | otherwise = do
        m <- subscriptionStates store
        if Map.member key m
            then pure True
            else threadDelay 20_000 >> waitUntilPresent (budget - 20_000) store key

-- | Poll the snapshot until the keyed entry is absent.
waitUntilAbsent :: Int -> KirokuStore -> (SubscriptionName, Int32) -> IO Bool
waitUntilAbsent budget store key
    | budget <= 0 = pure False
    | otherwise = do
        m <- subscriptionStates store
        if Map.member key m
            then threadDelay 20_000 >> waitUntilAbsent (budget - 20_000) store key
            else pure True

spec :: Spec
spec = describe "subscription registry (EP-1 / plan 45)" $ do
    it "registers every live subscription with a sensible state and position" $
        withTestStore $ \store -> do
            let plainName = SubscriptionName "reg-plain"
                groupName = SubscriptionName "reg-group"
            Right _ <- runStoreIO store $ appendToStream (StreamName "reg-1") NoStream [makeEvent "A" (Aeson.object [])]
            Right _ <- runStoreIO store $ appendToStream (StreamName "reg-2") NoStream [makeEvent "B" (Aeson.object [])]
            waitForPublisher store (GlobalPosition 2)
            hPlain <- subscribe store (plainCont "reg-plain")
            hG0 <- subscribe store (groupCont "reg-group" "reg" 0 2)
            hG1 <- subscribe store (groupCont "reg-group" "reg" 1 2)
            okP <- waitUntilPhase 5_000_000 store (plainName, 0) "live"
            ok0 <- waitUntilPhase 5_000_000 store (groupName, 0) "live"
            ok1 <- waitUntilPhase 5_000_000 store (groupName, 1) "live"
            (okP, ok0, ok1) `shouldBe` (True, True, True)
            snap <- subscriptionStates store
            Map.keys snap `shouldMatchList` [(plainName, 0), (groupName, 0), (groupName, 1)]
            for_ (Map.toList snap) $ \((nm, mbr), v) -> do
                (v ^. #subscriptionName) `shouldBe` nm
                (v ^. #member) `shouldBe` mbr
                (v ^. #statePhase) `shouldSatisfy` (`elem` (["catching_up", "live"] :: [Text]))
                let GlobalPosition p = v ^. #cursor
                p `shouldSatisfy` (>= 0)
            -- currentState on a live handle agrees with its snapshot entry.
            Just s <- currentState hPlain
            case Map.lookup (plainName, 0) snap of
                Just v -> stateName s `shouldBe` (v ^. #statePhase)
                Nothing -> expectationFailure "plain subscription missing from snapshot"
            mapM_ cancel [hPlain, hG0, hG1]

    it "removes a subscription's entry when it stops cleanly" $
        withTestStore $ \store -> do
            let nm = SubscriptionName "reg-stop"
            Right _ <- runStoreIO store $ appendToStream (StreamName "stop-1") NoStream [makeEvent "A" (Aeson.object [])]
            waitForPublisher store (GlobalPosition 1)
            handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> pure Stop))
            _ <- wait handle
            gone <- waitUntilAbsent 5_000_000 store (nm, 0)
            gone `shouldBe` True
            cs <- currentState handle
            cs `shouldSatisfy` isNothing

    it "removes a subscription's entry when it is cancelled" $
        withTestStore $ \store -> do
            let nm = SubscriptionName "reg-cancel"
            Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-1") NoStream [makeEvent "A" (Aeson.object [])]
            waitForPublisher store (GlobalPosition 1)
            handle <- subscribe store (plainCont "reg-cancel")
            live <- waitUntilPhase 5_000_000 store (nm, 0) "live"
            live `shouldBe` True
            cancel handle
            _ <- wait handle
            gone <- waitUntilAbsent 5_000_000 store (nm, 0)
            gone `shouldBe` True
            cs <- currentState handle
            cs `shouldSatisfy` isNothing

    it "removes a subscription's entry when its handler crashes" $
        withTestStore $ \store -> do
            let nm = SubscriptionName "reg-crash"
            Right _ <- runStoreIO store $ appendToStream (StreamName "crash-1") NoStream [makeEvent "A" (Aeson.object [])]
            waitForPublisher store (GlobalPosition 1)
            handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> throwIO (userError "boom")))
            res <- wait handle
            case res of
                Left _ -> pure ()
                Right () -> expectationFailure "expected the crashing handler to fail the worker"
            gone <- waitUntilAbsent 5_000_000 store (nm, 0)
            gone `shouldBe` True
            cs <- currentState handle
            cs `shouldSatisfy` isNothing

    it "stale duplicate-key cleanup does not remove the replacement entry" $
        withTestStore $ \store -> do
            let nm = SubscriptionName "reg-dup"
            h1 <- subscribe store (plainCont "reg-dup")
            present1 <- waitUntilPresent 5_000_000 store (nm, 0)
            present1 `shouldBe` True
            -- A second subscribe with the same (name, member) supersedes h1's
            -- entry in the registry (insert is synchronous, before the fork).
            h2 <- subscribe store (plainCont "reg-dup")
            -- Stopping the first worker must not delete the second's live entry.
            cancel h1
            _ <- wait h1
            snap <- subscriptionStates store
            Map.member (nm, 0) snap `shouldBe` True
            cs1 <- currentState h1
            cs1 `shouldSatisfy` isNothing
            cs2 <- currentState h2
            cs2 `shouldSatisfy` isJust
            cancel h2

    describe "publisher queue registration" $ do
        it "registers publisher queues only for non-group AllStreams subscriptions" $
            withTestStore $ \store -> do
                initial <- publisherSubscriberCount store
                initial `shouldBe` 0

                hCategory <- subscribe store (defaultSubscriptionConfig (SubscriptionName "reg-cat-no-queue") (Category (CategoryName "regcat")) (\_ -> pure Continue))
                publisherSubscriberCount store `shouldReturn` 0

                hGroupCategory <- subscribe store (groupCont "reg-group-cat-no-queue" "regcat" 0 2)
                publisherSubscriberCount store `shouldReturn` 0

                hGroupAll <- subscribe store (groupAllCont "reg-group-all-no-queue" 0 2)
                publisherSubscriberCount store `shouldReturn` 0

                hAll <- subscribe store (plainCont "reg-all-has-queue")
                publisherSubscriberCount store `shouldReturn` 1

                cancel hAll
                _ <- wait hAll
                publisherSubscriberCount store `shouldReturn` 0

                cancel hCategory
                _ <- wait hCategory
                publisherSubscriberCount store `shouldReturn` 0

                cancel hGroupCategory
                _ <- wait hGroupCategory
                publisherSubscriberCount store `shouldReturn` 0

                cancel hGroupAll
                _ <- wait hGroupAll
                publisherSubscriberCount store `shouldReturn` 0