packages feed

rdkafka-hs-1.1.0.0: test-integration/Kafka/TestEnv.hs

-- | The integration suite's environment: which broker to use, the per-run name
-- prefix that isolates one run from another, client properties, and tasty
-- fixtures that create and release clients.
--
-- The suite only ever talks to the project's private Redpanda broker. The
-- broker comes from @RDKAFKA_TEST_BROKER@ (default 'defaultBroker'), and
-- 'resolveBrokers' rejects the machine-global broker on loopback port 9092
-- before any client is created, because the suite creates and deletes topics.
module Kafka.TestEnv
  ( -- * Environment
    TestEnv (..),
    defaultBroker,
    resolveBrokers,
    newTestEnv,
    checkBrokerReachable,

    -- * Prefixed names
    prefixedName,
    testGroupId,
    makeGroupId,
    isTestGroupId,
    isTestTopic,

    -- * Client properties
    consumerProps,
    consumerPropsNoStore,
    producerProps,
    testSubscription,

    -- * Fixtures
    mkProducer,
    mkConsumerWith,
    mkConsumerFor,
    withProducer,
    withConsumer,
    withKafka,
    withTopic,
    produceTo,

    -- * Messages
    receiveMessages,
    receiveTestMessages,
    releaseConsumer,
    releaseProducer,
    testMessages,
    sendMessages,
    sendMessagesWithHeaders,
  )
where

import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, tryPutMVar, tryReadMVar)
import Control.Exception (bracket, bracketOnError, finally, throwIO)
import Control.Monad (void)
import Data.ByteString qualified as BS
import Data.Char (isDigit)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Clock (getMonotonicTimeNSec)
import Kafka.Consumer as C
import Kafka.FixtureSupport (acquirePair, collectExactly, sendAndConfirm)
import Kafka.Metadata (allTopicsMetadata)
import Kafka.Producer as P
import Kafka.Topic (NewTopic (..), PartitionCount (..), ReplicationFactor (..), createTopic, deleteTopic)
import System.IO (hPutStrLn, stderr)
import System.Random qualified as Rnd
import System.Timeout (timeout)
import Test.Tasty (TestTree, withResource)

-- | Everything a test group needs to know about this run.
data TestEnv = TestEnv
  { -- | the private broker's bootstrap addresses
    teBrokers :: [BrokerAddress],
    -- | a random prefix carried by every topic and consumer group of this run
    tePrefix :: Text,
    -- | the run's main topic, @<prefix>-topic@
    teTopic :: TopicName
  }

-- | The private broker's Kafka listener: @redpanda.kafka-port@ in
-- @.seihou/manifest.json@.
defaultBroker :: Text
defaultBroker = "127.0.0.1:39092"

-- | Turn the value of @RDKAFKA_TEST_BROKER@ (a comma-separated list of
-- @host:port@ addresses, or nothing for 'defaultBroker') into broker
-- addresses, refusing the machine-global broker.
--
-- An address on a loopback host whose port is 9092, or that has no port (which
-- librdkafka treats as 9092), is rejected.
resolveBrokers :: Maybe String -> Either String [BrokerAddress]
resolveBrokers raw = do
  let value = maybe defaultBroker (Text.strip . Text.pack) raw
      addrs = filter (not . Text.null) (Text.strip <$> Text.splitOn "," value)
  if null addrs
    then Left "RDKAFKA_TEST_BROKER is empty; unset it to use the private broker at 127.0.0.1:39092."
    else traverse checkAddress addrs
  where
    checkAddress addr
      | isSharedBroker addr =
          Left $
            "RDKAFKA_TEST_BROKER names "
              <> Text.unpack addr
              <> ", the machine-global broker. The integration suite creates and deletes topics,"
              <> " so it only runs against the project's private broker; start it with"
              <> " `just redpanda-up` and unset RDKAFKA_TEST_BROKER (default 127.0.0.1:39092)."
      | otherwise = Right (BrokerAddress addr)

isSharedBroker :: Text -> Bool
isSharedBroker addr =
  let (host, port) = splitHostPort addr
   in isLoopback (Text.toLower host) && maybe True (== 9092) port

-- | Split @host:port@, @[v6]:port@, @host@ or @[v6]@. A bare IPv6 literal
-- without brackets has no port.
splitHostPort :: Text -> (Text, Maybe Int)
splitHostPort addr
  | Just rest <- Text.stripPrefix "[" addr =
      let (host, after) = Text.breakOn "]" rest
       in (host, Text.stripPrefix "]:" after >>= readPort)
  | Text.count ":" addr == 1 =
      let (host, port) = Text.breakOn ":" addr
       in (host, readPort (Text.drop 1 port))
  | otherwise = (addr, Nothing)
  where
    readPort p
      | not (Text.null p) && Text.all isDigit p = Just (read (Text.unpack p))
      | otherwise = Nothing

isLoopback :: Text -> Bool
isLoopback host =
  host `elem` ["localhost", "localhost.localdomain", "::1", "0:0:0:0:0:0:0:1", "0.0.0.0", "::"]
    || "127." `Text.isPrefixOf` host

