packages feed

rdkafka-hs-1.1.0.0: test-integration/Kafka/Integration/DiagnosticContractTest.hs

module Kafka.Integration.DiagnosticContractTest (tests) where

import Control.Concurrent (forkFinally, forkIOWithUnmask, killThread, threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar)
import Control.Exception (AsyncException (ThreadKilled), IOException, SomeException, bracket, finally, fromException, throwIO, try)
import Control.Monad (replicateM_)
import Data.ByteString.Char8 qualified as BS
import Data.Map.Strict qualified as M
import GHC.Conc (BlockReason (BlockedOnException), ThreadStatus (ThreadBlocked), threadStatus)
import Kafka.Consumer qualified as C
import Kafka.DiagnosticTestSupport
import Kafka.Dump
import Kafka.Producer qualified as P
import Kafka.ProducerTestSupport (nativeProducerProperty, withProducerTracking)
import Kafka.TestEnv
import System.Directory (getTemporaryDirectory, removeFile)
import System.IO (Handle, SeekMode (AbsoluteSeek), hClose, hFlush, hIsClosed, hPutStr, hSeek, hTell, openBinaryTempFile)
import System.IO.Error (ioeGetLocation)
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))

tests :: TestEnv -> TestTree
tests env =
  testGroup "diagnostics" $
    [ testCase "supported-property print flushes and preserves the caller handle" $
        withDiagnosticFault 0 $
          withOutput $ \_ handle -> do
            hPutStr handle "before\n"
            hPrintSupportedKafkaConf handle
            hIsClosed handle >>= (@?= False)
            mapM diagnosticStat [2, 3] >>= (@?= [1, 1])
            output <- readOutput handle
            assertBool (show output) ("before\n" `BS.isPrefixOf` output && "bootstrap.servers" `BS.isInfixOf` output && "auto.offset.reset" `BS.isInfixOf` output)
            hPutStr handle "after\n"
            hFlush handle
            readOutput handle >>= \result -> assertBool (show result) ("after\n" `BS.isSuffixOf` result),
      testCase "client print flushes and preserves the caller handle" $
        bracket (P.newProducer (P.brokersList (teBrokers env) <> P.extraProp "client.id" "diagnostic-client") >>= either throwIO pure) releaseProducer $ \producer ->
          withDiagnosticFault 0 $ withOutput $ \_ handle -> do
            hPrintKafka handle producer
            hIsClosed handle >>= (@?= False)
            mapM diagnosticStat [2, 3] >>= (@?= [1, 1])
            output <- readOutput handle
            assertBool (show output) ("diagnostic-client" `BS.isInfixOf` output)
            hPutStr handle "after\n"
            hFlush handle
            readOutput handle >>= \result -> assertBool (show result) ("after\n" `BS.isSuffixOf` result),
      testCase "failed fdopen releases the duplicate and preserves the original" $
        withDiagnosticFault 3 $
          withOutput $ \_ handle -> do
            result <- try @IOException (hPrintSupportedKafkaConf handle)
            case result of
              Left exception -> ioeGetLocation exception @?= "fdopen"
              Right _ -> throwIO (userError "injected fdopen failure was suppressed")
            hIsClosed handle >>= (@?= False)
            mapM diagnosticStat [2, 3, 4] >>= (@?= [0, 0, 0])
            hPutStr handle "still usable"
            hFlush handle,
      testCase "failed native flush is reported after closing the duplicate" $
        withDiagnosticFault 4 $
          withOutput $ \_ handle -> do
            result <- try @IOException (hPrintSupportedKafkaConf handle)
            case result of
              Left exception -> ioeGetLocation exception @?= "fclose"
              Right _ -> throwIO (userError "injected fclose failure was suppressed")
            hIsClosed handle >>= (@?= False)
            mapM diagnosticStat [2, 3, 4] >>= (@?= [1, 1, 0])
            hPutStr handle "still usable"
            hFlush handle,
      testCase "producer configuration dumps retain exact explicit and global settings" $
        bracket (P.newProducer (P.brokersList (teBrokers env) <> P.extraProp "client.id" "diagnostic-global" <> P.extraTopicProps (M.fromList [("message.timeout.ms", "2345"), ("compression.codec", "gzip")])) >>= either throwIO pure) releaseProducer $ \producer ->
          withDiagnosticFault 0 $ do
            replicateM_ 100 $ do
              global <- dumpKafkaConf producer
              M.lookup "client.id" global @?= Just "diagnostic-global"
              topic <- dumpTopicConf producer
              M.lookup "message.timeout.ms" topic @?= Just "2345"
              M.lookup "compression.codec" topic @?= Just "gzip"
            mapM diagnosticStat [0, 1] >>= (@?= [200, 200]),
      testCase "producer constructor log level overrides the global property on the native client" $
        withTopic env "producer-constructor-log" 1 $ \topic _ ->
          bracket (P.newProducer (P.brokersList (teBrokers env) <> P.extraProp "log_level" "2" <> P.logLevel P.KafkaLogWarning) >>= either throwIO pure) releaseProducer $ \producer -> withProducerTracking 0 $ do
            produceTo producer topic 0 "native-log-level"
            nativeProducerProperty 5 >>= (@?= Just "4")
            config <- dumpKafkaConf producer
            M.lookup "log_level" config @?= Just "2",
      testCase "consumer configuration dump preserves global settings after subscription merge" $
        bracket (C.newAssignedConsumer (C.brokersList (teBrokers env) <> C.groupId (makeGroupId env "diagnostic") <> C.extraProp "client.id" "diagnostic-consumer" <> C.callbackPollMode C.CallbackPollModeSync) >>= either throwIO pure) releaseConsumer $ \consumer -> do
          config <- dumpKafkaConf consumer
          M.lookup "client.id" config @?= Just "diagnostic-consumer"
          M.lookup "group.id" config @?= Just (C.unConsumerGroupId (makeGroupId env "diagnostic")),
      testCase "native dump counter detects an unreleased array" $ withDiagnosticFault 0 $ do
        diagnosticLeakControl
        mapM diagnosticStat [0, 1] >>= (@?= [1, 0])
    ]
      <> [ testCase (label <> " cancellation at native acquisition releases its array") $
             bracket (P.newProducer (P.brokersList (teBrokers env)) >>= either throwIO pure) releaseProducer $ \producer ->
               withDiagnosticFault fault (cancelled (action producer))
         | (label, fault, action) <- [("global", 1, dumpKafkaConf), ("topic", 2, dumpTopicConf)]
         ]

