packages feed

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

-- | Test-only algorithms with injectable operations. Production fixtures and
-- harness self-tests use the same error policy and deadline handling.
module Kafka.FixtureSupport
  ( collectExactly,
    sendAndConfirm,
    acquirePair,
  )
where

import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (bracketOnError)
import Data.Word (Word64)
import GHC.Clock (getMonotonicTimeNSec)
import Kafka.Producer

import System.Timeout (timeout)

-- | Only timeouts/EOF are idle. After the requested count, require an idle
-- poll so an unexpected extra record cannot be hidden by truncation.
collectExactly :: IO Word64 -> Word64 -> Int -> IO (Either KafkaError a) -> IO (Either KafkaError [a])
collectExactly clock budget count poll
  | count < 0 = pure (Left (KafkaError "negative expected record count"))
  | otherwise = do
      deadline <- (+ budget) <$> clock
      let go acc = do
            now <- clock
            if now >= deadline
              then pure (Left (KafkaError "record deadline expired before exact count and idle poll"))
              else
                poll >>= \case
                  Right record
                    | length acc == count -> pure (Left (KafkaError "unexpected extra record"))
                    | otherwise -> go (record : acc)
                  Left err
                    | idle err && length acc == count -> pure (Right (reverse acc))
                    | idle err -> go acc
                    | otherwise -> pure (Left err)
      go []
  where
    idle (KafkaResponseError RdKafkaRespErrTimedOut) = True
    idle (KafkaResponseError RdKafkaRespErrPartitionEof) = True
    idle _ = False

-- | Immediate rejection, delivery failure, missing callback, and failed flush
-- are distinct failures. One monotonic budget bounds the entire operation.
sendAndConfirm :: Int -> (ProducerRecord -> (DeliveryReport -> IO ()) -> IO (Either ImmediateError ())) -> (Timeout -> IO (Maybe KafkaError)) -> [ProducerRecord] -> IO (Either KafkaError ())
sendAndConfirm budget enqueue flush records = do
  deadline <- (+ fromIntegral budget * 1000) <$> getMonotonicTimeNSec
  let remaining = do
        now <- getMonotonicTimeNSec
        pure (if now >= deadline then 0 else fromIntegral ((deadline - now) `div` 1000))
      bounded action = remaining >>= \n -> if n <= 0 then pure Nothing else timeout n action
      go [] = pure (Right ())
      go (record : rest) = do
        report <- newEmptyMVar
        enqueue record (putMVar report) >>= \case
          Left (ImmediateError err) -> pure (Left err)
          Right () -> do
            micros <- remaining
            flush (Timeout (max 1 (micros `div` 1000))) >>= \case
              Just err -> pure (Left err)
              Nothing ->
                bounded (takeMVar report) >>= \case
                  Nothing -> pure (Left (KafkaError "delivery callback deadline expired"))
                  Just (DeliverySuccess actual _)
                    | sameRecord record actual -> go rest
                    | otherwise -> pure (Left (KafkaError "delivery report does not match enqueued record"))
                  Just (DeliveryFailure _ err) -> pure (Left err)
                  Just (NoMessageError err) -> pure (Left err)
  go records
  where
    sameRecord expected actual =
      prTopic expected == prTopic actual
        && prKey expected == prKey actual
        && prValue expected == prValue actual
        && prHeaders expected == prHeaders actual
        && case prPartition expected of
          UnassignedPartition -> True
          partition -> partition == prPartition actual

-- | Release the first resource when constructing the second fails, including
-- asynchronous exceptions. Successful pairs are released by the caller.
acquirePair :: IO a -> (a -> IO ()) -> IO b -> IO (a, b)
acquirePair first release second = bracketOnError first release $ \a -> (a,) <$> second