packages feed

shibuya-kafka-adapter-0.12.0.0: test/Shibuya/Adapter/Kafka/SourceGateTest.hs

module Shibuya.Adapter.Kafka.SourceGateTest (tests, afterYields, await, waitUntil) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (atomically, writeTVar)
import Control.Exception qualified as IOExc
import Control.Monad (unless)
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import Data.Int (Int64)
import Data.List.NonEmpty qualified as NE
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Effectful.Error.Static (runErrorNoCallStack)
import Effectful.Exception qualified as Exception
import Kafka.Consumer.Types (ConsumerRecord (..), Offset (..), RebalanceEvent (..))
import Kafka.ScriptedLog
import Kafka.Types (BatchSize (..), KafkaError, PartitionId (..), Timeout (..))
import Shibuya.Adapter (Adapter (..))
import Shibuya.Adapter.Kafka (KafkaAdapterConfig (..), kafkaAdapter, kafkaRebalanceHandler)
import Shibuya.Adapter.Kafka.AckHandleTest hiding (tests)
import Shibuya.Adapter.Kafka.Internal (KafkaAdapterState (..), Polled (..), gateRecords, kafkaSource, mkAckHandle, newKafkaAdapterState)
import Shibuya.App (ProcessorId (..), defaultAppConfig, defaultShutdownConfig, mkBatchProcessor, mkProcessor, runApp, stopApp, stopAppGracefully)
import Shibuya.Batch (BatchConfig (..), BatchInfo (..), BatchTrigger (..), ackAllOk, ackExcept, defaultBatchConfig)
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Message (..))
import Shibuya.Core.Types (Cursor (..), Envelope (..))
import Shibuya.Telemetry.Effect (runTracingNoop)
import Streamly.Data.Fold qualified as Fold
import Streamly.Data.Stream (Stream)
import Streamly.Data.Stream qualified as Stream
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)

tests :: TestTree
tests =
  testGroup
    "Source gate"
    [ testCase "a retried record blocks its successors (scripted log)" (scriptedRetry 1),
      testCase "a twice-retried record blocks its successors (scripted log)" (scriptedRetry 2),
      testCase "pre-seek successors are dropped" testStale,
      testCase "replay holds successor until success" (testHold "ok"),
      testCase "retry drops held successor" (testHold "retry"),
      testCase "shutdown wakes held successor" (testHold "shutdown"),
      testCase "halt wakes held successor" (testHold "halt"),
      testCase "revocation wakes held successor" (testHold "revoke"),
      testCase "dead letter releases held successor" (testHold "dead-letter"),
      testCase "cancellation releases held gate worker" (testHold "cancel"),
      testCase "fatal acknowledgement wakes held successor" (testHold "fatal"),
      testCase "missing replay fences old callbacks" testGap,
      testCase "seek affects only its own partition" testPartitionIsolation,
      testCase "requested incomplete seek makes records stale" testPendingSeek,
      testCase "batch timeout releases replay and stores all successors" testBatchLiveness
    ]

-- Signal on the pull after the nth yield, before pulling upstream again.
afterYields :: (IOE :> es) => Int -> MVar () -> Stream (Eff es) a -> Stream (Eff es) a
afterYields count ready = Stream.unfoldrM step . (0,)
  where
    step (n, stream) = do
      if n == count then liftIO (putMVar ready ()) else pure ()
      next <- Stream.uncons stream
      pure $ case next of
        Nothing -> Nothing
        Just (value, rest) -> Just (value, (n + 1, rest))

await :: String -> MVar a -> IO a
await label var = timeout 5000000 (takeMVar var) >>= maybe (assertFailure (label <> " timed out") >> error label) pure

waitUntil :: String -> IO Bool -> IO ()
waitUntil label check = do
  result <- timeout 10000000 loop
  unless (result == Just ()) $ assertFailure (label <> " timed out")
  where
    loop = check >>= \done -> unless done (threadDelay 1000 >> loop)

