shibuya-kafka-adapter-0.8.0.1: test/Shibuya/Adapter/Kafka/AckHandleTest.hs
module Shibuya.Adapter.Kafka.AckHandleTest (tests) where
import Data.ByteString (ByteString)
import Data.IORef (IORef, atomicModifyIORef', atomicWriteIORef, newIORef, readIORef)
import Data.Int (Int64)
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
import Kafka.Consumer (RdKafkaRespErrT (..))
import Kafka.Consumer.Types (ConsumerRecord (..), Offset (..), PartitionOffset (..), Timestamp (..), TopicPartition (..))
import Kafka.Effectful.Consumer.Effect (KafkaConsumer (..))
import Kafka.Types (BatchSize (..), KafkaError (..), PartitionId (..), Timeout (..), TopicName (..))
import Shibuya.Adapter.Kafka.Config (KafkaAdapterConfig (..))
import Shibuya.Adapter.Kafka.Internal (KafkaAdapterState (..), ingestedStream, kafkaSource, mkAckHandle, newKafkaAdapterState)
import Shibuya.Core.Ack (AckDecision (..), HaltReason (..), RetryDelay (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream qualified as Stream
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)
data MockState = MockState
{ storeAttempts :: !Int
, pauseAttempts :: !Int
, seekCalls :: ![TopicPartition]
, storeFailuresRemaining :: !Int
, pauseFailuresRemaining :: !Int
, seekFailuresRemaining :: !Int
, storeError :: !KafkaError
, pauseError :: !KafkaError
, seekError :: !KafkaError
}
tests :: TestTree
tests =
testGroup
"AckHandle"
[ testCase "transient store failures retry and then succeed" testTransientStoreRetry
, testCase "persistent transient store failure records fatal slot without throwing" testPersistentStoreFailure
, testCase "fatal store failure records fatal slot after one attempt" testFatalStoreFailure
, testCase "AckHalt pause failure does not throw and records fatal slot" testAckHaltPauseFailure
, testCase "AckRetry seeks exact failed offset and does not store" testAckRetrySeeks
, testCase "seek barrier prevents stale successor store" testBarrierSkipsSuccessorStore
, testCase "source observes fatal slot before polling" testSourceObservesFatalSlot
]
testTransientStoreRetry :: IO ()
testTransientStoreRetry = do
mock <- newIORef defaultMockState{storeFailuresRemaining = 2}
state <- newKafkaAdapterState
result <- runFinalizer mock $ finalizeRecord state (recordAt 42) AckOk
assertRight result
final <- readIORef mock
fatal <- readIORef state.fatalError
assertEqual "store attempts" 3 final.storeAttempts
assertEqual "fatal slot" Nothing fatal
testPersistentStoreFailure :: IO ()
testPersistentStoreFailure = do
let err = KafkaResponseError RdKafkaRespErrTransport
mock <- newIORef defaultMockState{storeFailuresRemaining = 99, storeError = err}
state <- newKafkaAdapterState
result <- runFinalizer mock $ finalizeRecord state (recordAt 42) AckOk
assertRight result
final <- readIORef mock
fatal <- readIORef state.fatalError
assertEqual "store attempts" 3 final.storeAttempts
assertEqual "fatal slot" (Just err) fatal
testFatalStoreFailure :: IO ()
testFatalStoreFailure = do
let err = KafkaBadConfiguration
mock <- newIORef defaultMockState{storeFailuresRemaining = 99, storeError = err}
state <- newKafkaAdapterState
result <- runFinalizer mock $ finalizeRecord state (recordAt 42) AckOk
assertRight result
final <- readIORef mock
fatal <- readIORef state.fatalError
assertEqual "store attempts" 1 final.storeAttempts
assertEqual "fatal slot" (Just err) fatal
testAckHaltPauseFailure :: IO ()
testAckHaltPauseFailure = do
let err = KafkaResponseError RdKafkaRespErrTransport
mock <- newIORef defaultMockState{pauseFailuresRemaining = 99, pauseError = err}
state <- newKafkaAdapterState
result <- runFinalizer mock $ finalizeRecord state (recordAt 42) (AckHalt (HaltFatal "stop"))
assertRight result
final <- readIORef mock
fatal <- readIORef state.fatalError
assertEqual "pause attempts" 3 final.pauseAttempts
assertEqual "fatal slot" (Just err) fatal
testAckRetrySeeks :: IO ()
testAckRetrySeeks = do
mock <- newIORef defaultMockState
state <- newKafkaAdapterState
result <- runFinalizer mock $ finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0))
assertRight result
final <- readIORef mock
assertEqual "store attempts" 0 final.storeAttempts
assertEqual
"seek call"
[TopicPartition (TopicName "orders") (PartitionId 0) (PartitionOffset 42)]
final.seekCalls
testBarrierSkipsSuccessorStore :: IO ()
testBarrierSkipsSuccessorStore = do
mock <- newIORef defaultMockState
state <- newKafkaAdapterState
result <- runFinalizer mock $ do
finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0))
finalizeRecord state (recordAt 43) AckOk
finalizeRecord state (recordAt 42) AckOk
assertRight result
final <- readIORef mock
assertEqual "only retried message stored" 1 final.storeAttempts
testSourceObservesFatalSlot :: IO ()
testSourceObservesFatalSlot = do
let err = KafkaBadConfiguration
mock <- newIORef defaultMockState
state <- newKafkaAdapterState
atomicWriteIORef state.fatalError (Just err)
result <-
runEff . runErrorNoCallStack @KafkaError . runMockConsumer mock $
Stream.fold Fold.drain $
ingestedStream unreachableBuilder (kafkaSource state testConfig)
assertEqual "source error" (Left err) result
runFinalizer ::
IORef MockState ->
Eff '[KafkaConsumer, Error KafkaError, IOE] a ->
IO (Either KafkaError a)
runFinalizer mock action =
runEff . runErrorNoCallStack @KafkaError . runMockConsumer mock $
action
runMockConsumer ::
(IOE :> es, Error KafkaError :> es) =>
IORef MockState ->
Eff (KafkaConsumer : es) a ->
Eff es a
runMockConsumer mock =
interpret $ \_env -> \case
StoreOffsetMessage _ -> attemptStore mock
PausePartitions _ -> attemptPause mock
SeekPartitions tps _ -> recordSeek mock tps
PollMessage _ -> error "AckHandleTest: PollMessage not exercised"
PollMessageBatch _ _ -> error "AckHandleTest: PollMessageBatch not exercised"
CommitOffsetMessage _ _ -> error "AckHandleTest: CommitOffsetMessage not exercised"
CommitAllOffsets _ -> error "AckHandleTest: CommitAllOffsets not exercised"
CommitPartitionsOffsets _ _ -> error "AckHandleTest: CommitPartitionsOffsets not exercised"
StoreOffsets _ -> error "AckHandleTest: StoreOffsets not exercised"
Assign _ -> error "AckHandleTest: Assign not exercised"
ResumePartitions _ -> error "AckHandleTest: ResumePartitions not exercised"
Committed _ _ -> error "AckHandleTest: Committed not exercised"
Position _ -> error "AckHandleTest: Position not exercised"
Assignment -> error "AckHandleTest: Assignment not exercised"
Subscription -> error "AckHandleTest: Subscription not exercised"
AskConsumerHandle -> error "AckHandleTest: AskConsumerHandle not exercised"
unreachableBuilder ::
ConsumerRecord (Maybe ByteString) (Maybe ByteString) ->
Ingested es (Maybe ByteString)
unreachableBuilder _ = error "AckHandleTest: source should not yield records"
attemptStore :: (IOE :> es, Error KafkaError :> es) => IORef MockState -> Eff es ()
attemptStore mock = do
mbErr <-
liftIO $
atomicModifyIORef' mock $ \s ->
let remaining = s.storeFailuresRemaining
s' = s{storeAttempts = s.storeAttempts + 1, storeFailuresRemaining = max 0 (remaining - 1)}
in (s', if remaining > 0 then Just s.storeError else Nothing)
maybe (pure ()) throwError mbErr
attemptPause :: (IOE :> es, Error KafkaError :> es) => IORef MockState -> Eff es ()
attemptPause mock = do
mbErr <-
liftIO $
atomicModifyIORef' mock $ \s ->
let remaining = s.pauseFailuresRemaining
s' = s{pauseAttempts = s.pauseAttempts + 1, pauseFailuresRemaining = max 0 (remaining - 1)}
in (s', if remaining > 0 then Just s.pauseError else Nothing)
maybe (pure ()) throwError mbErr
recordSeek :: (IOE :> es, Error KafkaError :> es) => IORef MockState -> [TopicPartition] -> Eff es ()
recordSeek mock tps = do
mbErr <-
liftIO $
atomicModifyIORef' mock $ \s ->
let remaining = s.seekFailuresRemaining
s' = s{seekCalls = s.seekCalls <> tps, seekFailuresRemaining = max 0 (remaining - 1)}
in (s', if remaining > 0 then Just s.seekError else Nothing)
maybe (pure ()) throwError mbErr
finalizeRecord ::
(KafkaConsumer :> es, Error KafkaError :> es, IOE :> es) =>
KafkaAdapterState ->
ConsumerRecord (Maybe ByteString) (Maybe ByteString) ->
AckDecision ->
Eff es ()
finalizeRecord state cr decision =
let AckHandle finalize = mkAckHandle state testConfig cr
in finalize decision
recordAt :: Int64 -> ConsumerRecord (Maybe ByteString) (Maybe ByteString)
recordAt offset =
ConsumerRecord
{ crTopic = TopicName "orders"
, crPartition = PartitionId 0
, crOffset = Offset offset
, crTimestamp = NoTimestamp
, crHeaders = mempty
, crKey = Nothing
, crValue = Just "payload"
}
testConfig :: KafkaAdapterConfig
testConfig =
KafkaAdapterConfig
{ topics = [TopicName "orders"]
, pollTimeout = Timeout 100
, batchSize = BatchSize 100
}
defaultMockState :: MockState
defaultMockState =
MockState
{ storeAttempts = 0
, pauseAttempts = 0
, seekCalls = []
, storeFailuresRemaining = 0
, pauseFailuresRemaining = 0
, seekFailuresRemaining = 0
, storeError = KafkaResponseError RdKafkaRespErrTransport
, pauseError = KafkaResponseError RdKafkaRespErrTransport
, seekError = KafkaResponseError RdKafkaRespErrTransport
}
assertRight :: (Show e) => Either e a -> IO ()
assertRight = \case
Left err -> assertFailure $ "expected Right, got Left: " <> show err
Right _ -> pure ()