cancelled :: IO a -> IO ()
cancelled action = do
  done <- newEmptyMVar
  worker <- forkIOWithUnmask $ \unmask -> try @SomeException (unmask action) >>= putMVar done
  let bounded operation = timeout 5000000 operation >>= maybe (throwIO (userError "dump worker timed out")) pure
  flip finally (releaseDiagnosticBoundary >> bounded (readMVar done)) $ do
    awaitDiagnosticBoundary
    mapM diagnosticStat [0, 1] >>= (@?= [1, 0])
    killed <- newEmptyMVar
    killer <- forkFinally (killThread worker) (putMVar killed)
    let pending = threadStatus killer >>= \status -> if status == ThreadBlocked BlockedOnException then pure () else threadDelay 1000 >> pending
    bounded pending
    releaseDiagnosticBoundary
    bounded (readMVar done) >>= \case
      Left exception -> (fromException exception :: Maybe AsyncException) @?= Just ThreadKilled
      Right _ -> throwIO (userError "dump cancellation was not delivered")
    bounded (readMVar killed) >>= either throwIO pure
    mapM diagnosticStat [0, 1] >>= (@?= [1, 1])

withOutput :: (FilePath -> Handle -> IO a) -> IO a
withOutput action = do
  directory <- getTemporaryDirectory
  bracket (openBinaryTempFile directory "rdkafka-diagnostic-") (\(path, handle) -> hClose handle `finally` removeFile path) (uncurry action)

readOutput :: Handle -> IO BS.ByteString
readOutput handle = do
  end <- hTell handle
  hSeek handle AbsoluteSeek 0
  result <- BS.hGet handle (fromIntegral end)
  hSeek handle AbsoluteSeek end
  pure result