scriptedRetry :: Int -> IO ()
scriptedRetry retries = do
  scripted <- newScriptedLog 12
  deliveries <- newIORef ([] :: [Int64])
  attempts <- newIORef (0 :: Int)
  queued <- newEmptyMVar
  outcome <- timeout 30000000 $
    runEff . runErrorNoCallStack @KafkaError . runScriptedConsumer scripted . runTracingNoop $ do
      upstream <- kafkaAdapter KafkaAdapterConfig {topics = [scriptedTopic], pollTimeout = Timeout 5, batchSize = BatchSize 100}
      let adapter = upstream {source = afterYields 12 queued upstream.source}
          handler Message {envelope} = do
            let offset = case envelope.cursor of
                  Just (CursorInt value) -> fromIntegral value
                  _ -> -1
            liftIO $ atomicModifyIORef' deliveries (\xs -> (xs <> [offset], ()))
            if offset == 4
              then do
                attempt <- liftIO $ atomicModifyIORef' attempts (\n -> (n + 1, n))
                if attempt == 0 then liftIO (await "initial inbox" queued) else pure ()
                pure $ if attempt < retries then AckRetry (RetryDelay 0) else AckOk
              else pure AckOk
      app <- runApp defaultAppConfig [(ProcessorId "scripted-retry", mkProcessor adapter handler)]
      case app of
        Left err -> liftIO $ assertFailure (show err)
        Right handle ->
          Exception.finally
            ( do
                liftIO $
                  waitUntil "all offsets stored" ((== [0 .. 11]) <$> readIORef scripted.stored)
                    `IOExc.onException` ( do
                                            stores <- readIORef scripted.stored
                                            trace <- readIORef deliveries
                                            putStrLn ("stored=" <> show stores <> " deliveries=" <> show trace)
                                        )
                drained <- stopAppGracefully defaultShutdownConfig handle
                liftIO $ assertEqual "successful drain" True drained
            )
            (stopApp handle)
  observed <- readIORef deliveries
  stores <- readIORef scripted.stored
  seeks <- readIORef scripted.seeks
  assertEqual ("completion; deliveries=" <> show observed <> "; stored=" <> show stores) (Just (Right ())) outcome
  assertEqual "handler order" ([0 .. 4] <> replicate (retries - 1) 4 <> [4 .. 11]) observed
  assertEqual "stored offsets" [0 .. 11] stores
  assertEqual "seeks" (replicate retries 4) seeks

right :: (Show e) => Either e a -> IO a
right = either (\err -> assertFailure (show err) >> error "unexpected Left") pure

gateOffsets :: KafkaAdapterState -> [Polled] -> IO [Int64]
gateOffsets state values = runEff $ Stream.fold Fold.toList $ fmap (either (const (-1)) (unOffset . (.crOffset))) $ gateRecords state $ Stream.fromList (map Right values)

testStale :: IO ()
testStale = do
  state <- newKafkaAdapterState
  mock <- newIORef defaultMockState
  runFinalizer mock (finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0))) >>= assertRight
  gateOffsets state [Polled 0 (recordAt 43), Polled 0 (recordAt 44)] >>= assertEqual "stale records" []

testHold :: String -> IO ()
testHold mode = do
  state <- newKafkaAdapterState
  mock <- newIORef defaultMockState
  runFinalizer mock (finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0))) >>= assertRight
  emitted <- newEmptyMVar
  offered <- newEmptyMVar
  successor <- newEmptyMVar
  let input =
        Stream.mapM
          ( \p -> do
              if p.record.crOffset == Offset 43 then liftIO (putMVar offered ()) else pure ()
              pure (Right p)
          )
          $ Stream.fromList [Polled 1 (recordAt 42), Polled 1 (recordAt 43)]
      worker =
        runEff
          $ Stream.fold Fold.drain
          $ Stream.mapM
            ( \case
                Right cr | cr.crOffset == Offset 42 -> liftIO $ do
                  ack <- runFinalizer mock (mkAckHandle state testConfig cr) >>= right
                  putMVar emitted ack
                Right _ -> liftIO (putMVar successor ())
                Left _ -> error "unexpected error"
            )
          $ gateRecords state input
  Async.withAsync worker $ \async -> do
    AckHandle finalize <- await "replay emission" emitted
    await "successor offered" offered
    timeout 200000 (takeMVar successor) >>= assertEqual "held successor" Nothing
    case mode of
      "shutdown" -> atomically $ writeTVar state.shutdownVar True
      "revoke" -> kafkaRebalanceHandler state (error "unused consumer") (RebalanceRevoke [(scriptedTopic, PartitionId 0)])
      "cancel" -> Async.cancel async
      "fatal" -> do
        atomicModifyIORef' mock (\m -> (m {storeFailuresRemaining = 99}, ()))
        failed <- IOExc.try @IOExc.SomeException $ runFinalizer mock (finalizeRecord state (recordAt 1) {crPartition = PartitionId 1} AckOk)
        case failed of
          Left _ -> pure ()
          Right _ -> assertFailure "expected exhausted acknowledgement"
      _ ->
        runFinalizer
          mock
          ( finalize
              ( case mode of
                  "retry" -> AckRetry (RetryDelay 0)
                  "halt" -> AckHalt (HaltFatal "stop")
                  "dead-letter" -> AckDeadLetter MaxRetriesExceeded
                  _ -> AckOk
              )
          )
          >>= assertRight
    if mode == "cancel"
      then do
        timeout 1000000 (runFinalizer mock (finalize AckOk)) >>= maybe (assertFailure "lock leaked") assertRight
      else do
        timeout 1000000 (Async.wait async) >>= assertEqual "gate worker exits" (Just ())
        if mode `elem` ["ok", "dead-letter"]
          then await "successor released" successor
          else timeout 10000 (takeMVar successor) >>= assertEqual "successor dropped" Nothing
    if mode == "fatal"
      then do
        result <- runFinalizer mock $ Stream.fold Fold.drain $ gateRecords state $ kafkaSource state testConfig
        case result of
          Left _ -> pure ()
          Right _ -> assertFailure "fatal slot must fail before polling"
      else pure ()

