packages feed

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

{- | Failure-injection tests for kiroku-store.

Each test deliberately disrupts a runtime dependency (the LISTEN
connection, the connection pool) and asserts the system recovers per its
documented contract.

Currently covers:

  * F14: Listener killed during a window where an event is appended.
    The event is still delivered after the listener reconnects, because
    the publisher re-queries the database from its last position when
    the next NOTIFY arrives — no safety-poll wait needed.

F15 (pool exhaustion → 'PoolAcquisitionTimeout') is deferred until the
'acquisitionTimeout' field is exposed in 'ConnectionSettings'. The
mapping itself ('Kiroku.Store.Error.mapUsageError'
@AcquisitionTimeoutUsageError -> PoolAcquisitionTimeout@) is
straightforward; without a way to set a sub-second acquisition timeout
from the test, the test would either hang (default unbounded timeout)
or require modifying the production type. See the EP-6 plan's M2 notes.
-}
module Test.FailureInjection (spec) where

import Control.Concurrent.STM (atomically, newTVarIO, readTVar, retry, writeTVar)
import Control.Lens ((&), (.~), (^.))
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef)
import Kiroku.Store
import Test.Helpers (
    makeEvent,
    terminateBackend,
    waitForListenerPid,
    waitForListenerPidNotEqual,
    waitWithTimeout,
    withTestStoreSettings,
 )
import Test.Hspec

spec :: Spec
spec = describe "kiroku-store failure injection" $ do
    -- F14 — Listener killed during a window where an event is
    -- appended. The publisher's broadcast loop pulls events directly
    -- from PostgreSQL when the next NOTIFY arrives, so the
    -- down-window event is delivered as soon as a post-reconnect
    -- event triggers a tick. No 30-second safety-poll wait required.
    it "delivers events appended during listener-down window after reconnect (F14)" $ \() -> do
        ref <- newIORef ([] :: [RecordedEvent])
        countVar <- newTVarIO (0 :: Int)
        let handler' evt = do
                modifyIORef' ref (evt :)
                n <- atomically $ do
                    c <- readTVar countVar
                    let c' = c + 1
                    writeTVar countVar c'
                    pure c'
                if n >= 3 then pure Stop else pure Continue
        let cfg =
                SubscriptionConfig
                    { name = SubscriptionName "f14-down-window"
                    , target = AllStreams
                    , handler = handler'
                    , batchSize = 100
                    , queueCapacity = 16
                    , overflowPolicy = DropSubscription
                    , consumerGroup = Nothing
                    , consumerGroupGuard = False
                    , retryPolicy = defaultRetryPolicy
                    , eventTypeFilter = AllEventTypes
                    , selector = Nothing
                    }
        withTestStoreSettings (& #eventHandler .~ Nothing) $ \store -> do
            handle <- subscribe store cfg
            -- Pre-event: confirm the subscription is alive and the
            -- listener has reached pg_stat_activity.
            Right _ <- runStoreIO store $ appendToStream (StreamName "f14-pre") NoStream [makeEvent "Pre" (Aeson.object [])]
            atomically $ do
                c <- readTVar countVar
                if c >= 1 then pure () else retry
            -- Find listener pid, terminate it. The listener loop
            -- will reconnect with a fresh pid.
            --
            -- NOTE: pg_terminate_backend is the "administrator command"
            -- that makes PostgreSQL report
            --   FATAL: terminating connection due to administrator command
            -- on the targeted backend. The kiroku-listener's libpq socket
            -- (inside hasql-notifications' waitForNotifications) receives
            -- that as it dies, so that FATAL line on stderr during this
            -- test is EXPECTED — it is the fault we are injecting, not a
            -- connection leak or a teardown-ordering bug. (log_min_messages
            -- is already PANIC, so this is the client seeing a server-
            -- initiated termination, not server-side logging.)
            pid1 <- waitForListenerPid (store ^. #pool) 5_000_000
            terminateBackend (store ^. #pool) pid1
            -- Append the during-down-window event. No NOTIFY reaches
            -- the publisher right now because the listener is
            -- disconnected.
            Right _ <- runStoreIO store $ appendToStream (StreamName "f14-during") NoStream [makeEvent "During" (Aeson.object [])]
            -- Wait for the listener to come back with a fresh pid.
            _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000
            -- Append the post-reconnect event. The new NOTIFY
            -- triggers the publisher to fetch from its last position
            -- — which picks up both the during-down event AND this
            -- one.
            Right _ <- runStoreIO store $ appendToStream (StreamName "f14-after") NoStream [makeEvent "After" (Aeson.object [])]
            result <- waitWithTimeout 30_000_000 handle
            case result of
                Left timeout -> expectationFailure timeout
                Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)
                Right (Right ()) -> pure ()
            collected <- readIORef ref
            length collected `shouldBe` 3