kiroku-store-0.2.0.0: test/Test/SubscriptionRetryDeadLetter.hs
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{- | Regression tests for EP-40 (MasterPlan 6, child plan 2) — per-event retry
and dead-letter dispositions in @kiroku-store@.
These pin the three behavioral acceptance criteria from the ExecPlan:
* __dead-letter__: a handler returning 'DeadLetter' for one event records it in
@kiroku.dead_letters@, advances the checkpoint past it atomically, and keeps
delivering later events;
* __bounded retry__: a handler returning 'Retry' for one event sees that same
event redelivered (the checkpoint does not advance past it) until it
succeeds;
* __retry exhaustion__: once the configured 'retryMaxAttempts' is exceeded the
event is dead-lettered with 'DeadLetterMaxAttempts' and later events
continue.
All three use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery
path). Events are appended and the publisher is awaited before subscribing, so
delivery happens during catch-up.
-}
module Test.SubscriptionRetryDeadLetter (spec) where
import Control.Lens ((^.))
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef)
import Data.Int (Int32, Int64)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
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, withTestStore)
import Test.Hspec
posOf :: RecordedEvent -> Int64
posOf e = case e ^. #globalPosition of GlobalPosition p -> p
readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64)
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 mPos
readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord]
readDeadLetters store subName = do
result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)
case result of
Left err -> error ("readDeadLetters failed: " <> show err)
Right v -> pure (V.toList v)
-- Append three single-event streams so global positions are 1, 2, 3.
appendThree :: KirokuStore -> IO ()
appendThree store =
mapM_
( \i -> do
let sn = StreamName ("rdl-" <> T.pack (show (i :: Int)))
Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "E" (Aeson.object [])]
pure ()
)
[1 .. 3]
spec :: Spec
spec = describe "subscription dispositions — retry / dead-letter (EP-40)" $ do
it "dead-letters one event, advances the checkpoint, and keeps delivering" $ do
withTestStore $ \store -> do
appendThree store
waitForPublisher store (GlobalPosition 3)
delivered <- newIORef ([] :: [Int64])
let subName = SubscriptionName "rdl-deadletter"
handler' evt = do
let p = posOf evt
modifyIORef' delivered (p :)
case p of
2 -> pure (DeadLetter (DeadLetterPoison "boom"))
3 -> pure Stop
_ -> pure Continue
cfg = (defaultSubscriptionConfig subName AllStreams handler'){batchSize = 100}
handle <- subscribe store cfg
result <- waitWithTimeout 20_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
-- Each event delivered once; event 2 was dead-lettered (not retried).
ds `shouldBe` [1, 2, 3]
dls <- readDeadLetters store "rdl-deadletter"
map SQL.deadLetterGlobalPosition dls `shouldBe` [2]
-- Checkpoint advanced to the last processed event (the Stop at 3).
cp <- readCheckpoint store "rdl-deadletter"
cp `shouldBe` Just 3
it "retries one event until it succeeds without advancing past it" $ do
withTestStore $ \store -> do
appendThree store
waitForPublisher store (GlobalPosition 3)
delivered <- newIORef ([] :: [Int64])
pos2Count <- newIORef (0 :: Int)
let subName = SubscriptionName "rdl-retry"
handler' evt = do
let p = posOf evt
modifyIORef' delivered (p :)
case p of
2 -> do
c <- atomicModifyIORef' pos2Count (\x -> (x + 1, x + 1))
-- Retry the first two deliveries, succeed on the third.
if c <= 2 then pure (Retry (RetryDelay 0)) else pure Continue
3 -> pure Stop
_ -> pure Continue
cfg = (defaultSubscriptionConfig subName AllStreams handler'){batchSize = 100}
handle <- subscribe store cfg
result <- waitWithTimeout 20_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
-- Event 2 delivered three times (two retries + success); 1 and 3 once each.
length (filter (== 2) ds) `shouldBe` 3
ds `shouldBe` [1, 2, 2, 2, 3]
-- No dead letters; the retry succeeded.
dls <- readDeadLetters store "rdl-retry"
dls `shouldSatisfy` null
cp <- readCheckpoint store "rdl-retry"
cp `shouldBe` Just 3
it "dead-letters after exhausting the retry budget and continues" $ do
withTestStore $ \store -> do
appendThree store
waitForPublisher store (GlobalPosition 3)
delivered <- newIORef ([] :: [Int64])
let subName = SubscriptionName "rdl-exhaust"
handler' evt = do
let p = posOf evt
modifyIORef' delivered (p :)
case p of
2 -> pure (Retry (RetryDelay 0)) -- always retry → exhausts the budget
3 -> pure Stop
_ -> pure Continue
cfg =
(defaultSubscriptionConfig subName AllStreams handler')
{ batchSize = 100
, retryPolicy = RetryPolicy{retryMaxAttempts = 3}
}
handle <- subscribe store cfg
result <- waitWithTimeout 20_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
-- Event 2 delivered exactly retryMaxAttempts (3) times, then dead-lettered.
length (filter (== 2) ds) `shouldBe` 3
ds `shouldBe` [1, 2, 2, 2, 3]
dls <- readDeadLetters store "rdl-exhaust"
map SQL.deadLetterGlobalPosition dls `shouldBe` [2]
map SQL.deadLetterAttemptCount dls `shouldBe` [3]
-- Later events still processed; checkpoint advanced to the end.
cp <- readCheckpoint store "rdl-exhaust"
cp `shouldBe` Just 3