testGap :: IO ()
testGap = mapM_ check [AckOk, AckRetry (RetryDelay 0)]
  where
    check decision = do
      state <- newKafkaAdapterState
      mock <- newIORef defaultMockState
      old <- runFinalizer mock (mkAckHandle state testConfig (recordAt 46)) >>= right
      runFinalizer mock (finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0))) >>= assertRight
      gateOffsets state [Polled 1 (recordAt 45), Polled 1 (recordAt 46)] >>= assertEqual "gap resumes" [45, 46]
      let AckHandle finalize = old
      runFinalizer mock (finalize decision >> finalizeRecord state (recordAt 45) AckOk) >>= assertRight
      m <- readIORef mock
      assertEqual "only new delivery stores" [Offset 45] m.storedOffsets
      assertEqual "old callback cannot seek" 1 (length m.seekCalls)

testPartitionIsolation :: IO ()
testPartitionIsolation = do
  state <- newKafkaAdapterState
  mock <- newIORef defaultMockState
  runFinalizer mock (finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0))) >>= assertRight
  gateOffsets state [Polled 0 (recordAt 43) {crPartition = PartitionId 1}] >>= assertEqual "other partition stays current" [43]
  kafkaRebalanceHandler state (error "unused") (RebalanceRevoke [(scriptedTopic, PartitionId 0)])
  gateOffsets state [Polled 1 (recordAt 42)] >>= assertEqual "revoked partition" []

testPendingSeek :: IO ()
testPendingSeek = do
  state <- newKafkaAdapterState
  started <- newEmptyMVar
  release <- newEmptyMVar
  mock <- newIORef defaultMockState {seekBlock = Just (started, release)}
  Async.withAsync (runFinalizer mock (finalizeRecord state (recordAt 42) (AckRetry (RetryDelay 0)))) $ \worker -> do
    await "seek entered" started
    gateOffsets state [Polled 0 (recordAt 42)] >>= assertEqual "pending seek drops record" []
    putMVar release ()
    Async.wait worker >>= assertRight
    gateOffsets state [Polled 1 (recordAt 42)] >>= assertEqual "completed seek admits replay" [42]

testBatchLiveness :: IO ()
testBatchLiveness = do
  scripted <- newScriptedLog 12
  first <- newIORef True
  batches <- newIORef []
  outcome <- timeout 10000000 $
    runEff . runErrorNoCallStack @KafkaError . runScriptedConsumer scripted . runTracingNoop $ do
      adapter <- kafkaAdapter KafkaAdapterConfig {topics = [scriptedTopic], pollTimeout = Timeout 5, batchSize = BatchSize 100}
      let config = defaultBatchConfig {batchSize = 12, batchTimeout = 0.05, tickInterval = Just 0.01}
          handler info messages = do
            isFirst <- liftIO $ atomicModifyIORef' first (\x -> (False, x))
            liftIO $ atomicModifyIORef' batches (\xs -> (xs <> [(info.trigger, info.size)], ()))
            pure $ if isFirst then ackExcept [((NE.head messages).envelope.messageId, AckRetry (RetryDelay 0))] else ackAllOk
      app <- runApp defaultAppConfig [(ProcessorId "batch-replay", mkBatchProcessor adapter handler config)]
      case app of
        Left err -> liftIO $ assertFailure (show err)
        Right handle ->
          Exception.finally
            ( do
                liftIO $ waitUntil "batch stores" ((== [0 .. 11]) <$> readIORef scripted.stored)
                drained <- stopAppGracefully defaultShutdownConfig handle
                liftIO $ assertEqual "drain" True drained
            )
            (stopApp handle)
  assertEqual "completion" (Just (Right ())) outcome
  observed <- readIORef batches
  unless ((TriggerTimeout, 1) `elem` observed) $ assertFailure ("missing replay timeout: " <> show observed)