packages feed

shibuya-kafka-adapter-0.8.0.1: test/Shibuya/Adapter/Kafka/IntegrationTest.hs

module Shibuya.Adapter.Kafka.IntegrationTest (tests) where

import Control.Exception (throwIO)
import Control.Monad (forM)
import Control.Monad.IO.Class (liftIO)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.List (nub, sort)
import Data.Maybe (mapMaybe)
import Data.Text qualified as Text
import Effectful (runEff)
import Effectful.Error.Static (runError)
import Kafka.Consumer.Types (OffsetCommit (..), OffsetReset (..))
import Kafka.Effectful.Consumer (
    brokersList,
    groupId,
    noAutoOffsetStore,
    offsetReset,
    runKafkaConsumer,
    topics,
 )
import Kafka.Effectful.Consumer.Effect (commitAllOffsets, pollMessageBatch)
import Kafka.TestEnv (
    TestEnv (..),
    consumeN,
    createTopic,
    createTopicWithPartitions,
    produceKeyedMessages,
    produceMessages,
    withTestEnv,
 )
import Kafka.Types (
    BatchSize (..),
    KafkaError,
    Timeout (..),
    TopicName (..),
 )
import Shibuya.Adapter (Adapter (..))
import Shibuya.Adapter.Kafka (KafkaAdapterConfig (..), kafkaAdapter)
import Shibuya.App (ProcessorId (..), defaultAppConfig, mkProcessor, runApp, waitApp)
import Shibuya.Core.Ack (AckDecision (..), RetryDelay (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..), Message (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Stream
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)

tests :: TestTree
tests =
    testGroup
        "Integration"
        [ testCase "Basic produce-consume" testBasicProduceConsume
        , testCase "Offset commit verification" testOffsetCommit
        , testCase "Multi-partition distribution" testMultiPartition
        , testCase "Batch polling" testBatchPolling
        , testCase "Graceful shutdown" testGracefulShutdown
        , testCase "Idle graceful shutdown completes promptly" testIdleGracefulShutdown
        , testCase "AckRetry redelivers within the same session" testAckRetryRedelivery
        , testCase "AckRetry is not committed past when session exits" testAckRetryAbandonedSession
        , testCase "Handler exception redelivers instead of skipping" testHandlerExceptionRedelivery
        ]

testBasicProduceConsume :: IO ()
testBasicProduceConsume = withTestEnv $ \env -> do
    createTopic env
    let payloads = ["msg-1", "msg-2", "msg-3", "msg-4", "msg-5"]
    produceMessages env payloads
    envelopes <- consumeN env 5 AckOk

    -- Verify all 5 messages received
    assertEqual "message count" 5 (length envelopes)

    -- Verify payloads
    let receivedPayloads = map (\(Envelope{payload}) -> payload) envelopes
    assertEqual "payloads" (map Just payloads) receivedPayloads

    -- Verify messageId format: topic-partition-offset
    case envelopes of
        (Envelope{messageId = MessageId firstIdText} : _) ->
            assertBool "messageId contains topic" (Text.isPrefixOf (unTopicName env.testTopic) firstIdText)
        [] -> error "unreachable: already verified 5 envelopes"

    -- Verify cursor is populated
    assertBool "cursor is Just" (all (\(Envelope{cursor}) -> case cursor of Just (CursorInt _) -> True; _ -> False) envelopes)

    -- Verify partition is populated
    assertBool "partition is Just" (all (\(Envelope{partition}) -> case partition of Just _ -> True; _ -> False) envelopes)

testOffsetCommit :: IO ()
testOffsetCommit = withTestEnv $ \env -> do
    createTopic env
    let payloads = ["oc-1", "oc-2", "oc-3"]
    produceMessages env payloads

    -- Consume all 3, AckOk each (stores offsets), then commit
    _ <- consumeN env 3 AckOk

    -- Create new consumer in same group - should get no messages
    result <- runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            -- Poll a few times to allow group join + rebalance, verify no re-delivery
            allResults <- forM [1 .. 3 :: Int] $ \_ -> do
                results <- pollMessageBatch (Timeout 3000) (BatchSize 100)
                pure [cr | Right cr <- results]
            let totalMessages = concat allResults
            liftIO $ assertEqual "no re-delivery" 0 (length totalMessages)
    case result of
        Left err -> error $ "Failed: " <> show err
        Right () -> pure ()

testMultiPartition :: IO ()
testMultiPartition = withTestEnv $ \env -> do
    createTopicWithPartitions env 3
    let pairs =
            [ ("key-a", "msg-a")
            , ("key-b", "msg-b")
            , ("key-c", "msg-c")
            , ("key-d", "msg-d")
            , ("key-e", "msg-e")
            , ("key-f", "msg-f")
            ]
    produceKeyedMessages env pairs
    envelopes <- consumeN env 6 AckOk

    assertEqual "message count" 6 (length envelopes)

    let partitions = mapMaybe (\(Envelope{partition}) -> partition) envelopes
    assertEqual "all have partition" 6 (length partitions)

    let uniquePartitions = nub partitions
    assertBool
        ("expected multiple partitions, got: " <> show uniquePartitions)
        (length uniquePartitions >= 2)

testBatchPolling :: IO ()
testBatchPolling = withTestEnv $ \env -> do
    createTopic env
    let payloads = map (\i -> BS8.pack ("batch-" <> show i)) [1 .. 20 :: Int]
    produceMessages env payloads
    envelopes <- consumeN env 20 AckOk

    assertEqual "message count" 20 (length envelopes)

    let receivedPayloads = sort $ mapMaybe (\(Envelope{payload}) -> payload) envelopes
    assertEqual "payloads" (sort payloads) receivedPayloads

testGracefulShutdown :: IO ()
testGracefulShutdown = withTestEnv $ \env -> do
    createTopic env
    let payloads = ["sd-1", "sd-2", "sd-3"]
    produceMessages env payloads

    ref <- newIORef ([] :: [Envelope (Maybe ByteString)])
    result <- runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            let config =
                    KafkaAdapterConfig
                        { topics = [env.testTopic]
                        , pollTimeout = Timeout 5000
                        , batchSize = BatchSize 100
                        }
            Adapter{source, shutdown} <- kafkaAdapter config
            Stream.fold Fold.drain
                $ Stream.mapM
                    ( \(Ingested{envelope, ack = AckHandle finalize}) -> do
                        liftIO $ modifyIORef' ref (envelope :)
                        finalize AckOk
                    )
                $ Stream.take 3 source
            shutdown
    case result of
        Left err -> error $ "Failed: " <> show err
        Right () -> pure ()
    envelopes <- reverse <$> readIORef ref
    assertEqual "consumed 3 before shutdown" 3 (length envelopes)

testIdleGracefulShutdown :: IO ()
testIdleGracefulShutdown = withTestEnv $ \env -> do
    createTopic env

    timedResult <- timeout 3000000 $ runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            let config =
                    KafkaAdapterConfig
                        { topics = [env.testTopic]
                        , pollTimeout = Timeout 250
                        , batchSize = BatchSize 100
                        }
            Adapter{source, shutdown} <- kafkaAdapter config
            shutdown
            Stream.fold Fold.drain source
    case timedResult of
        Nothing -> assertFailure "idle shutdown did not terminate promptly"
        Just (Left (_cs, err)) -> assertFailure $ "idle shutdown failed: " <> show err
        Just (Right ()) -> pure ()

testAckRetryRedelivery :: IO ()
testAckRetryRedelivery = withTestEnv $ \env -> do
    createTopic env
    let payloads = ["r-1", "r-2", "r-3"]
    produceMessages env payloads

    retried <- newIORef False
    seen <- newIORef ([] :: [ByteString])
    result <- runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            Adapter{source} <- kafkaAdapter (testConfig env)
            Stream.fold Fold.drain
                $ Stream.mapM
                    ( \(Ingested{envelope, ack = AckHandle finalize}) -> do
                        let payload = maybe "" id envelope.payload
                        liftIO $ modifyIORef' seen (payload :)
                        hasRetried <- liftIO $ readIORef retried
                        if payload == "r-2" && not hasRetried
                            then do
                                liftIO $ writeIORef retried True
                                finalize (AckRetry (RetryDelay 0))
                            else finalize AckOk
                    )
                $ Stream.take 4 source
            commitAllOffsets OffsetCommit
    case result of
        Left (_cs, err) -> assertFailure $ "AckRetry redelivery failed: " <> show err
        Right () -> pure ()

    delivered <- reverse <$> readIORef seen
    assertBool ("expected r-2 at least twice, saw " <> show delivered) (countPayload "r-2" delivered >= 2)
    assertBool ("expected r-3 after retry, saw " <> show delivered) ("r-3" `elem` delivered)

    noRedelivery <- runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            batches <- forM [1 .. 3 :: Int] $ \_ ->
                pollMessageBatch (Timeout 500) (BatchSize 100)
            liftIO $ assertEqual "no redelivery after final AckOk commit" 0 (length [cr | Right cr <- concat batches])
    case noRedelivery of
        Left (_cs, err) -> assertFailure $ "post-commit verification failed: " <> show err
        Right () -> pure ()

testAckRetryAbandonedSession :: IO ()
testAckRetryAbandonedSession = withTestEnv $ \env -> do
    createTopic env
    let payloads = ["ab-1", "ab-2", "ab-3"]
    produceMessages env payloads

    firstSession <- runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            Adapter{source} <- kafkaAdapter (testConfig env)
            Stream.fold Fold.drain
                $ Stream.mapM
                    ( \(Ingested{envelope, ack = AckHandle finalize}) -> do
                        case envelope.payload of
                            Just "ab-2" -> finalize (AckRetry (RetryDelay 0))
                            _ -> finalize AckOk
                    )
                $ Stream.take 2 source
    case firstSession of
        Left (_cs, err) -> assertFailure $ "first session failed: " <> show err
        Right () -> pure ()

    redelivered <- newIORef ([] :: [ByteString])
    secondSession <- runEff . runError @KafkaError $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            Adapter{source} <- kafkaAdapter (testConfig env)
            Stream.fold Fold.drain
                $ Stream.mapM
                    ( \(Ingested{envelope, ack = AckHandle finalize}) -> do
                        maybe (pure ()) (liftIO . modifyIORef' redelivered . (:)) envelope.payload
                        finalize AckOk
                    )
                $ Stream.take 2 source
            commitAllOffsets OffsetCommit
    case secondSession of
        Left (_cs, err) -> assertFailure $ "second session failed: " <> show err
        Right () -> pure ()

    delivered <- readIORef redelivered
    assertBool ("expected ab-2 redelivery, saw " <> show delivered) ("ab-2" `elem` delivered)

testHandlerExceptionRedelivery :: IO ()
testHandlerExceptionRedelivery = withTestEnv $ \env -> do
    createTopic env
    let payloads = ["n-1", "n-2", "n-3"]
    produceMessages env payloads

    thrown <- newIORef False
    seen <- newIORef ([] :: [ByteString])
    result <- runEff . runError @KafkaError . runTracingNoop $ do
        let props = brokersList [env.testBroker] <> groupId env.testGroupId <> noAutoOffsetStore
            sub = topics [env.testTopic] <> offsetReset Earliest
        runKafkaConsumer props sub $ do
            upstream <- kafkaAdapter (testConfig env)
            let finiteAdapter = upstream{source = Stream.take 4 upstream.source}
                handler Message{envelope} = do
                    let payload = maybe "" id envelope.payload
                    liftIO $ modifyIORef' seen (payload :)
                    hasThrown <- liftIO $ readIORef thrown
                    if payload == "n-2" && not hasThrown
                        then liftIO $ do
                            writeIORef thrown True
                            throwIO (userError "planned handler exception")
                        else pure AckOk
            appResult <- runApp defaultAppConfig [(ProcessorId "handler-exception", mkProcessor finiteAdapter handler)]
            case appResult of
                Left appErr -> liftIO $ assertFailure $ "runApp failed: " <> show appErr
                Right appHandle -> waitApp appHandle
            commitAllOffsets OffsetCommit
    case result of
        Left (_cs, err) -> assertFailure $ "handler exception scenario failed: " <> show err
        Right () -> pure ()

    delivered <- reverse <$> readIORef seen
    assertBool ("expected n-2 at least twice, saw " <> show delivered) (countPayload "n-2" delivered >= 2)
    assertBool ("expected n-3 after handler exception retry, saw " <> show delivered) ("n-3" `elem` delivered)

testConfig :: TestEnv -> KafkaAdapterConfig
testConfig env =
    KafkaAdapterConfig
        { topics = [env.testTopic]
        , pollTimeout = Timeout 500
        , batchSize = BatchSize 100
        }

countPayload :: ByteString -> [ByteString] -> Int
countPayload target = length . filter (== target)