-- | An environment for one run, with a fresh random ten-letter prefix.
newTestEnv :: [BrokerAddress] -> IO TestEnv
newTestEnv brokers = do
  prefix <- Text.pack . take 10 . Rnd.randomRs ('a', 'z') <$> Rnd.newStdGen
  hPutStrLn stderr ("[fixture] run-prefix=" <> Text.unpack prefix)
  pure
    TestEnv
      { teBrokers = brokers,
        tePrefix = prefix,
        teTopic = TopicName (prefix <> "-topic")
      }

-- | Ask the broker for metadata, giving up after ten seconds.
checkBrokerReachable :: TestEnv -> IO (Either String ())
checkBrokerReachable env = do
  res <- newProducer (P.brokersList (teBrokers env))
  case res of
    Left err -> pure (Left ("could not create a probe client: " <> show err))
    Right prod -> do
      meta <- allTopicsMetadata prod (Timeout 10000) `finally` releaseProducer prod
      pure $ case meta of
        Right _ -> Right ()
        Left err ->
          Left $
            "the Kafka broker at "
              <> Text.unpack (Text.intercalate "," (unBrokerAddress <$> teBrokers env))
              <> " did not answer a metadata request within 10 seconds ("
              <> show err
              <> "). Start the private broker with `just redpanda-up`."

-- | @<prefix>-<suffix>@.
prefixedName :: TestEnv -> Text -> Text
prefixedName env suffix = tePrefix env <> "-" <> suffix

-- | The run's shared consumer group, named after the prefix alone.
testGroupId :: TestEnv -> ConsumerGroupId
testGroupId env = ConsumerGroupId (tePrefix env)

makeGroupId :: TestEnv -> Text -> ConsumerGroupId
makeGroupId env = ConsumerGroupId . prefixedName env

isTestGroupId :: TestEnv -> ConsumerGroupId -> Bool
isTestGroupId env (ConsumerGroupId group) = tePrefix env `Text.isPrefixOf` group

isTestTopic :: TestEnv -> TopicName -> Bool
isTestTopic env (TopicName name) = (tePrefix env <> "-") `Text.isPrefixOf` name

consumerProps :: TestEnv -> ConsumerProperties
consumerProps env =
  C.brokersList (teBrokers env)
    <> groupId (testGroupId env)
    <> C.setCallback (logCallback (\l s1 s2 -> print $ "[Consumer] " <> show l <> ": " <> s1 <> ", " <> s2))
    <> C.setCallback (errorCallback (\e r -> print $ "[Consumer] " <> show e <> ": " <> r))
    <> noAutoCommit

consumerPropsNoStore :: TestEnv -> ConsumerProperties
consumerPropsNoStore env = consumerProps env <> noAutoOffsetStore

producerProps :: TestEnv -> ProducerProperties
producerProps env =
  P.brokersList (teBrokers env)
    <> P.setCallback (logCallback (\l s1 s2 -> print $ "[Producer] " <> show l <> ": " <> s1 <> ", " <> s2))
    <> P.setCallback (errorCallback (\e r -> print $ "[Producer] " <> show e <> ": " <> r))

testSubscription :: TopicName -> Subscription
testSubscription t =
  topics [t]
    <> offsetReset Earliest

mkProducer :: TestEnv -> IO KafkaProducer
mkProducer env =
  newProducer (producerProps env) >>= either (throwIO . userError . ("newProducer: " <>) . show) pure

-- | Subscribe a consumer to the run's topic and return once its first
-- partition assignment has been delivered, or fail after thirty seconds.
--
-- In 'CallbackPollModeSync' librdkafka only serves the rebalance callback from
-- inside a poll, so this keeps polling for events (without blocking, so no
-- record can be fetched and discarded) while it waits. In
-- 'CallbackPollModeAsync' the consumer's background loop serves it, and
-- polling here could discard a record that races the partition-queue redirect.
mkConsumerWith :: TestEnv -> ConsumerProperties -> IO KafkaConsumer
mkConsumerWith env = mkConsumerFor (teTopic env)

-- | Like 'mkConsumerWith', subscribed to the given topic.
mkConsumerFor :: TopicName -> ConsumerProperties -> IO KafkaConsumer
mkConsumerFor topic props = do
  assigned <- newEmptyMVar
  let props' = C.setCallback (rebalanceCallback (\_ evt -> onRebalance assigned evt)) <> props
      driveCallbacks c = case effectiveCallbackPollMode props' of
        CallbackPollModeSync -> pollConsumerEvents c Nothing
        CallbackPollModeAsync -> pure ()
  let acquire =
        newConsumer props' (testSubscription topic)
          >>= either (throwIO . userError . ("newConsumer: " <>) . show) pure
  bracketOnError acquire destroyConsumer $ \consumer -> do
    let awaitAssignment =
          tryReadMVar assigned >>= \case
            Just () -> pure ()
            Nothing -> do
              driveCallbacks consumer
              threadDelay 50000
              awaitAssignment
    timeout 30000000 awaitAssignment >>= \case
      Just () -> pure consumer
      Nothing -> throwIO (userError "consumer was not assigned a partition within 30 seconds")
  where
    onRebalance var = \case
      RebalanceAssign _ -> void (tryPutMVar var ())
      _ -> pure ()

