packages feed

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

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

{- | Regression test for ExecPlan 41 M3 — worker-level live reconnect.

Before M3, a live database fetch error in the Category / consumer-group live
loops was retried in place, forever and invisibly. M3 lifts recovery into the
FSM: a live-mode 'Hasql.Pool.UsageError' drives the worker into the
'Kiroku.Store.Subscription.Fsm.Reconnecting' state — it emits
'Kiroku.Store.Observability.KirokuEventSubscriptionReconnecting', backs off, and
re-catches-up from its checkpoint — instead of silently spinning. The
subscription survives the fault and resumes from where it left off.

This spec uses a __Category__ subscription, not AllStreams: AllStreams live
delivery is fed by the shared publisher (which owns its own reconnect), so the
AllStreams worker performs no live-mode fetch and has nothing to reconnect. The
Category worker fetches in live mode, so 'withFetchBatchHookForTest' can inject
failures there. The hook fails the first @K@ fetches that occur __after__ the
subscription has caught up (a 'TVar' flag the @eventHandler@ sets on
'KirokuEventSubscriptionCaughtUp'), then falls through to the real database.

Asserted: at least one 'KirokuEventSubscriptionReconnecting' fired; @wait@ did
not surface the injected error (the worker recovered rather than died); every
appended event was delivered in increasing position; the checkpoint advanced to
the last position.
-}
module Test.SubscriptionReconnect (spec) where

import Control.Concurrent.STM (atomically, check, newTVarIO, readTVar, readTVarIO, 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.Types qualified as SubTypes
import Kiroku.Store.Subscription.Worker (withFetchBatchHookForTest)
import Test.Helpers (makeEvent, waitWithTimeout, withTestStoreSettings)
import Test.Hspec

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 — worker-level live reconnect (EP-41 M3)" $ do
    it "reconnects and resumes after transient live-mode database errors" $ do
        evtRef <- newIORef ([] :: [KirokuEvent])
        deliveredRef <- newIORef ([] :: [GlobalPosition])
        caughtUpFlag <- newTVarIO False
        failsLeft <- newTVarIO (2 :: Int)
        seenCount <- newTVarIO (0 :: Int)

        let subName = SubscriptionName "reconnect-test"
            observe evt = do
                modifyIORef' evtRef (evt :)
                case evt of
                    KirokuEventSubscriptionCaughtUp n _ _
                        | n == subName -> atomically (writeTVar caughtUpFlag True)
                    _ -> pure ()
            -- Fail the first K fetches that happen after catch-up, then fall
            -- through to the real database. Catch-up fetches (flag still False)
            -- are never failed, so the fault is purely a live-mode event.
            injectHook config _cursor
                | SubTypes.name config == subName = do
                    cu <- readTVarIO caughtUpFlag
                    if not cu
                        then pure Nothing
                        else atomically $ do
                            n <- readTVar failsLeft
                            if n > 0
                                then do
                                    writeTVar failsLeft (n - 1)
                                    pure (Just (Left Pool.AcquisitionTimeoutUsageError))
                                else pure Nothing
                | otherwise = pure Nothing
            handler' evt = do
                modifyIORef' deliveredRef ((evt ^. #globalPosition) :)
                n <- atomically $ do
                    c <- readTVar seenCount
                    writeTVar seenCount (c + 1)
                    pure (c + 1)
                if n >= 3 then pure Stop else pure Continue
            cfg =
                SubscriptionConfig
                    { name = subName
                    , target = Category (CategoryName "rc")
                    , handler = handler'
                    , batchSize = 100
                    , queueCapacity = 16
                    , overflowPolicy = PauseAndResume
                    , consumerGroup = Nothing
                    , consumerGroupGuard = False
                    , retryPolicy = defaultRetryPolicy
                    , eventTypeFilter = AllEventTypes
                    , selector = Nothing
                    }

        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do
            withFetchBatchHookForTest injectHook $ do
                -- Empty store: the worker catches up instantly (no fetch), sets the
                -- flag, then its first live-mode fetch is the one the hook fails —
                -- so the fault lands squarely in live mode.
                handle <- subscribe store cfg
                -- Wait until catch-up has completed and the reconnect failures have
                -- been consumed, so the appends below are served by the real DB.
                atomically $ do
                    cu <- readTVar caughtUpFlag
                    check cu
                    n <- readTVar failsLeft
                    check (n == 0)
                Right _ <- runStoreIO store $ appendToStream (StreamName "rc-1") NoStream [makeEvent "A" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "rc-2") NoStream [makeEvent "B" (Aeson.object [])]
                Right _ <- runStoreIO store $ appendToStream (StreamName "rc-3") NoStream [makeEvent "C" (Aeson.object [])]
                result <- waitWithTimeout 20_000_000 handle
                case result of
                    Left timeout -> expectationFailure timeout
                    Right (Left e) -> expectationFailure ("worker did not survive the fault: " <> show e)
                    Right (Right ()) -> pure ()

            delivered <- reverse <$> readIORef deliveredRef
            delivered `shouldBe` map GlobalPosition [1 .. 3]
            cp <- readCheckpoint store "reconnect-test"
            cp `shouldBe` Just 3
            evts <- readIORef evtRef
            let isReconnecting (KirokuEventSubscriptionReconnecting n _ _) = n == subName
                isReconnecting _ = False
            any isReconnecting evts `shouldBe` True