packages feed

kiroku-store-0.8.0.0: test/Test/SubscriptionPauseResume.hs

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

{- | Regression tests for ExecPlan 41 M2 — recoverable backpressure.

Before M2, a slow AllStreams consumer that filled its bounded queue was
__terminal__: the publisher marked it @Overflowed@ and the worker threw
'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' and died (overflow
policy 'Kiroku.Store.Subscription.Types.DropSubscription'). M2 adds the
recoverable 'Kiroku.Store.Subscription.Types.PauseAndResume' policy (now the
default): when the queue fills the publisher /pauses/ delivery rather than
killing the subscriber, and the worker — once its slow handler catches up —
drains the stale queue and re-reads the events it missed directly from the
database from its checkpoint. No event is lost and the checkpoint advances
monotonically.

The first spec proves the recovery: a handler that blocks long enough to fill a
@queueCapacity = 1@ subscriber while five events are appended still delivers all
five in order, stops cleanly (not via 'SubscriptionOverflowed'), advances the
checkpoint to the last position, and emits a
'Kiroku.Store.Observability.KirokuEventSubscriptionPaused' followed by a
'Kiroku.Store.Observability.KirokuEventSubscriptionResumed'.

The second spec proves the fail-fast path is preserved: the identical scenario
under 'DropSubscription' still surfaces 'SubscriptionOverflowed' on @wait@.

To confirm the first spec actually pins the new behavior: temporarily set its
@overflowPolicy@ to 'DropSubscription' and it fails with
@Left (SubscriptionOverflowed ...)@ where it expected @Right ()@.
-}
module Test.SubscriptionPauseResume (spec) where

import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)
import Control.Exception qualified
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 Test.Helpers (makeEvent, waitForPublisher, waitWithTimeout, withTestStoreSettings)
import Test.Hspec

-- Read the durable checkpoint (@kiroku.subscriptions.last_seen@) for a non-group
-- subscription (member 0), or 'Nothing' if no row exists yet.
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 — recoverable backpressure (EP-41 M2)" $ do
    it "pauses a slow AllStreams consumer and resumes, delivering all events" $ do
        evtRef <- newIORef ([] :: [KirokuEvent])
        let evtHandler e = modifyIORef' evtRef (e :)
        withTestStoreSettings (& #eventHandler .~ Just evtHandler) $ \store -> do
            firstSeen <- newEmptyMVar
            release <- newEmptyMVar
            delivered <- newIORef ([] :: [GlobalPosition])
            seenCount <- newTVarIO (0 :: Int)
            let handler' evt = do
                    modifyIORef' delivered ((evt ^. #globalPosition) :)
                    n <- atomically $ do
                        c <- readTVar seenCount
                        writeTVar seenCount (c + 1)
                        pure (c + 1)
                    if n == 1
                        then putMVar firstSeen () >> takeMVar release
                        else pure ()
                    -- Stop once all five have been seen, so `wait` resolves Right ().
                    if n >= 5 then pure Stop else pure Continue
            let cfg =
                    SubscriptionConfig
                        { name = SubscriptionName "pause-resume-test"
                        , target = AllStreams
                        , handler = handler'
                        , batchSize = 100
                        , queueCapacity = 1
                        , overflowPolicy = PauseAndResume
                        , consumerGroup = Nothing
                        , consumerGroupGuard = False
                        , missingCheckpointPolicy = FromBeginning
                        , retryPolicy = defaultRetryPolicy
                        , eventTypeFilter = AllEventTypes
                        , selector = Nothing
                        }
            handle <- subscribe store cfg
            -- First append: the worker reads it from the queue and the handler
            -- blocks inside it on `release`, so the worker stops draining.
            Right _ <- runStoreIO store $ appendToStream (StreamName "pr-1") NoStream [makeEvent "E1" (Aeson.object [])]
            takeMVar firstSeen
            -- With the worker stuck, append four more one at a time. Capacity is 1,
            -- so the second of these fills the queue and the publisher pauses the
            -- subscriber for the rest (skipping, not dropping).
            let appendOne i = do
                    let sn = StreamName ("pr-" <> T.pack (show (i :: Int)))
                    Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]
                    waitForPublisher store (GlobalPosition (fromIntegral i))
            appendOne 2
            appendOne 3
            appendOne 4
            appendOne 5
            -- Release the slow handler; the worker drains, resumes, re-catches-up
            -- from its checkpoint, and delivers the skipped events.
            putMVar release ()
            result <- waitWithTimeout 15_000_000 handle
            case result of
                Left timeout -> expectationFailure timeout
                Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)
                Right (Right ()) -> pure ()
            ds <- reverse <$> readIORef delivered
            ds `shouldBe` map GlobalPosition [1 .. 5]
            cp <- readCheckpoint store "pause-resume-test"
            cp `shouldBe` Just 5
            evts <- readIORef evtRef
            let isPaused (KirokuEventSubscriptionPaused (SubscriptionName "pause-resume-test") _ _) = True
                isPaused _ = False
                isResumed (KirokuEventSubscriptionResumed (SubscriptionName "pause-resume-test") _ _) = True
                isResumed _ = False
            any isPaused evts `shouldBe` True
            any isResumed evts `shouldBe` True

    it "still fails fast under DropSubscription" $ do
        withTestStoreSettings Prelude.id $ \store -> do
            firstSeen <- newEmptyMVar
            release <- newEmptyMVar
            seenCount <- newTVarIO (0 :: Int)
            let handler' _evt = do
                    n <- atomically $ do
                        c <- readTVar seenCount
                        writeTVar seenCount (c + 1)
                        pure (c + 1)
                    if n == 1
                        then putMVar firstSeen () >> takeMVar release
                        else pure ()
                    pure Continue
            let cfg =
                    SubscriptionConfig
                        { name = SubscriptionName "dropsub-overflow-test"
                        , target = AllStreams
                        , handler = handler'
                        , batchSize = 100
                        , queueCapacity = 1
                        , overflowPolicy = DropSubscription
                        , consumerGroup = Nothing
                        , consumerGroupGuard = False
                        , missingCheckpointPolicy = FromBeginning
                        , retryPolicy = defaultRetryPolicy
                        , eventTypeFilter = AllEventTypes
                        , selector = Nothing
                        }
            handle <- subscribe store cfg
            Right _ <- runStoreIO store $ appendToStream (StreamName "ds-1") NoStream [makeEvent "E1" (Aeson.object [])]
            takeMVar firstSeen
            let appendOne i = do
                    let sn = StreamName ("ds-" <> T.pack (show (i :: Int)))
                    Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]
                    waitForPublisher store (GlobalPosition (fromIntegral i))
            appendOne 2
            appendOne 3
            appendOne 4
            appendOne 5
            putMVar release ()
            result <- waitWithTimeout 15_000_000 handle
            case result of
                Left timeout -> expectationFailure timeout
                Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got a clean stop"
                Right (Left e) ->
                    case Control.Exception.fromException e of
                        Just (SubscriptionOverflowed sn) -> sn `shouldBe` SubscriptionName "dropsub-overflow-test"
                        Nothing -> expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)