kiroku-store-0.12.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 = defaultBatchSize,
queueCapacity = 16,
overflowPolicy = DropSubscription,
consumerGroup = Nothing,
consumerGroupGuard = False,
missingCheckpointPolicy = FromBeginning,
targetBindingPolicy = AdoptUnbound,
undecodableHandler = Nothing,
handlerStallWarnAfter = Nothing,
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