withProducer :: TestEnv -> (IO KafkaProducer -> TestTree) -> TestTree
withProducer env = withResource (mkProducer env) releaseProducer

withConsumer :: TestEnv -> ConsumerProperties -> (IO KafkaConsumer -> TestTree) -> TestTree
withConsumer env props = withResource (mkConsumerWith env props) releaseConsumer

withKafka :: TestEnv -> ConsumerProperties -> (IO (KafkaConsumer, KafkaProducer) -> TestTree) -> TestTree
withKafka env props =
  withResource
    (acquirePair (mkConsumerWith env props) releaseConsumer (mkProducer env))
    (\(consumer, producer) -> releaseProducer producer `finally` releaseConsumer consumer)

-- | Create a topic named @<prefix>-<suffix>@ with the given number of
-- partitions, run the action with it and a producer, then delete the topic.
withTopic :: TestEnv -> Text -> Int -> (TopicName -> KafkaProducer -> IO a) -> IO a
withTopic env suffix partitions action =
  bracket (mkProducer env) releaseProducer $ \producer -> do
    let topic = TopicName (prefixedName env suffix)
        create =
          createTopic producer (NewTopic topic (PartitionCount partitions) (ReplicationFactor 1) mempty)
            >>= either (throwIO . userError . ("createTopic: " <>) . show) (checkTopic topic)
        remove =
          deleteTopic producer topic
            >>= either (throwIO . userError . ("deleteTopic: " <>) . show) (checkTopic topic)
    bracket create (const remove) (const (action topic producer))
  where
    checkTopic expected actual
      | expected == actual = pure ()
      | otherwise = throwIO (userError ("admin response topic mismatch: " <> show (expected, actual)))

releaseConsumer :: KafkaConsumer -> IO ()
releaseConsumer consumer =
  (closeConsumer consumer >>= mapM_ throwIO) `finally` destroyConsumer consumer

-- | Cleanup must not wait forever for an unreachable broker. Purge first;
-- this fixture is cleanup, delivery success is checked by sendMessages.
releaseProducer :: KafkaProducer -> IO ()
releaseProducer producer = do
  purgeProducer producer [PurgeQueue, PurgeInFlight] >>= mapM_ throwIO
  flushProducerTimeout producer (Timeout 5000) >>= mapM_ throwIO

-- | Produce one record and verify the matching successful delivery report.
produceTo :: KafkaProducer -> TopicName -> Int -> BS.ByteString -> IO ()
produceTo producer topic partition value =
  sendMessages [ProducerRecord topic (SpecifiedPartition partition) Nothing (Just value) mempty] producer
    >>= either throwIO pure

receiveMessages :: KafkaConsumer -> Int -> IO (Either KafkaError [ConsumerRecord (Maybe BS.ByteString) (Maybe BS.ByteString)])
receiveMessages kafka count = collectExactly getMonotonicTimeNSec 30000000000 count (pollMessage kafka (Timeout 200))

-- | Validate the inherited scenario's complete payload/key/header identities
-- and contiguous offsets, not just its count. Every send uses testMessages.
receiveTestMessages :: TestEnv -> KafkaConsumer -> Int -> Int -> IO (Either KafkaError [ConsumerRecord (Maybe BS.ByteString) (Maybe BS.ByteString)])
receiveTestMessages env consumer count start = do
  result <- receiveMessages consumer count
  pure $ do
    records <- result
    let expected = take count (cycle (testMessages (teTopic env)))
        actual = [ProducerRecord (crTopic r) UnassignedPartition (crKey r) (crValue r) (crHeaders r) | r <- records]
    if actual == expected
      && map crPartition records == replicate count (PartitionId 0)
      && map crOffset records == map (Offset . fromIntegral) [start .. start + count - 1]
      then Right records
      else Left (KafkaError ("unexpected record identities/offsets: " <> Text.pack (show records)))

testMessages :: TopicName -> [ProducerRecord]
testMessages t =
  [ ProducerRecord t UnassignedPartition Nothing (Just "test from producer") mempty,
    ProducerRecord t UnassignedPartition (Just "key") (Just "test from producer (with key)") mempty
  ]

sendMessages :: [ProducerRecord] -> KafkaProducer -> IO (Either KafkaError ())
sendMessages msgs prod = sendAndConfirm 10000000 (produceMessage' prod) (flushProducerTimeout prod) msgs

sendMessagesWithHeaders :: [ProducerRecord] -> Headers -> KafkaProducer -> IO (Either KafkaError ())
sendMessagesWithHeaders msgs hdrs = sendMessages [msg {prHeaders = hdrs} | msg <- msgs]