nri-kafka-0.4.0.1: test/Helpers.hs
-- | Tests, exposed so we can run them against GHCID
module Helpers
( spawnWorker,
stopWorker,
test,
sendSync,
spawnWorkerManagingOwnOffsets,
)
where
import qualified Control.Concurrent.Async as Async
import qualified Control.Concurrent.MVar as MVar
import qualified Control.Concurrent.STM as STM
import qualified Control.Exception.Safe as Exception
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy
import qualified Data.UUID
import qualified Data.UUID.V4
import qualified Dict
import qualified Environment
import qualified Expect
import qualified GHC.Stack as Stack
import qualified Kafka.Consumer as Consumer
import qualified Kafka.Internal as Internal
import qualified Kafka.Producer as Producer
import qualified Kafka.Settings as Settings
import qualified Kafka.Worker as Worker
import qualified Kafka.Worker.Internal
import qualified Kafka.Worker.Settings as Worker.Settings
import qualified Platform
import qualified Test
import qualified Prelude
-- | A reference to a Kafka worker. In practice, each worker will be running
-- in its own container. For testing purposes, we sometimes launch multiple
-- workers in a single test thread.
newtype Worker = Worker (Async.Async ())
data TestHandler = TestHandler
{ producer :: Producer.KafkaProducer,
terminator :: MVar.MVar (),
doAnything :: Platform.DoAnythingHandler
}
returnWhenTerminating :: TestHandler -> Prelude.IO ()
returnWhenTerminating TestHandler {terminator} = MVar.readMVar terminator
terminate :: TestHandler -> Prelude.IO ()
terminate TestHandler {terminator} = MVar.putMVar terminator ()
-- | Spawns a worker, guarded by the terminator 🦾
spawnWorker ::
(Aeson.ToJSON msg, Aeson.FromJSON msg) =>
TestHandler ->
Internal.Topic ->
(msg -> STM.STM ()) ->
Expect.Expectation' Worker
spawnWorker handler' topic callback =
Expect.fromIO <| do
settings <-
case Environment.decodeDefaults Worker.Settings.decoder of
Ok settings' -> Prelude.pure settings'
Err err -> Prelude.fail (Text.toList err)
async <-
Kafka.Worker.Internal.processWithoutShutdownEnsurance
settings
(Consumer.ConsumerGroupId "group")
( Worker.subscription
(Internal.unTopic topic)
( \msg -> do
callback msg
|> STM.atomically
|> map Ok
|> Platform.doAnything (doAnything handler')
)
)
|> Async.race_ (returnWhenTerminating handler')
|> Async.async
Async.link async
Prelude.pure (Worker async)
spawnWorkerManagingOwnOffsets ::
(Aeson.ToJSON msg, Aeson.FromJSON msg) =>
TestHandler ->
Internal.Topic ->
(Worker.PartitionOffset -> Worker.ProcessAttemptsCount -> msg -> STM.STM Worker.SeekCmd) ->
Expect.Expectation' Worker
spawnWorkerManagingOwnOffsets handler' topic callback =
Expect.fromIO <| do
settings <-
case Environment.decodeDefaults Worker.Settings.decoder of
Ok settings' -> Prelude.pure settings'
Err err -> Prelude.fail (Text.toList err)
async <-
Kafka.Worker.Internal.processWithoutShutdownEnsurance
settings
(Consumer.ConsumerGroupId "group")
( Worker.subscriptionManageOwnOffsets
(Internal.unTopic topic)
Worker.CommitToKafkaAsWell
( \partitions ->
partitions
|> List.map (\id -> Worker.PartitionOffset {Worker.partitionId = id, Worker.offset = 0})
|> Task.succeed
)
( \partitionOffset retryCount msg -> do
callback partitionOffset retryCount msg
|> STM.atomically
|> map Ok
|> Platform.doAnything (doAnything handler')
)
)
|> Async.race_ (returnWhenTerminating handler')
|> Async.async
Async.link async
Prelude.pure (Worker async)
-- | Stops a single worker
stopWorker :: Worker -> Expect.Expectation
stopWorker (Worker async) =
Async.cancel async
|> Expect.fromIO
-- | creates a test handler
testHandler :: Settings.Settings -> Prelude.IO TestHandler
testHandler Settings.Settings {Settings.brokerAddresses, Settings.deliveryTimeout, Settings.logLevel, Settings.batchNumMessages, Settings.compressionCodec} = do
doAnything <- Platform.doAnythingHandler
let properties =
Producer.brokersList brokerAddresses
++ Producer.sendTimeout deliveryTimeout
++ Producer.logLevel logLevel
++ Producer.compression compressionCodec
++ Producer.extraProps
( Dict.fromList
[ ( "batch.num.messages",
batchNumMessages
|> Settings.unBatchNumMessages
|> Text.fromInt
),
-- Enable idemptent producers
-- See https://www.cloudkarafka.com/blog/apache-kafka-idempotent-producer-avoiding-message-duplication.html for reference
("enable.idempotence", "true"),
("acks", "all")
]
)
eitherProducer <- Producer.newProducer properties
case eitherProducer of
Prelude.Left err ->
-- We create the handler as part of starting the application. Throwing
-- means that if there's a problem with the settings the application will
-- fail immediately upon start. It won't result in runtime errors during
-- operation.
Exception.throwIO err
Prelude.Right producer -> do
terminator <- MVar.newEmptyMVar
Prelude.pure TestHandler {producer, doAnything, terminator}
-- | puts a message synchronously onto a topic-partition
sendSync :: (Aeson.ToJSON a) => TestHandler -> Internal.Topic -> Int -> a -> Expect.Expectation
sendSync handler topicName partitionId msg' =
Platform.tracingSpan
"Sync send Kafka messages"
( sendHelperSync
(producer handler)
(doAnything handler)
topicName
partitionId
(Aeson.toJSON msg')
)
|> Expect.succeeds
sendHelperSync ::
Producer.KafkaProducer ->
Platform.DoAnythingHandler ->
Internal.Topic ->
Int ->
Aeson.Value ->
Task Text ()
sendHelperSync producer doAnything topicName partitionId msg' =
Exception.handleAny
(\exception -> Prelude.pure (Err (Debug.toString exception)))
( do
res <- Producer.produceMessage producer (record topicName partitionId msg')
case res of
Nothing -> Prelude.pure ()
Just err -> Exception.throwIO err
-- by flushing the producer immediately after producing a message,
-- we make this function synchronous. Without flush it's by default asynchronous.
Producer.flushProducer producer
Prelude.pure (Ok ())
)
|> Platform.doAnything doAnything
record :: Internal.Topic -> Int -> Aeson.Value -> Producer.ProducerRecord
record topicName partitionId val =
Producer.ProducerRecord
{ Producer.prTopic = Producer.TopicName (Internal.unTopic topicName),
Producer.prPartition = Producer.SpecifiedPartition (Prelude.fromIntegral partitionId),
Producer.prKey = Nothing,
Producer.prValue =
Internal.MsgWithMetaData
{ Internal.metaData =
Internal.MetaData
{ Internal.requestId = "test-request"
},
Internal.value = Internal.Encodable val
}
|> Aeson.encode
|> Data.ByteString.Lazy.toStrict
|> Just
}
-- | test helper, that yields a new @Kafka.Topic@ and @TestHandler@
test ::
(Stack.HasCallStack) =>
Text ->
((Internal.Topic, TestHandler) -> Expect.Expectation) ->
Test.Test
test description body =
Stack.withFrozenCallStack Test.test description <| \_ -> do
doAnything <- Expect.fromIO Platform.doAnythingHandler
Expect.around
( \task' ->
Platform.bracketWithError
( -- create handler
Platform.doAnything doAnything <|
case Environment.decodeDefaults Settings.decoder of
Ok settings ->
map
Ok
( testHandler
settings
{ Settings.batchNumMessages = Settings.exampleBatchNumMessages
}
)
Err err -> Debug.todo ("Failed to to decode worker settings" ++ err)
)
( -- terminate all workers hanging around after the test is over
\_maybeErr handler' ->
terminate handler'
|> map Ok
|> Platform.doAnything doAnything
)
( -- yield the test
\handler' -> do
uuid <-
map Data.UUID.toText Data.UUID.V4.nextRandom
|> map Ok
|> Platform.doAnything doAnything
let topic = Internal.Topic uuid
task' (topic, handler')
)
)
body