packages feed

kiroku-store-0.12.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 = defaultBatchSize}
      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 = defaultBatchSize}
      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 = defaultBatchSize,
                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