packages feed

kiroku-store-0.2.0.0: test/Test/SubscriptionState.hs

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

{- | Regression tests for ExecPlan 41 M4 — observable FSM state and the
no-missed-events / monotonic-checkpoint invariants under the FSM driver.

The first spec reads the worker's current state through the new
'Kiroku.Store.Subscription.Types.currentState' handle accessor: while a handler
blocks on its first catch-up event the worker is observably @CatchingUp@, and
once it has caught up it is observably @Live@.

The second spec proves the FSM driver preserves the core delivery invariant: with
events appended both before and after the subscription starts, every event is
delivered exactly once in strictly increasing 'GlobalPosition' across the
catch-up → live boundary, and the durable checkpoint advances to the last
position (a monotonic, no-gap result).
-}
module Test.SubscriptionState (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Int (Int32)
import Data.Text qualified as T
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Kiroku.Store
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.Fsm (SubscriptionState (..))
import Kiroku.Store.Subscription.Types (SubscriptionConfigM (..))
import Test.Helpers (caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, waitWithTimeout, withTestStoreSettings)
import Test.Hspec

isCatchingUp :: SubscriptionState -> Bool
isCatchingUp CatchingUp{} = True
isCatchingUp _ = False

isLive :: SubscriptionState -> Bool
isLive Live{} = True
isLive _ = False

-- Poll an FSM-state read until the predicate holds or the budget runs out.
-- The read is the handle's 'currentState', which is @Maybe SubscriptionState@:
-- @Nothing@ (not currently live) is treated as "not yet in the wanted state".
waitUntilState :: Int -> IO (Maybe SubscriptionState) -> (SubscriptionState -> Bool) -> IO Bool
waitUntilState budget readSt p
    | budget <= 0 = pure False
    | otherwise = do
        s <- readSt
        if maybe False p s
            then pure True
            else threadDelay 20_000 >> waitUntilState (budget - 20_000) readSt p

readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int)
readCheckpoint store subName = do
    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)
    case result of
        Left err -> error ("readCheckpoint failed: " <> show err)
        Right mPos -> pure (fmap fromIntegral mPos)

spec :: Spec
spec = describe "subscription FSM — observable state (EP-41 M4)" $ do
    it "reports CatchingUp while blocked in catch-up and Live once caught up" $ do
        caughtUp <- newEmptyMVar
        firstSeen <- newEmptyMVar
        release <- newEmptyMVar
        seen <- newTVarIO (0 :: Int)
        let subName = SubscriptionName "state-readout-test"
            observe = caughtUpEventHandler subName caughtUp Nothing
            handler' _evt = do
                n <- atomically $ do
                    c <- readTVar seen
                    writeTVar seen (c + 1)
                    pure (c + 1)
                if n == 1
                    then putMVar firstSeen () >> takeMVar release
                    else pure ()
                pure Continue
            cfg =
                SubscriptionConfig
                    { name = subName
                    , target = AllStreams
                    , handler = handler'
                    , batchSize = 100
                    , queueCapacity = 16
                    , overflowPolicy = PauseAndResume
                    , consumerGroup = Nothing
                    , consumerGroupGuard = False
                    , retryPolicy = defaultRetryPolicy
                    , eventTypeFilter = AllEventTypes
                    , selector = Nothing
                    }
        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do
            Right _ <- runStoreIO store $ appendToStream (StreamName "st-1") NoStream [makeEvent "A" (Aeson.object [])]
            Right _ <- runStoreIO store $ appendToStream (StreamName "st-2") NoStream [makeEvent "B" (Aeson.object [])]
            Right _ <- runStoreIO store $ appendToStream (StreamName "st-3") NoStream [makeEvent "C" (Aeson.object [])]
            waitForPublisher store (GlobalPosition 3)
            handle <- subscribe store cfg
            -- Worker is now blocked inside the handler on the first catch-up event.
            takeMVar firstSeen
            Just st <- currentState handle
            st `shouldSatisfy` isCatchingUp
            -- Release: catch-up completes, the worker goes live.
            putMVar release ()
            waitForSubscriptionLive caughtUp
            becameLive <- waitUntilState 5_000_000 (currentState handle) isLive
            becameLive `shouldBe` True
            cancel handle

    it "delivers every event exactly once in order across the catch-up to live boundary" $ do
        delivered <- newIORef ([] :: [GlobalPosition])
        seen <- newTVarIO (0 :: Int)
        let subName = SubscriptionName "no-missed-test"
            handler' evt = do
                modifyIORef' delivered ((evt ^. #globalPosition) :)
                n <- atomically $ do
                    c <- readTVar seen
                    writeTVar seen (c + 1)
                    pure (c + 1)
                if n >= 10 then pure Stop else pure Continue
            cfg =
                SubscriptionConfig
                    { name = subName
                    , target = AllStreams
                    , handler = handler'
                    , batchSize = 100
                    , queueCapacity = 16
                    , overflowPolicy = PauseAndResume
                    , consumerGroup = Nothing
                    , consumerGroupGuard = False
                    , retryPolicy = defaultRetryPolicy
                    , eventTypeFilter = AllEventTypes
                    , selector = Nothing
                    }
        withTestStoreSettings Prelude.id $ \store -> do
            -- Five events before subscribing (delivered during catch-up).
            let appendAt i =
                    runStoreIO store $
                        appendToStream (StreamName ("nm-" <> T.pack (show (i :: Int)))) NoStream [makeEvent "E" (Aeson.object [])]
            mapM_ (\i -> appendAt i >> pure ()) [1 .. 5]
            waitForPublisher store (GlobalPosition 5)
            handle <- subscribe store cfg
            -- Five more after (delivered live).
            mapM_ (\i -> appendAt i >> pure ()) [6 .. 10]
            result <- waitWithTimeout 20_000_000 handle
            case result of
                Left timeout -> expectationFailure timeout
                Right (Left e) -> expectationFailure ("subscription failed: " <> show e)
                Right (Right ()) -> pure ()
            ds <- reverse <$> readIORef delivered
            ds `shouldBe` map GlobalPosition [1 .. 10]
            cp <- readCheckpoint store "no-missed-test"
            cp `shouldBe` Just 10